use std::{ collections::{HashMap, HashSet}, path::Path, }; use anyhow::Context; use memofs::{DirEntry, Vfs}; use crate::{ snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource}, syncback::{hash_instance, slugify_name, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, }; use super::{meta_file::DirectoryMetadata, snapshot_from_vfs}; const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep"; pub fn snapshot_dir( context: &InstanceContext, vfs: &Vfs, path: &Path, name: &str, ) -> anyhow::Result> { let mut snapshot = match snapshot_dir_no_meta(context, vfs, path, name)? { Some(snapshot) => snapshot, None => return Ok(None), }; DirectoryMetadata::read_and_apply_all(vfs, path, &mut snapshot)?; Ok(Some(snapshot)) } /// Snapshot a directory without applying meta files; useful for if the /// directory's ClassName will change before metadata should be applied. For /// example, this can happen if the directory contains an `init.client.lua` /// file. pub fn snapshot_dir_no_meta( context: &InstanceContext, vfs: &Vfs, path: &Path, name: &str, ) -> anyhow::Result> { let passes_filter_rules = |child: &DirEntry| { context .path_ignore_rules .iter() .all(|rule| rule.passes(child.path())) }; let mut snapshot_children = Vec::new(); for entry in vfs.read_dir(path)? { let entry = entry?; if !passes_filter_rules(&entry) { continue; } if let Some(child_snapshot) = snapshot_from_vfs(context, vfs, entry.path())? { snapshot_children.push(child_snapshot); } } let normalized_path = vfs.canonicalize(path)?; let relevant_paths = vec![ normalized_path.clone(), // TODO: We shouldn't need to know about Lua existing in this // middleware. Should we figure out a way for that function to add // relevant paths to this middleware? normalized_path.join("init.lua"), normalized_path.join("init.luau"), normalized_path.join("init.server.lua"), normalized_path.join("init.server.luau"), normalized_path.join("init.client.lua"), normalized_path.join("init.client.luau"), normalized_path.join("init.csv"), ]; let snapshot = InstanceSnapshot::new() .name(name) .class_name("Folder") .children(snapshot_children) .metadata( InstanceMetadata::new() .instigating_source(path) .relevant_paths(relevant_paths) .context(context), ); Ok(Some(snapshot)) } pub fn syncback_dir<'sync>( snapshot: &SyncbackSnapshot<'sync>, ) -> anyhow::Result> { let new_inst = snapshot.new_inst(); let mut dir_syncback = syncback_dir_no_meta(snapshot)?; let mut meta = DirectoryMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?; if let Some(meta) = &mut meta { if new_inst.class != "Folder" { meta.class_name = Some(new_inst.class); } if !meta.is_empty() { dir_syncback.fs_snapshot.add_file( snapshot.path.join("init.meta.json"), serde_json::to_vec_pretty(&meta) .context("could not serialize new init.meta.json")?, ); } } let metadata_empty = meta .as_ref() .map(DirectoryMetadata::is_empty) .unwrap_or_default(); if new_inst.children().is_empty() && metadata_empty { dir_syncback .fs_snapshot .add_file(snapshot.path.join(EMPTY_DIR_KEEP_NAME), Vec::new()) } Ok(dir_syncback) } pub fn syncback_dir_no_meta<'sync>( snapshot: &SyncbackSnapshot<'sync>, ) -> anyhow::Result> { let new_inst = snapshot.new_inst(); let mut children = Vec::new(); let mut removed_children = Vec::new(); // Build the old child map early so it can be used for deduplication below. let mut old_child_map = HashMap::new(); if let Some(old_inst) = snapshot.old_inst() { for child in old_inst.children() { let inst = snapshot.get_old_instance(*child).unwrap(); old_child_map.insert(inst.name(), inst); } } // Enforce unique filesystem names. Uses actual on-disk names for existing // children and resolved names (with init-prefix) for new ones. let mut fs_child_names = HashSet::with_capacity(new_inst.children().len()); let mut duplicate_set = HashSet::new(); for child_ref in new_inst.children() { let child = snapshot.get_new_instance(*child_ref).unwrap(); let fs_name = old_child_map .get(child.name.as_str()) .and_then(|old| old.metadata().relevant_paths.first()) .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .map(|s| s.to_lowercase()) .unwrap_or_else(|| { let slug = slugify_name(&child.name); let slug_lower = slug.to_lowercase(); // Mirror name_for_inst's init-prefix. if slug_lower == "init" { format!("_{slug_lower}") } else { slug_lower } }); if !fs_child_names.insert(fs_name) { duplicate_set.insert(child.name.as_str()); } } if !duplicate_set.is_empty() { if duplicate_set.len() <= 25 { anyhow::bail!( "Instance has children with duplicate name (case may not exactly match):\n {}", duplicate_set.into_iter().collect::>().join(", ") ); } anyhow::bail!("Instance has more than 25 children with duplicate names"); } if snapshot.old_inst().is_some() { for new_child_ref in new_inst.children() { let new_child = snapshot.get_new_instance(*new_child_ref).unwrap(); if let Some(old_child) = old_child_map.remove(new_child.name.as_str()) { if old_child.metadata().relevant_paths.is_empty() { log::debug!( "Skipping instance {} because it doesn't exist on the disk", old_child.name() ); continue; } else if matches!( old_child.metadata().instigating_source, Some(InstigatingSource::ProjectNode { .. }) ) { log::debug!( "Skipping instance {} because it originates in a project file", old_child.name() ); continue; } // This child exists in both doms. Pass it on. children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?); } else { // The child only exists in the the new dom children.push(snapshot.with_joined_path(*new_child_ref, None)?); } } // Any children that are in the old dom but not the new one are removed. removed_children.extend(old_child_map.into_values()); } else { // There is no old instance. Just add every child. for new_child_ref in new_inst.children() { children.push(snapshot.with_joined_path(*new_child_ref, None)?); } } let mut fs_snapshot = FsSnapshot::new(); if let Some(old_ref) = snapshot.old { let new_hash = hash_instance(snapshot.project(), snapshot.new_tree(), snapshot.new) .expect("new Instance should be hashable"); let old_hash = hash_instance(snapshot.project(), snapshot.old_tree(), old_ref) .expect("old Instance should be hashable"); if old_hash != new_hash { fs_snapshot.add_dir(&snapshot.path); } else { log::debug!( "Skipping reserializing directory {} because old and new tree hash the same", new_inst.name ); } } else { fs_snapshot.add_dir(&snapshot.path); } Ok(SyncbackReturn { fs_snapshot, children, removed_children, }) } #[cfg(test)] mod test { use super::*; use std::path::PathBuf; use crate::{ snapshot::{InstanceMetadata, InstanceSnapshot}, Project, RojoTree, SyncbackData, SyncbackSnapshot, }; use memofs::{InMemoryFs, VfsSnapshot}; #[test] fn empty_folder() { let mut imfs = InMemoryFs::new(); imfs.load_snapshot("/foo", VfsSnapshot::empty_dir()) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_dir(&InstanceContext::default(), &vfs, Path::new("/foo"), "foo") .unwrap() .unwrap(); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn folder_in_folder() { let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo", VfsSnapshot::dir([("Child", VfsSnapshot::empty_dir())]), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_dir(&InstanceContext::default(), &vfs, Path::new("/foo"), "foo") .unwrap() .unwrap(); insta::assert_yaml_snapshot!(instance_snapshot); } fn make_project() -> Project { serde_json::from_str(r#"{"tree": {"$className": "DataModel"}}"#).unwrap() } fn make_vfs() -> Vfs { let mut imfs = InMemoryFs::new(); imfs.load_snapshot("/root", VfsSnapshot::empty_dir()).unwrap(); Vfs::new(imfs) } /// Two children whose Roblox names are identical when lowercased ("Alpha" /// and "alpha") but live at different filesystem paths because of the /// `name` property ("Beta/" and "Alpha/" respectively). The dedup check /// must use the actual filesystem paths, not the raw Roblox names, to /// avoid a false-positive duplicate error. #[test] fn syncback_no_false_duplicate_with_name_prop() { use rbx_dom_weak::{InstanceBuilder, WeakDom}; // Old child A: Roblox name "Alpha", on disk at "/root/Beta" // (name property maps "Alpha" → "Beta" on the filesystem) let old_child_a = InstanceSnapshot::new() .name("Alpha") .class_name("Folder") .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root/Beta")) .relevant_paths(vec![PathBuf::from("/root/Beta")]), ); // Old child B: Roblox name "alpha", on disk at "/root/Alpha" let old_child_b = InstanceSnapshot::new() .name("alpha") .class_name("Folder") .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root/Alpha")) .relevant_paths(vec![PathBuf::from("/root/Alpha")]), ); let old_parent = InstanceSnapshot::new() .name("Parent") .class_name("Folder") .children(vec![old_child_a, old_child_b]) .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root")) .relevant_paths(vec![PathBuf::from("/root")]), ); let old_tree = RojoTree::new(old_parent); // New state: same two children in Roblox. let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT")); let new_parent = new_tree.insert( new_tree.root_ref(), InstanceBuilder::new("Folder").with_name("Parent"), ); new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Alpha")); new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("alpha")); let vfs = make_vfs(); let project = make_project(); let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project); let snapshot = SyncbackSnapshot { data, old: Some(old_tree.get_root_id()), new: new_parent, path: PathBuf::from("/root"), middleware: None, }; let result = syncback_dir_no_meta(&snapshot); assert!( result.is_ok(), "should not error when two children have the same lowercased Roblox \ name but map to distinct filesystem paths: {result:?}", ); } /// Two completely new children with the same non-init name would produce /// the same filesystem entry and must be detected as a duplicate. #[test] fn syncback_detects_sibling_duplicate_names() { use rbx_dom_weak::{InstanceBuilder, WeakDom}; let old_parent = InstanceSnapshot::new() .name("Parent") .class_name("Folder") .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root")) .relevant_paths(vec![PathBuf::from("/root")]), ); let old_tree = RojoTree::new(old_parent); let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT")); let new_parent = new_tree.insert( new_tree.root_ref(), InstanceBuilder::new("Folder").with_name("Parent"), ); // "Foo" is not a reserved name but two siblings named "Foo" still // collide on disk. new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo")); new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo")); let vfs = make_vfs(); let project = make_project(); let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project); let snapshot = SyncbackSnapshot { data, old: Some(old_tree.get_root_id()), new: new_parent, path: PathBuf::from("/root"), middleware: None, }; let result = syncback_dir_no_meta(&snapshot); assert!( result.is_err(), "should error when two new children would produce the same filesystem name", ); } /// A new child named "Init" (as a ModuleScript) would naively become /// "Init.luau", which case-insensitively matches the parent's reserved /// "init.luau". Syncback must resolve this automatically by prefixing the /// filesystem name with '_' (→ "_Init.luau") rather than erroring. #[test] fn syncback_resolves_init_name_conflict() { use rbx_dom_weak::{InstanceBuilder, WeakDom}; let old_parent = InstanceSnapshot::new() .name("Parent") .class_name("Folder") .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root")) .relevant_paths(vec![PathBuf::from("/root")]), ); let old_tree = RojoTree::new(old_parent); let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT")); let new_parent = new_tree.insert( new_tree.root_ref(), InstanceBuilder::new("Folder").with_name("Parent"), ); new_tree.insert( new_parent, InstanceBuilder::new("ModuleScript").with_name("Init"), ); let vfs = make_vfs(); let project = make_project(); let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project); let snapshot = SyncbackSnapshot { data, old: Some(old_tree.get_root_id()), new: new_parent, path: PathBuf::from("/root"), middleware: None, }; let result = syncback_dir_no_meta(&snapshot); assert!( result.is_ok(), "should resolve init-name conflict by prefixing '_', not error: {result:?}", ); // The child should have been placed at "_Init.luau", not "Init.luau". let child_file_name = result .unwrap() .children .into_iter() .next() .and_then(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned())) .unwrap_or_default(); assert!( child_file_name.starts_with('_'), "child filesystem name should start with '_' to avoid init collision, \ got: {child_file_name}", ); } /// A child whose filesystem name is stored with a slugified prefix (e.g. /// "_Init") must NOT be blocked — only the bare "init" stem is reserved. #[test] fn syncback_allows_slugified_init_name() { use rbx_dom_weak::{InstanceBuilder, WeakDom}; // Existing child: on disk as "_Init" (slugified from a name with an // illegal character), its stem is "_init" which is not reserved. let old_child = InstanceSnapshot::new() .name("Init") .class_name("Folder") .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root/_Init")) .relevant_paths(vec![PathBuf::from("/root/_Init")]), ); let old_parent = InstanceSnapshot::new() .name("Parent") .class_name("Folder") .children(vec![old_child]) .metadata( InstanceMetadata::new() .instigating_source(PathBuf::from("/root")) .relevant_paths(vec![PathBuf::from("/root")]), ); let old_tree = RojoTree::new(old_parent); let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT")); let new_parent = new_tree.insert( new_tree.root_ref(), InstanceBuilder::new("Folder").with_name("Parent"), ); new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Init")); let vfs = make_vfs(); let project = make_project(); let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project); let snapshot = SyncbackSnapshot { data, old: Some(old_tree.get_root_id()), new: new_parent, path: PathBuf::from("/root"), middleware: None, }; let result = syncback_dir_no_meta(&snapshot); assert!( result.is_ok(), "should allow a child whose filesystem name is slugified away from \ the reserved 'init' stem: {result:?}", ); } }