use std::{ borrow::Cow, collections::{BTreeMap, HashMap, VecDeque}, path::Path, }; use anyhow::{bail, Context}; use memofs::Vfs; use rbx_dom_weak::{ types::{Attributes, Ref, Variant}, ustr, HashMapExt as _, Instance, Ustr, UstrMap, }; use rbx_reflection::ClassTag; use crate::{ project::{PathNode, Project, ProjectNode}, resolution::UnresolvedValue, snapshot::{ InstanceContext, InstanceMetadata, InstanceSnapshot, InstanceWithMeta, InstigatingSource, PathIgnoreRule, SyncRule, }, snapshot_middleware::Middleware, syncback::{filter_properties, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, variant_eq::variant_eq, RojoRef, }; use super::{emit_legacy_scripts_default, snapshot_from_vfs}; pub fn snapshot_project( context: &InstanceContext, vfs: &Vfs, path: &Path, name: &str, ) -> anyhow::Result> { let project = Project::load_exact(vfs, path, Some(name)) .with_context(|| format!("File was not a valid Rojo project: {}", path.display()))?; let project_name = match project.name.as_deref() { Some(name) => name, None => panic!("Project is missing a name"), }; let mut context = context.clone(); context.clear_sync_rules(); let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule { glob: glob.clone(), base_path: project.folder_location().to_path_buf(), }); let sync_rules = project.sync_rules.iter().map(|rule| SyncRule { base_path: project.folder_location().to_path_buf(), ..rule.clone() }); context.add_sync_rules(sync_rules); context.add_path_ignore_rules(rules); context.set_emit_legacy_scripts( project .emit_legacy_scripts .or_else(emit_legacy_scripts_default) .unwrap(), ); match snapshot_project_node(&context, path, project_name, &project.tree, vfs, None)? { Some(found_snapshot) => { let mut snapshot = found_snapshot; // Setting the instigating source to the project file path is a little // coarse. // // Ideally, we'd only snapshot the project file if the project file // actually changed. Because Rojo only has the concept of one // relevant path -> snapshot path mapping per instance, we pick the more // conservative approach of snapshotting the project file if any // relevant paths changed. snapshot.metadata.instigating_source = Some(path.to_path_buf().into()); // Mark this snapshot (the root node of the project file) as being // related to the project file. // // We SHOULD NOT mark the project file as a relevant path for any // nodes that aren't roots. They'll be updated as part of the project // file being updated. snapshot.metadata.relevant_paths.push(path.to_path_buf()); // When git filter is active, also register the project folder as a // relevant path. This serves as a catch-all so that file changes // not under any specific $path node can still walk up the directory // tree and trigger a re-snapshot of the entire project. if context.has_git_filter() { if let Some(folder) = path.parent() { let normalized = vfs .canonicalize(folder) .unwrap_or_else(|_| folder.to_path_buf()); snapshot.metadata.relevant_paths.push(normalized); } } Ok(Some(snapshot)) } None => Ok(None), } } pub fn snapshot_project_node( context: &InstanceContext, project_path: &Path, instance_name: &str, node: &ProjectNode, vfs: &Vfs, parent_class: Option<&str>, ) -> anyhow::Result> { let project_folder = project_path.parent().unwrap(); let mut class_name_from_path = None; let name = Cow::Owned(instance_name.to_owned()); let mut properties = UstrMap::new(); let mut children = Vec::new(); let mut metadata = InstanceMetadata::new().context(context); if let Some(path_node) = &node.path { let path = path_node.path(); // If the path specified in the project is relative, we assume it's // relative to the folder that the project is in, project_folder. let full_path = if path.is_relative() { Cow::Owned(project_folder.join(path)) } else { Cow::Borrowed(path) }; if let Some(snapshot) = snapshot_from_vfs(context, vfs, &full_path)? { class_name_from_path = Some(snapshot.class_name); // Properties from the snapshot are pulled in unchanged, and // overridden by properties set on the project node. properties.reserve(snapshot.properties.len()); for (key, value) in snapshot.properties.into_iter() { properties.insert(key, value); } // The snapshot's children will be merged with the children defined // in the project node, if there are any. children.reserve(snapshot.children.len()); for child in snapshot.children.into_iter() { children.push(child); } // Take the snapshot's metadata as-is, which will be mutated later // on. metadata = snapshot.metadata; } else if context.has_git_filter() { // When the git filter is active and the $path was filtered out // (no acknowledged files yet), we still need to register the path // in relevant_paths. This allows the change processor to map file // changes in this directory back to this project node instance, // triggering a re-snapshot that will pick up newly modified files. let normalized = vfs .canonicalize(full_path.as_ref()) .unwrap_or_else(|_| full_path.to_path_buf()); metadata.relevant_paths.push(normalized); // The VFS only sets up file watches via read() and read_dir(), // not via metadata(). Since the git filter caused snapshot_from_vfs // to return early (before read_dir was called), the VFS is not // watching this path. We must read the directory here to ensure // the VFS sets up a recursive watch, otherwise file change events // will never fire and live sync won't detect modifications. if full_path.is_dir() { let _ = vfs.read_dir(&full_path); } } } let class_name_from_inference = infer_class_name(&name, parent_class); let class_name = match ( node.class_name, class_name_from_path, class_name_from_inference, &node.path, ) { // These are the easy, happy paths! (Some(project), None, None, _) => project, (None, Some(path), None, _) => path, (None, None, Some(inference), _) => inference, // If the user specifies a class name, but there's an inferred class // name, we prefer the name listed explicitly by the user. (Some(project), None, Some(_), _) => project, // If the user has a $path pointing to a folder and we're able to infer // a class name, let's use the inferred name. If the path we're pointing // to isn't a folder, though, that's a user error. (None, Some(path), Some(inference), _) => { if path == "Folder" { inference } else { path } } (Some(project), Some(path), _, _) => { if path == "Folder" { project } else { bail!( "ClassName for Instance \"{}\" was specified in both the project file (as \"{}\") and from the filesystem (as \"{}\").\n\ If $className and $path are both set, $path must refer to a Folder. \n\ Project path: {}\n\ Filesystem path: {}\n", instance_name, project, path, project_path.display(), node.path.as_ref().unwrap().path().display() ); } } (None, None, None, Some(PathNode::Optional(_))) => { return Ok(None); } (_, None, _, Some(PathNode::Required(path))) => { // If git filter is active and the path was filtered out, treat it // as if the path was optional and skip this node. if context.has_git_filter() { log::trace!( "Skipping project node '{}' because its path was filtered by git filter: {}", instance_name, path.display() ); return Ok(None); } anyhow::bail!( "Rojo project referred to a file using $path that could not be turned into a Roblox Instance by Rojo.\n\ Check that the file exists and is a file type known by Rojo.\n\ \n\ Project path: {}\n\ File $path: {}", project_path.display(), path.display(), ); } (None, None, None, None) => { bail!( "Instance \"{}\" is missing some required information.\n\ One of the following must be true:\n\ - $className must be set to the name of a Roblox class\n\ - $path must be set to a path of an instance\n\ - The instance must be a known service, like ReplicatedStorage\n\ \n\ Project path: {}", instance_name, project_path.display(), ); } }; for (child_name, child_project_node) in &node.children { if let Some(child) = snapshot_project_node( context, project_path, child_name, child_project_node, vfs, Some(&class_name), )? { children.push(child); } } for (key, unresolved) in &node.properties { let value = unresolved .clone() .resolve(&class_name, key) .with_context(|| { format!( "Unresolvable property in project at path {}", project_path.display() ) })?; match key.as_str() { "Name" | "Parent" => { log::warn!( "Property '{}' cannot be set manually, ignoring. Attempted to set in '{}' at {}", key, instance_name, project_path.display() ); continue; } _ => {} } properties.insert(*key, value); } if !node.attributes.is_empty() { let mut attributes = Attributes::new(); for (key, unresolved) in &node.attributes { let value = unresolved.clone().resolve_unambiguous().with_context(|| { format!( "Unresolvable attribute in project at path {}", project_path.display() ) })?; attributes.insert(key.clone(), value); } properties.insert("Attributes".into(), attributes.into()); } // If the user specified $ignoreUnknownInstances, overwrite the existing // value. // // If the user didn't specify it AND $path was not specified (meaning // there's no existing value we'd be stepping on from a project file or meta // file), set it to true. // // When git filter is active, always set to true to preserve descendants // in Studio that are not tracked by Rojo. if context.has_git_filter() { metadata.ignore_unknown_instances = true; } else if let Some(ignore) = node.ignore_unknown_instances { metadata.ignore_unknown_instances = ignore; } else if node.path.is_none() { // TODO: Introduce a strict mode where $ignoreUnknownInstances is never // set implicitly. metadata.ignore_unknown_instances = true; } if let Some(id) = &node.id { metadata.specified_id = Some(RojoRef::new(id.clone())) } metadata.instigating_source = Some(InstigatingSource::ProjectNode { path: project_path.to_path_buf(), name: instance_name.to_string(), node: node.clone(), parent_class: parent_class.map(|name| name.to_owned()), }); Ok(Some(InstanceSnapshot { snapshot_id: Ref::none(), name, class_name, properties, children, metadata, })) } pub fn syncback_project<'sync>( snapshot: &SyncbackSnapshot<'sync>, ) -> anyhow::Result> { let old_inst = snapshot .old_inst() .expect("projects should always exist in both trees"); // Generally, the path of a project is the first thing added to the relevant // paths. So, we take the last one. let project_path = old_inst .metadata() .relevant_paths .last() .expect("all projects should have a relevant path"); let vfs = snapshot.vfs(); log::debug!("Reloading project {} from vfs", project_path.display(),); let mut project = Project::load_exact(vfs, project_path, None)?; let base_path = project.folder_location().to_path_buf(); // Sync rules for this project do not have their base rule set but it is // important when performing syncback on other projects. for rule in &mut project.sync_rules { rule.base_path.clone_from(&base_path) } let mut descendant_snapshots = Vec::new(); let mut removed_descendants = Vec::new(); let mut ref_to_path_map = HashMap::new(); let mut old_child_map = HashMap::new(); let mut new_child_map = HashMap::new(); let mut node_changed_map = Vec::new(); let mut node_queue = VecDeque::with_capacity(1); node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst())); while let Some((node, old_inst, new_inst)) = node_queue.pop_front() { log::debug!("Processing node {}", old_inst.name()); if old_inst.class_name() != new_inst.class { anyhow::bail!( "Cannot change the class of {} in project file {}.\n\ Current class is {}, it is a {} in the input file.", old_inst.name(), project_path.display(), old_inst.class_name(), new_inst.class ); } // TODO handle meta.json files in this branch. Right now, we perform // syncback if a node has `$path` set but the Middleware aren't aware // that the Instances they're running on originate in a project.json. // As a result, the `meta.json` syncback code is hardcoded to not work // if the Instance originates from a project file. However, we should // ideally use a .meta.json over the project node if it exists already. if node.path.is_some() { // Since the node has a path, we have to run syncback on it. let node_path = node.path.as_ref().map(PathNode::path).expect( "Project nodes with a path must have a path \ If you see this message, something went seriously wrong. Please report it.", ); let full_path = if node_path.is_absolute() { node_path.to_path_buf() } else { base_path.join(node_path) }; let middleware = match Middleware::middleware_for_path( snapshot.vfs(), &project.sync_rules, &full_path, )? { Some(middleware) => middleware, // The only way this can happen at this point is if the path does // not exist on the file system or there's no middleware for it. None => anyhow::bail!( "path does not exist or could not be turned into a file Rojo understands: {}", full_path.display() ), }; descendant_snapshots.push( snapshot .with_new_path(full_path.clone(), new_inst.referent(), Some(old_inst.id())) .middleware(middleware), ); ref_to_path_map.insert(new_inst.referent(), full_path); // We only want to set properties if it needs it. if !middleware.handles_own_properties() { project_node_property_syncback_path(snapshot, new_inst, node); } } else { project_node_property_syncback_no_path(snapshot, new_inst, node); } for child_ref in new_inst.children() { let child = snapshot .get_new_instance(*child_ref) .expect("all children of Instances should be in new DOM"); if new_child_map.insert(&child.name, child).is_some() { anyhow::bail!( "Instances that are direct children of an Instance that is made by a project file \ must have a unique name.\nThe child '{}' of '{}' is duplicated in the place file.", child.name, old_inst.name() ); } } for child_ref in old_inst.children() { let child = snapshot .get_old_instance(*child_ref) .expect("all children of Instances should be in old DOM"); if old_child_map.insert(child.name(), child).is_some() { anyhow::bail!( "Instances that are direct children of an Instance that is made by a project file \ must have a unique name.\nThe child '{}' of '{}' is duplicated on the file system.", child.name(), old_inst.name() ); } } // This loop does basic matching of Instance children to the node's // children. It ensures that `new_child_map` and `old_child_map` will // only contain Instances that don't belong to the project after this. for (child_name, child_node) in &mut node.children { // If a node's path is optional, we want to skip it if the path // doesn't exist since it isn't in the current old DOM. if let Some(path) = &child_node.path { if path.is_optional() { let real_path = if path.path().is_absolute() { path.path().to_path_buf() } else { base_path.join(path.path()) }; if !real_path.exists() { log::warn!( "Skipping node '{child_name}' of project because it is optional and not present on the disk.\n\ If this is not deliberate, please create a file or directory at {}", real_path.display() ); continue; } } } let new_equivalent = new_child_map.remove(child_name); let old_equivalent = old_child_map.remove(child_name.as_str()); match (new_equivalent, old_equivalent) { (Some(new), Some(old)) => node_queue.push_back((child_node, old, new)), (_, None) => anyhow::bail!( "The child '{child_name}' of Instance '{}' would be removed.\n\ Syncback cannot add or remove Instances from project {}", old_inst.name(), project_path.display() ), (None, _) => anyhow::bail!( "The child '{child_name}' of Instance '{}' is present only in a project file,\n\ and not the provided file. Syncback cannot add or remove Instances from project:\n{}.", old_inst.name(), project_path.display(), ) } } // All of the children in this loop are by their nature not in the // project, so we just need to run syncback on them. for (name, new_child) in new_child_map.drain() { let parent_path = match ref_to_path_map.get(&new_child.parent()) { Some(path) => path.clone(), None => { log::debug!("Skipping child {name} of node because it has no parent_path"); continue; } }; // If a child also exists in the old tree, it will be caught in the // syncback on the project node path above (or is itself a node). // So the only things we need to run seperately is new children. if old_child_map.remove(name.as_str()).is_none() { let parent_middleware = Middleware::middleware_for_path(vfs, &project.sync_rules, &parent_path)? .expect("project nodes should have a middleware if they have children."); // If this node points directly to a project, it may still have // children but they'll be handled by syncback. This isn't a // concern with directories because they're singular things, // files that contain their own children. if parent_middleware != Middleware::Project { descendant_snapshots.push(snapshot.with_base_path( &parent_path, new_child.referent(), None, )?); } } } removed_descendants.extend(old_child_map.drain().map(|(_, v)| v)); node_changed_map.push((&node.properties, &node.attributes, old_inst)) } let mut fs_snapshot = FsSnapshot::new(); for (node_properties, node_attributes, old_inst) in node_changed_map { if project_node_should_reserialize(node_properties, node_attributes, old_inst)? { fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?); break; } } Ok(SyncbackReturn { fs_snapshot, children: descendant_snapshots, removed_children: removed_descendants, }) } fn project_node_property_syncback( _snapshot: &SyncbackSnapshot, filtered_properties: UstrMap<&Variant>, new_inst: &Instance, node: &mut ProjectNode, ) { let properties = &mut node.properties; let mut attributes = BTreeMap::new(); for (name, value) in filtered_properties { match value { Variant::Attributes(attrs) => { for (attr_name, attr_value) in attrs.iter() { // We (probably) don't want to preserve internal attributes, // only user defined ones. if attr_name.starts_with("RBX") { continue; } attributes.insert( attr_name.clone(), UnresolvedValue::from_variant_unambiguous(attr_value.clone()), ); } } _ => { properties.insert( name, UnresolvedValue::from_variant(value.clone(), &new_inst.class, &name), ); } } } node.attributes = attributes; } fn project_node_property_syncback_path( snapshot: &SyncbackSnapshot, new_inst: &Instance, node: &mut ProjectNode, ) { let filtered_properties = snapshot .get_path_filtered_properties(new_inst.referent()) .unwrap(); project_node_property_syncback(snapshot, filtered_properties, new_inst, node) } fn project_node_property_syncback_no_path( snapshot: &SyncbackSnapshot, new_inst: &Instance, node: &mut ProjectNode, ) { let filtered_properties = filter_properties(snapshot.project(), new_inst); project_node_property_syncback(snapshot, filtered_properties, new_inst, node) } fn project_node_should_reserialize( node_properties: &BTreeMap, node_attributes: &BTreeMap, instance: InstanceWithMeta, ) -> anyhow::Result { for (prop_name, unresolved_node_value) in node_properties { if let Some(inst_value) = instance.properties().get(prop_name) { let node_value = unresolved_node_value .clone() .resolve(&instance.class_name(), prop_name)?; if !variant_eq(inst_value, &node_value) { return Ok(true); } } else { return Ok(true); } } match instance.properties().get(&ustr("Attributes")) { Some(Variant::Attributes(inst_attributes)) => { // This will also catch if one is empty but the other isn't if node_attributes.len() != inst_attributes.len() { Ok(true) } else { for (attr_name, unresolved_node_value) in node_attributes { if let Some(inst_value) = inst_attributes.get(attr_name.as_str()) { let node_value = unresolved_node_value.clone().resolve_unambiguous()?; if !variant_eq(inst_value, &node_value) { return Ok(true); } } else { return Ok(true); } } Ok(false) } } Some(_) => Ok(true), None => { if !node_attributes.is_empty() { Ok(true) } else { Ok(false) } } } } fn infer_class_name(name: &str, parent_class: Option<&str>) -> Option { // If className wasn't defined from another source, we may be able // to infer one. let parent_class = parent_class?; if parent_class == "DataModel" { // Members of DataModel with names that match known services are // probably supposed to be those services. let descriptor = rbx_reflection_database::get().unwrap().classes.get(name)?; if descriptor.tags.contains(&ClassTag::Service) { return Some(ustr(name)); } } else if parent_class == "StarterPlayer" { // StarterPlayer has two special members with their own classes. if name == "StarterPlayerScripts" || name == "StarterCharacterScripts" { return Some(ustr(name)); } } else if parent_class == "Workspace" { // Workspace has a special Terrain class inside it if name == "Terrain" { return Some(ustr(name)); } } None } // #[cfg(feature = "broken-tests")] #[cfg(test)] mod test { use super::*; use memofs::{InMemoryFs, VfsSnapshot}; #[ignore = "Functionality moved to root snapshot middleware"] #[test] fn project_from_folder() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([( "default.project.json", VfsSnapshot::file( r#" { "name": "indirect-project", "tree": { "$className": "Folder" } } "#, ), )]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_from_direct_file() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([( "hello.project.json", VfsSnapshot::file( r#" { "name": "direct-project", "tree": { "$className": "Model" } } "#, ), )]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo/hello.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_with_resolved_properties() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo.project.json", VfsSnapshot::file( r#" { "name": "resolved-properties", "tree": { "$className": "StringValue", "$properties": { "Value": { "String": "Hello, world!" } } } } "#, ), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_with_unresolved_properties() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo.project.json", VfsSnapshot::file( r#" { "name": "unresolved-properties", "tree": { "$className": "StringValue", "$properties": { "Value": "Hi!" } } } "#, ), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_with_children() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo.project.json", VfsSnapshot::file( r#" { "name": "children", "tree": { "$className": "Folder", "Child": { "$className": "Model" } } } "#, ), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_with_path_to_txt() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([ ( "default.project.json", VfsSnapshot::file( r#" { "name": "path-project", "tree": { "$path": "other.txt" } } "#, ), ), ("other.txt", VfsSnapshot::file("Hello, world!")), ]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo/default.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_with_path_to_project() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([ ( "default.project.json", VfsSnapshot::file( r#" { "name": "path-project", "tree": { "$path": "other.project.json" } } "#, ), ), ( "other.project.json", VfsSnapshot::file( r#" { "name": "other-project", "tree": { "$className": "Model" } } "#, ), ), ]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo/default.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn project_with_path_to_project_with_children() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([ ( "default.project.json", VfsSnapshot::file( r#" { "name": "path-child-project", "tree": { "$path": "other.project.json" } } "#, ), ), ( "other.project.json", VfsSnapshot::file( r#" { "name": "other-project", "tree": { "$className": "Folder", "SomeChild": { "$className": "Model" } } } "#, ), ), ]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo/default.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } /// Ensures that if a property is defined both in the resulting instance /// from $path and also in $properties, that the $properties value takes /// precedence. #[test] fn project_path_property_overrides() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([ ( "default.project.json", VfsSnapshot::file( r#" { "name": "path-property-override", "tree": { "$path": "other.project.json", "$properties": { "Value": "Changed" } } } "#, ), ), ( "other.project.json", VfsSnapshot::file( r#" { "name": "other-project", "tree": { "$className": "StringValue", "$properties": { "Value": "Original" } } } "#, ), ), ]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo/default.project.json"), "NOT_IN_SNAPSHOT", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn no_name_project() { let _ = env_logger::try_init(); let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([( "default.project.json", VfsSnapshot::file( r#" { "tree": { "$className": "Model" } } "#, ), )]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_project( &InstanceContext::default(), &vfs, Path::new("/foo/default.project.json"), "no_name_project", ) .expect("snapshot error") .expect("snapshot returned no instances"); insta::assert_yaml_snapshot!(instance_snapshot); } }