Implement fixes and enhancements from audit pass 2

- Introduced a new plan addressing critical bugs, correctness issues, and refactors identified during the audit, including improvements to metadata handling and slugification logic.
- Replaced inline removal logic in `handle_api_write` with a new `remove_instance_at_path` method to ensure correct deletion of instances.
- Updated pre-seeding logic in `dir.rs` and `project.rs` to derive deduplication keys from actual filesystem paths instead of slugified names.
- Enhanced rename handling in `change_processor.rs` to prevent collisions by deduplicating names against existing siblings.
- Added tests for various scenarios, including handling of forbidden characters and ensuring correct deduplication behavior.
- Refactored child iteration in `dir.rs` and `project.rs` to sort children alphabetically for deterministic deduplication ordering.
This commit is contained in:
Joe 2026-02-10 21:35:26 -06:00
parent baf05dcb3d
commit 2ff5c3936c
30 changed files with 871 additions and 229 deletions

View file

@ -15,7 +15,7 @@ use crate::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
},
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
syncback::{name_needs_slugify, slugify_name},
syncback::{deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix},
};
/// Processes file change events, updates the DOM, and sends those updates
@ -226,6 +226,13 @@ impl JobThreadContext {
}
}
/// Suppress the next Remove VFS event for the given path.
fn suppress_path_remove(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).0 += 1;
}
/// Upsert the `name` field in a `.meta.json5` file, suppressing filesystem
/// events to avoid feedback loops.
fn upsert_meta_name_field(&self, meta_path: &Path, real_name: &str) {
@ -251,10 +258,11 @@ impl JobThreadContext {
self.unsuppress_path(meta_path);
}
Ok(RemoveNameOutcome::FileDeleted) => {
// File was deleted — also suppress the Remove event.
// suppress_path already covers Create/Write; we need
// suppress_path_any for the Remove event as well.
self.suppress_path_any(meta_path);
// File was deleted, not rewritten. Swap: undo the
// pre-emptive Write suppression and add a Remove
// suppression instead so the counts are (1, 0).
self.unsuppress_path(meta_path);
self.suppress_path_remove(meta_path);
}
Ok(RemoveNameOutcome::FieldRemoved) => {
// File was rewritten — suppress_path already covers it.
@ -606,7 +614,7 @@ impl JobThreadContext {
let mut tree = self.tree.lock().unwrap();
// NOTE: We do NOT delete files from disk here. The API handler
// (handle_api_write → syncback_removed_instance) already deleted
// (handle_api_write → remove_instance_at_path) already deleted
// the files before sending this PatchSet. Re-deleting would
// destroy any new file created at the same path — e.g., when an
// instance is renamed from "joe_test" to "joe/test", the new slug
@ -688,6 +696,11 @@ impl JobThreadContext {
} else {
new_name.to_string()
};
// Directory renames: fs::rename fails safely
// on all platforms if the target is a non-empty
// directory, so no dedup is needed here (unlike
// the file rename path below, where Unix
// fs::rename silently overwrites).
let new_dir_path =
grandparent.join(&slugified_new_name);
@ -791,12 +804,57 @@ impl JobThreadContext {
} else {
new_name.to_string()
};
// Guard against rename collision: if the target
// path already exists and isn't our own file,
// deduplicate the name against siblings.
let deduped_new_name;
{
let candidate_file = if extension.is_empty() {
format!("{}{}", slugified_new_name, script_suffix)
} else {
format!(
"{}{}.{}",
slugified_new_name, script_suffix, extension
)
};
let candidate = parent.join(&candidate_file);
if candidate != *path && candidate.exists() {
let mut taken: std::collections::HashSet<String> =
std::collections::HashSet::new();
if let Ok(entries) = fs::read_dir(parent) {
for entry in entries.flatten() {
let ep = entry.path();
let slug = if ep.is_dir() {
ep.file_name()
.and_then(|f| f.to_str())
.unwrap_or("")
.to_lowercase()
} else {
let s = ep
.file_stem()
.and_then(|f| f.to_str())
.unwrap_or("");
strip_script_suffix(s).to_lowercase()
};
taken.insert(slug);
}
}
// Free the slot we're vacating
taken.remove(&old_base.to_lowercase());
deduped_new_name =
deduplicate_name(&slugified_new_name, &taken);
} else {
deduped_new_name = slugified_new_name.clone();
}
}
let new_file_name = if extension.is_empty() {
format!("{}{}", slugified_new_name, script_suffix)
format!("{}{}", deduped_new_name, script_suffix)
} else {
format!(
"{}{}.{}",
slugified_new_name, script_suffix, extension
deduped_new_name, script_suffix, extension
)
};
let new_path = parent.join(&new_file_name);
@ -828,7 +886,7 @@ impl JobThreadContext {
.join(format!("{}.meta.json5", old_base));
let new_meta = parent.join(format!(
"{}.meta.json5",
slugified_new_name
deduped_new_name
));
if old_meta.exists() {
self.suppress_path_any(&old_meta);
@ -839,7 +897,7 @@ impl JobThreadContext {
self.unsuppress_path(&new_meta);
}
}
effective_meta_base = &slugified_new_name;
effective_meta_base = &deduped_new_name;
}
} else {
effective_meta_base = old_base;
@ -848,12 +906,13 @@ impl JobThreadContext {
// Always update the meta name field when the
// instance name changed, even if the path didn't
// change (e.g. "Foo/Bar" → "Foo|Bar" both slugify
// to "Foo_Bar").
// to "Foo_Bar"). Use deduped_new_name because
// dedup may have appended ~N.
let current_meta = parent.join(format!(
"{}.meta.json5",
effective_meta_base
));
if slugified_new_name != *new_name {
if deduped_new_name != *new_name {
self.upsert_meta_name_field(
&current_meta,
new_name,

View file

@ -77,10 +77,16 @@ pub fn snapshot_csv_init(
let mut init_snapshot = snapshot_csv(context, vfs, init_path, &dir_snapshot.name)?.unwrap();
// Preserve the init script's instigating_source (the actual file path)
// before copying the directory's metadata (which has the folder path)
let script_instigating_source = init_snapshot.metadata.instigating_source.take();
init_snapshot.children = dir_snapshot.children;
init_snapshot.metadata = dir_snapshot.metadata;
// The directory snapshot middleware includes all possible init paths
// so we don't need to add it here.
// Restore the init script's instigating_source so two-way sync writes
// to the actual file (e.g., init.csv) instead of the directory
init_snapshot.metadata.instigating_source = script_instigating_source;
DirectoryMetadata::read_and_apply_all(vfs, folder_path, &mut init_snapshot)?;

View file

@ -8,9 +8,10 @@ use memofs::{DirEntry, Vfs};
use crate::{
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
snapshot_middleware::Middleware,
syncback::{
hash_instance, name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn,
SyncbackSnapshot,
hash_instance, name_needs_slugify, slugify_name, strip_middleware_extension, FsSnapshot,
SyncbackReturn, SyncbackSnapshot,
},
};
@ -186,25 +187,49 @@ pub fn syncback_dir_no_meta<'sync>(
old_child_map.insert(inst.name(), inst);
}
// Pre-seed taken_names from old children's slugified instance names
// so that new-only children correctly dedup against existing siblings
// (e.g., new "A/B" → slug "A_B" won't collide with existing "A_B").
// Pre-seed taken_names from old children's actual filesystem dedup keys
// so that new-only children correctly dedup against existing siblings.
// We derive keys from relevant_paths (the real filename on disk) rather
// than slugifying instance names, because an old instance may already
// have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare
// slug wouldn't capture.
// Only in incremental mode: in clean mode, old_ref is forced to None
// for all children, so every child is treated as new and pre-seeding
// would cause false collisions (spurious ~1 suffixes).
if snapshot.data.is_incremental() {
for inst in old_child_map.values() {
let name = inst.name();
let slug = if name_needs_slugify(name) {
slugify_name(name).to_lowercase()
if let Some(path) = inst.metadata().relevant_paths.first() {
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
let middleware = inst.metadata().middleware.unwrap_or(Middleware::Dir);
let dedup_key =
strip_middleware_extension(filename, middleware).to_lowercase();
taken_names.insert(dedup_key);
}
} else {
name.to_lowercase()
};
taken_names.insert(slug);
// Fallback (shouldn't happen for old instances with disk presence)
let name = inst.name();
let slug = if name_needs_slugify(name) {
slugify_name(name).to_lowercase()
} else {
name.to_lowercase()
};
taken_names.insert(slug);
}
}
}
for new_child_ref in new_inst.children() {
// Sort children alphabetically for deterministic dedup ordering.
// Without this, DOM iteration order determines which sibling gets
// the base slug vs ~1, causing non-deterministic output across
// different serializations of the same place.
let mut sorted_children: Vec<_> = new_inst
.children()
.iter()
.map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone()))
.collect();
sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b));
for (new_child_ref, _) in &sorted_children {
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
// Skip children with duplicate names - cannot reliably sync them
@ -282,7 +307,15 @@ pub fn syncback_dir_no_meta<'sync>(
}));
} else {
// There is no old instance. Just add every child.
for new_child_ref in new_inst.children() {
// Sort alphabetically for deterministic dedup ordering.
let mut sorted_children: Vec<_> = new_inst
.children()
.iter()
.map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone()))
.collect();
sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b));
for (new_child_ref, _) in &sorted_children {
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
// Skip children with duplicate names - cannot reliably sync them

View file

@ -21,8 +21,8 @@ use crate::{
},
snapshot_middleware::Middleware,
syncback::{
filter_properties, inst_path, name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn,
SyncbackSnapshot,
filter_properties, inst_path, name_needs_slugify, slugify_name,
strip_middleware_extension, FsSnapshot, SyncbackReturn, SyncbackSnapshot,
},
variant_eq::variant_eq,
RojoRef,
@ -658,9 +658,12 @@ pub fn syncback_project<'sync>(
std::collections::HashSet<String>,
> = std::collections::HashMap::new();
// Pre-seed taken_names from old children's slugified instance names
// so that new-only children correctly dedup against existing siblings
// (e.g., new "utils" won't collide with existing "Utils.luau").
// Pre-seed taken_names from old children's actual filesystem dedup keys
// so that new-only children correctly dedup against existing siblings.
// We derive keys from relevant_paths (the real filename on disk) rather
// than slugifying instance names, because an old instance may already
// have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare
// slug wouldn't capture.
// Only in incremental mode: in clean mode, old_ref is forced to None
// for all children, so every child is treated as new and pre-seeding
// would cause false collisions (spurious ~1 suffixes).
@ -668,13 +671,24 @@ pub fn syncback_project<'sync>(
if let Some(parent_path) = ref_to_path_map.get(&new_inst.referent()) {
let taken = taken_names_per_dir.entry(parent_path.clone()).or_default();
for old_child in old_child_map.values() {
let name = old_child.name();
let slug = if name_needs_slugify(name) {
slugify_name(name).to_lowercase()
if let Some(path) = old_child.metadata().relevant_paths.first() {
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
let middleware =
old_child.metadata().middleware.unwrap_or(Middleware::Dir);
let dedup_key =
strip_middleware_extension(filename, middleware).to_lowercase();
taken.insert(dedup_key);
}
} else {
name.to_lowercase()
};
taken.insert(slug);
// Fallback (shouldn't happen for old instances with disk presence)
let name = old_child.name();
let slug = if name_needs_slugify(name) {
slugify_name(name).to_lowercase()
} else {
name.to_lowercase()
};
taken.insert(slug);
}
}
}
}

View file

@ -6,7 +6,7 @@ snapshot_id: "00000000000000000000000000000000"
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root
Path: /root/init.csv
relevant_paths:
- /root
- /root/init.luau

View file

@ -6,7 +6,7 @@ snapshot_id: "00000000000000000000000000000000"
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root
Path: /root/init.csv
relevant_paths:
- /root
- /root/init.luau

View file

@ -2400,4 +2400,77 @@ mod tests {
let dk_from_filename = strip_middleware_extension(&name1, Middleware::ModuleScript);
assert_eq!(dk_from_filename.to_lowercase(), dk1.to_lowercase());
}
#[test]
fn syncback_forbidden_chars_produce_slugified_filenames() {
// Verify that instances with forbidden chars produce correct
// (filename, needs_meta, dedup_key) via name_for_inst
let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder"));
let root_ref = dom.root_ref();
let child = dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("Hey/Bro"));
let inst = dom.get_by_ref(child).unwrap();
let taken = std::collections::HashSet::new();
let (filename, needs_meta, dedup_key) = name_for_inst(
crate::snapshot_middleware::Middleware::ModuleScript,
inst, None, &taken,
).unwrap();
assert_eq!(filename.as_ref(), "Hey_Bro.luau");
assert!(needs_meta);
assert_eq!(dedup_key, "Hey_Bro");
}
#[test]
fn syncback_collision_deduplicates() {
let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder"));
let root_ref = dom.root_ref();
let child1 = dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("A/B"));
let child2 = dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("A:B"));
let inst1 = dom.get_by_ref(child1).unwrap();
let inst2 = dom.get_by_ref(child2).unwrap();
let mut taken = std::collections::HashSet::new();
let (f1, m1, dk1) = name_for_inst(
crate::snapshot_middleware::Middleware::ModuleScript,
inst1, None, &taken,
).unwrap();
taken.insert(dk1.to_lowercase());
let (f2, m2, dk2) = name_for_inst(
crate::snapshot_middleware::Middleware::ModuleScript,
inst2, None, &taken,
).unwrap();
assert_eq!(f1.as_ref(), "A_B.luau");
assert!(m1);
assert_eq!(f2.as_ref(), "A_B~1.luau");
assert!(m2);
assert_ne!(dk1.to_lowercase(), dk2.to_lowercase());
}
#[test]
fn syncback_idempotency_same_input_same_output() {
// Run the same set of instances through name_for_inst twice
// and verify identical output (determinism).
let names = vec!["Alpha", "Beta/Gamma", "Beta:Gamma", "Delta", "CON"];
let mw = crate::snapshot_middleware::Middleware::ModuleScript;
let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder"));
let root = dom.root_ref();
let refs: Vec<_> = names.iter().map(|n|
dom.insert(root, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name(*n))
).collect();
let run = |dom: &rbx_dom_weak::WeakDom, refs: &[rbx_dom_weak::types::Ref]| -> Vec<(String, bool, String)> {
let mut taken = std::collections::HashSet::new();
refs.iter().map(|r| {
let inst = dom.get_by_ref(*r).unwrap();
let (f, m, dk) = name_for_inst(mw, inst, None, &taken).unwrap();
taken.insert(dk.to_lowercase());
(f.into_owned(), m, dk)
}).collect()
};
let result1 = run(&dom, &refs);
let result2 = run(&dom, &refs);
assert_eq!(result1, result2, "Two runs of name_for_inst must produce identical output");
}
}

View file

@ -32,7 +32,8 @@ use crate::{
pub use file_names::{
adjacent_meta_path, deduplicate_name, extension_for_middleware, name_for_inst,
name_needs_slugify, slugify_name, strip_middleware_extension, validate_file_name,
name_needs_slugify, slugify_name, strip_middleware_extension, strip_script_suffix,
validate_file_name,
};
pub use fs_snapshot::FsSnapshot;
pub use hash::*;

View file

@ -354,7 +354,7 @@ impl ApiService {
// they are NOT removed from the in-memory tree.
let mut actually_removed: Vec<Ref> = Vec::new();
if !request.removed.is_empty() {
let removal_actions: Vec<(Ref, Option<(PathBuf, bool)>)> = {
let removal_actions: Vec<(Ref, Option<PathBuf>)> = {
let tree = self.serve_session.tree();
request
.removed
@ -364,7 +364,7 @@ impl ApiService {
inst.metadata().instigating_source.as_ref().and_then(|src| {
match src {
crate::snapshot::InstigatingSource::Path(p) => {
Some((p.clone(), p.is_dir()))
Some(p.clone())
}
crate::snapshot::InstigatingSource::ProjectNode {
name,
@ -386,66 +386,9 @@ impl ApiService {
// Phase 2: Execute filesystem deletions without the lock
for (id, action) in removal_actions {
if let Some((path, is_dir)) = action {
if !path.exists() {
log::info!(
"Syncback: Path already removed (likely parent was deleted): {}",
path.display()
);
if let Some(path) = action {
if self.remove_instance_at_path(&path) {
actually_removed.push(id);
continue;
}
if is_dir {
self.suppress_path_remove(&path);
if let Err(err) = fs::remove_dir_all(&path) {
log::warn!(
"Failed to remove directory {:?} for instance {:?}: {}",
path,
id,
err
);
} else {
log::info!("Syncback: Removed directory at {}", path.display());
actually_removed.push(id);
}
} else {
self.suppress_path_remove(&path);
if let Err(err) = fs::remove_file(&path) {
log::warn!(
"Failed to remove file {:?} for instance {:?}: {}",
path,
id,
err
);
} else {
log::info!("Syncback: Removed file at {}", path.display());
actually_removed.push(id);
}
// Also remove adjacent meta file.
// Strip known script suffixes (.server, .client, etc.)
// rather than splitting on dots, so that names containing
// dots (e.g. "Config.Client.server.luau") resolve correctly.
if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
let base_name = file_stem
.strip_suffix(".server")
.or_else(|| file_stem.strip_suffix(".client"))
.or_else(|| file_stem.strip_suffix(".plugin"))
.or_else(|| file_stem.strip_suffix(".local"))
.or_else(|| file_stem.strip_suffix(".legacy"))
.unwrap_or(file_stem);
if let Some(parent_dir) = path.parent() {
let meta_path =
parent_dir.join(format!("{}.meta.json5", base_name));
if meta_path.exists() {
self.suppress_path_remove(&meta_path);
let _ = fs::remove_file(&meta_path);
log::info!(
"Syncback: Removed adjacent meta file at {}",
meta_path.display()
);
}
}
}
}
}
}
@ -1422,139 +1365,109 @@ impl ApiService {
Ok(new_dir)
}
/// Syncback a removed instance by deleting its file(s) from the filesystem.
/// This is called when the user selects "pull" for an instance that exists in Rojo
/// but has been deleted in Studio.
#[allow(dead_code)]
fn syncback_removed_instance(
&self,
removed_id: Ref,
tree: &crate::snapshot::RojoTree,
) -> anyhow::Result<()> {
use anyhow::Context;
/// Remove an instance from the filesystem given its path.
///
/// Handles three cases:
/// - Directory: `remove_dir_all`
/// - Init file (`init.luau`, `init.server.luau`, etc.): remove parent directory
/// (which IS the instance) + grandparent-level adjacent meta
/// - Regular file: remove the file + adjacent `.meta.json5`
///
/// Returns `true` if the removal succeeded (or the path was already gone),
/// `false` if the filesystem operation failed (logged as warning).
fn remove_instance_at_path(&self, path: &Path) -> bool {
use crate::syncback::adjacent_meta_path;
// Find the instance in the tree
let instance = tree
.get_instance(removed_id)
.context("Instance not found in Rojo tree")?;
// Get the instigating source and handle each variant appropriately
let instigating_source = instance
.metadata()
.instigating_source
.as_ref()
.context("Instance has no filesystem path (not synced from filesystem)")?;
// Only delete instances that originate from filesystem paths, not from project nodes
let instance_path = match instigating_source {
InstigatingSource::Path(path) => path,
InstigatingSource::ProjectNode { name, .. } => {
// Instances defined in project files cannot be removed via syncback
// because that would require modifying the project file structure
log::warn!(
"Syncback: Cannot remove instance '{}' (id: {:?}) - it is defined in a project file",
name,
removed_id
);
anyhow::bail!(
"Cannot remove instance '{}' because it is defined in a project file. \
To remove this instance, edit the project file directly.",
name
);
}
};
// Check if path exists - it may have already been deleted as part of a parent removal
// (e.g., if both a directory and its children are marked for removal, the directory
// deletion will recursively delete all children, so we just skip them)
if !instance_path.exists() {
log::trace!(
if !path.exists() {
log::info!(
"Syncback: Path already removed (likely parent was deleted): {}",
instance_path.display()
path.display()
);
return Ok(());
return true;
}
// Delete the file or directory
if instance_path.is_dir() {
self.suppress_path_remove(instance_path);
fs::remove_dir_all(instance_path).with_context(|| {
format!("Failed to remove directory: {}", instance_path.display())
})?;
log::info!("Syncback: Removed directory at {}", instance_path.display());
} else if instance_path.is_file() {
use crate::syncback::adjacent_meta_path;
if path.is_dir() {
self.suppress_path_remove(path);
if let Err(err) = fs::remove_dir_all(path) {
log::warn!("Failed to remove directory {:?}: {}", path, err);
return false;
}
log::info!("Syncback: Removed directory at {}", path.display());
return true;
}
let file_name = instance_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
let is_init_file = file_name.starts_with("init.");
// File: check if it's an init file (directory-format script)
let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
let is_init_file = file_name.starts_with("init.");
if is_init_file {
// Directory-format script: the parent directory represents the
// instance (e.g. src/MyModule/init.luau → the directory IS MyModule).
// Remove the entire directory.
let dir_path = instance_path.parent().unwrap();
self.suppress_path_remove(dir_path);
fs::remove_dir_all(dir_path).with_context(|| {
format!(
"Failed to remove directory for init file: {}",
dir_path.display()
)
})?;
log::info!(
"Syncback: Removed directory {} (init file: {})",
dir_path.display(),
instance_path.display()
if is_init_file {
// Directory-format script: the parent directory represents the
// instance (e.g. src/MyModule/init.luau → the directory IS MyModule).
// Remove the entire directory.
let dir_path = path.parent().unwrap();
self.suppress_path_remove(dir_path);
if let Err(err) = fs::remove_dir_all(dir_path) {
log::warn!(
"Failed to remove directory for init file {:?}: {}",
dir_path,
err
);
return false;
}
log::info!(
"Syncback: Removed directory {} (init file: {})",
dir_path.display(),
path.display()
);
// Also remove adjacent dir-level meta in the grandparent if it
// exists (e.g. grandparent/MyModule.meta.json5).
if let Some(grandparent) = dir_path.parent() {
if let Some(dir_name) = dir_path.file_name().and_then(|f| f.to_str()) {
let dir_meta = grandparent.join(format!("{}.meta.json5", dir_name));
if dir_meta.exists() {
if let Err(err) = fs::remove_file(&dir_meta) {
log::warn!(
"Failed to remove dir-level meta file {}: {}",
dir_meta.display(),
err
);
}
// Also remove adjacent dir-level meta in the grandparent if it
// exists (e.g. grandparent/MyModule.meta.json5).
if let Some(grandparent) = dir_path.parent() {
if let Some(dir_name) = dir_path.file_name().and_then(|f| f.to_str()) {
let dir_meta = grandparent.join(format!("{}.meta.json5", dir_name));
if dir_meta.exists() {
self.suppress_path_remove(&dir_meta);
if let Err(err) = fs::remove_file(&dir_meta) {
log::warn!(
"Failed to remove dir-level meta file {}: {}",
dir_meta.display(),
err
);
}
}
}
} else {
// Regular file: remove the file itself
self.suppress_path_remove(instance_path);
fs::remove_file(instance_path).with_context(|| {
format!("Failed to remove file: {}", instance_path.display())
})?;
log::info!("Syncback: Removed file at {}", instance_path.display());
}
} else {
// Regular file: remove the file itself
self.suppress_path_remove(path);
if let Err(err) = fs::remove_file(path) {
log::warn!("Failed to remove file {:?}: {}", path, err);
return false;
}
log::info!("Syncback: Removed file at {}", path.display());
// Remove the adjacent meta file if it exists. The meta file is
// named after the script file's base stem (the slugified name),
// not the raw instance name.
let meta_path = adjacent_meta_path(instance_path);
if meta_path.exists() {
if let Err(err) = fs::remove_file(&meta_path) {
log::warn!(
"Failed to remove adjacent meta file {}: {}",
meta_path.display(),
err
);
} else {
log::info!(
"Syncback: Removed adjacent meta file at {}",
meta_path.display()
);
}
// Remove the adjacent meta file if it exists. The meta file is
// named after the script file's base stem (the slugified name),
// not the raw instance name.
let meta_path = adjacent_meta_path(path);
if meta_path.exists() {
self.suppress_path_remove(&meta_path);
if let Err(err) = fs::remove_file(&meta_path) {
log::warn!(
"Failed to remove adjacent meta file {}: {}",
meta_path.display(),
err
);
} else {
log::info!(
"Syncback: Removed adjacent meta file at {}",
meta_path.display()
);
}
}
}
Ok(())
true
}
/// Check for duplicate names among children and return the set of unique children