Skip to main content

musefs_core/
tree.rs

1use im::{HashMap as ImHashMap, OrdMap};
2use std::borrow::Cow;
3
4/// Case-fold a name for case-insensitive comparison. Unicode-aware lowercasing;
5/// ASCII is the floor. Applied identically to every comparison (collision,
6/// disambiguation, lookup) - never compare a folded value against an unfolded one.
7fn fold(name: &str) -> String {
8    name.to_lowercase()
9}
10
11/// Why an incremental tree mutation could not complete; the caller falls back to
12/// a full rebuild. Carries diagnostics instead of `()` (#95).
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RebuildError {
15    /// A track collected for rebuild had no entry in `new_paths`.
16    MissingRenderedPath(i64),
17    /// Test-only injected failure (`force_apply_fail`).
18    TestInjected,
19}
20
21/// Assigns stable inodes keyed by rendered path, persisted across tree rebuilds:
22/// an unchanged path keeps its inode, a new path gets a fresh one, and a retired
23/// inode is never recycled (a stale FUSE handle can't alias a different node).
24/// Retired paths are dropped by `prune_retired` once they outnumber live ones,
25/// bounding the map at 2x the live tree between prunes; a path that returns
26/// after a prune gets a fresh inode rather than its old one.
27/// In case-insensitive mounts the key is case-folded, so a track keeps its
28/// inode when an unrelated deletion flips a merged directory's display casing
29/// (#305).
30#[derive(Debug, Clone)]
31pub struct InodeAllocator {
32    paths: ImHashMap<String, u64>,
33    next: u64,
34    fold_keys: bool,
35}
36
37impl InodeAllocator {
38    pub fn new(case_insensitive: bool) -> InodeAllocator {
39        let mut paths = ImHashMap::new();
40        paths.insert(String::new(), VirtualTree::ROOT); // root path "" -> inode 1
41        InodeAllocator {
42            paths,
43            next: 2,
44            fold_keys: case_insensitive,
45        }
46    }
47
48    /// Transform a path into its map key: case-folded when the mount is
49    /// case-insensitive (so a survivor keeps its inode when an unrelated deletion
50    /// flips a merged directory's display casing, #305), identity otherwise.
51    fn key<'a>(&self, path: &'a str) -> Cow<'a, str> {
52        if self.fold_keys {
53            Cow::Owned(fold(path))
54        } else {
55            Cow::Borrowed(path)
56        }
57    }
58
59    /// The inode for `path` (the disambiguated path from root), reused if seen
60    /// before, else freshly allocated.
61    fn intern(&mut self, path: &str) -> u64 {
62        let key = self.key(path);
63        if let Some(&ino) = self.paths.get(key.as_ref()) {
64            return ino;
65        }
66        let ino = self.next;
67        self.next += 1;
68        self.paths.insert(key.into_owned(), ino);
69        ino
70    }
71
72    /// Rebuild `paths` from the live tree once retired entries outnumber live
73    /// ones (map > 2x live nodes), keeping each live path's existing inode.
74    /// `next` is untouched, so a retired inode is never reissued. A retired
75    /// path that reappears after a prune gets a fresh inode: a kernel dentry
76    /// cached for its old inode resolves ENOENT for at most one entry TTL,
77    /// the same degradation as any vanished path.
78    pub(crate) fn prune_retired(&mut self, tree: &VirtualTree) {
79        if self.paths.len() <= 2 * tree.nodes.len() {
80            return;
81        }
82        let mut live = ImHashMap::new();
83        for &ino in tree.nodes.keys() {
84            live.insert(self.key(&tree.path_of(ino)).into_owned(), ino);
85        }
86        self.paths = live;
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum NodeKind {
92    Dir,
93    File { track_id: i64 },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Node {
98    pub parent: u64,
99    pub name: String,          // disambiguated name
100    pub rendered_name: String, // pre-disambiguation base name
101    pub kind: NodeKind,
102}
103
104/// An in-memory virtual filesystem tree: directories derived from path components
105/// and files mapped to track ids. Inodes are stable for the lifetime of the tree.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct VirtualTree {
108    nodes: ImHashMap<u64, Node>,
109    children: ImHashMap<u64, OrdMap<String, u64>>,
110    rendered_children: ImHashMap<u64, OrdMap<String, OrdMap<String, u64>>>,
111    /// `parent -> fold(name) -> inode`, populated ONLY when `case_insensitive`.
112    /// Kept in sync with `children` on every insert AND removal, so a folded
113    /// `lookup` never resolves a stale inode even if the tree is mutated in place
114    /// (production folded mounts only ever full-rebuild, but the public
115    /// `build_with_ci` + mutators must stay consistent). Gives O(1) folded lookup
116    /// and collision checks. Part of structural equality; built deterministically,
117    /// so fresh and re-rendered folded trees compare equal.
118    folded_children: ImHashMap<u64, ImHashMap<String, u64>>,
119    track_to_inode: ImHashMap<i64, u64>,
120    /// When true, names are compared case-insensitively (dirs merge, files
121    /// disambiguate). Defaults to false (exact matching, identical to Linux).
122    case_insensitive: bool,
123}
124
125impl VirtualTree {
126    pub const ROOT: u64 = 1;
127
128    pub fn build(entries: &[(i64, String)]) -> VirtualTree {
129        VirtualTree::build_with(entries, &mut InodeAllocator::new(false))
130    }
131
132    /// Case-sensitive build (the default everywhere except a folded mount).
133    /// Inodes are assigned via `alloc` (keyed by rendered path), stable across
134    /// rebuilds that reuse the same allocator.
135    pub fn build_with(entries: &[(i64, String)], alloc: &mut InodeAllocator) -> VirtualTree {
136        VirtualTree::build_with_ci(entries, alloc, false)
137    }
138
139    /// Build with explicit case-folding. With `case_insensitive`, names fold:
140    /// case-variant directories merge, case-variant files disambiguate.
141    pub fn build_with_ci(
142        entries: &[(i64, String)],
143        alloc: &mut InodeAllocator,
144        case_insensitive: bool,
145    ) -> VirtualTree {
146        let mut tree = VirtualTree {
147            nodes: ImHashMap::new(),
148            children: ImHashMap::new(),
149            rendered_children: ImHashMap::new(),
150            folded_children: ImHashMap::new(),
151            track_to_inode: ImHashMap::new(),
152            case_insensitive,
153        };
154        tree.nodes.insert(
155            Self::ROOT,
156            Node {
157                parent: Self::ROOT,
158                name: String::new(),
159                rendered_name: String::new(),
160                kind: NodeKind::Dir,
161            },
162        );
163        tree.children.insert(Self::ROOT, OrdMap::new());
164        tree.rendered_children.insert(Self::ROOT, OrdMap::new());
165        for (track_id, path) in entries {
166            tree.insert_file(*track_id, path, alloc);
167        }
168        tree
169    }
170
171    /// Structural equality for the equivalence oracle: identical track→inode map,
172    /// node set, AND children maps. Delegates to the derived `PartialEq` so adding a
173    /// field to `VirtualTree` can never silently weaken the oracle. See SP2 Testing item 1.
174    pub fn equiv(&self, other: &VirtualTree) -> bool {
175        self == other
176    }
177
178    pub fn node(&self, inode: u64) -> Option<&Node> {
179        self.nodes.get(&inode)
180    }
181
182    /// The parent inode of `inode` (root's parent is itself), or `None` if `inode`
183    /// is unknown. Used by the FUSE layer to emit `..` directory entries.
184    pub fn parent(&self, inode: u64) -> Option<u64> {
185        self.nodes.get(&inode).map(|n| n.parent)
186    }
187
188    pub fn children(&self, inode: u64) -> Option<&OrdMap<String, u64>> {
189        self.children.get(&inode)
190    }
191
192    pub fn lookup(&self, parent: u64, name: &str) -> Option<u64> {
193        if self.case_insensitive {
194            self.folded_children
195                .get(&parent)
196                .and_then(|b| b.get(&fold(name)).copied())
197        } else {
198            self.children
199                .get(&parent)
200                .and_then(|c| c.get(name).copied())
201        }
202    }
203
204    pub fn is_dir(&self, inode: u64) -> bool {
205        matches!(self.nodes.get(&inode).map(|n| &n.kind), Some(NodeKind::Dir))
206    }
207
208    pub fn track_id(&self, inode: u64) -> Option<i64> {
209        match self.nodes.get(&inode).map(|n| &n.kind) {
210            Some(NodeKind::File { track_id }) => Some(*track_id),
211            _ => None,
212        }
213    }
214
215    /// The inode of the file node serving `track_id`, if present.
216    pub fn inode_of_track(&self, track_id: i64) -> Option<u64> {
217        self.track_to_inode.get(&track_id).copied()
218    }
219
220    fn insert_file(&mut self, track_id: i64, path: &str, alloc: &mut InodeAllocator) {
221        let comps: Vec<&str> = path
222            .split('/')
223            .filter(|c| !c.is_empty() && *c != "." && *c != "..")
224            .collect();
225        if comps.is_empty() {
226            return;
227        }
228        let mut dir = Self::ROOT;
229        let mut dir_path = String::new();
230        for comp in &comps[..comps.len() - 1] {
231            let (child, child_path) = self.ensure_dir(dir, &dir_path, comp, alloc);
232            dir = child;
233            dir_path = child_path;
234        }
235        let raw_name = comps[comps.len() - 1];
236        let truncated = truncate_component(raw_name, true);
237        let raw_name = truncated.as_ref();
238        let name = self.disambiguate(dir, raw_name);
239        let full = join_path(&dir_path, &name);
240        let inode = alloc.intern(&full);
241        self.track_to_inode.insert(track_id, inode);
242        self.nodes.insert(
243            inode,
244            Node {
245                parent: dir,
246                name: name.clone(),
247                rendered_name: raw_name.to_string(),
248                kind: NodeKind::File { track_id },
249            },
250        );
251        self.children
252            .get_mut(&dir)
253            .unwrap()
254            .insert(name.clone(), inode);
255        self.insert_rendered_child(dir, raw_name, &name, inode);
256        self.insert_folded_child(dir, &name, inode);
257    }
258
259    fn ensure_dir(
260        &mut self,
261        parent: u64,
262        parent_path: &str,
263        name: &str,
264        alloc: &mut InodeAllocator,
265    ) -> (u64, String) {
266        let truncated = truncate_component(name, false);
267        let name = truncated.as_ref();
268        if let Some(existing) = self.dir_child_named(parent, name) {
269            let stored = self
270                .node(existing)
271                .expect("dir_child_named node")
272                .name
273                .clone();
274            return (existing, join_path(parent_path, &stored));
275        }
276        let unique = self.disambiguate(parent, name);
277        let full = join_path(parent_path, &unique);
278        let inode = alloc.intern(&full);
279        self.nodes.insert(
280            inode,
281            Node {
282                parent,
283                name: unique.clone(),
284                rendered_name: name.to_string(),
285                kind: NodeKind::Dir,
286            },
287        );
288        self.children.insert(inode, OrdMap::new());
289        self.rendered_children.insert(inode, OrdMap::new());
290        self.children
291            .get_mut(&parent)
292            .unwrap()
293            .insert(unique.clone(), inode);
294        self.insert_rendered_child(parent, name, &unique, inode);
295        self.insert_folded_child(parent, &unique, inode);
296        (inode, full)
297    }
298
299    /// Return `name` if free in `dir`, else append ` (k)` before the extension.
300    /// Freeness is case-folded when the tree is case-insensitive.
301    fn disambiguate(&self, dir: u64, name: &str) -> String {
302        if !self.taken(dir, name) {
303            return name.to_string();
304        }
305        for k in 2u32.. {
306            let candidate = suffix_candidate(name, k);
307            if !self.taken(dir, &candidate) {
308                return candidate;
309            }
310        }
311        unreachable!("an unoccupied candidate rank always exists")
312    }
313
314    /// Mirror a child insertion into the folded index (no-op unless folding).
315    fn insert_folded_child(&mut self, parent: u64, name: &str, inode: u64) {
316        if !self.case_insensitive {
317            return;
318        }
319        self.folded_children
320            .entry(parent)
321            .or_default()
322            .insert(fold(name), inode);
323    }
324
325    /// Mirror a child removal out of the folded index, dropping an emptied parent
326    /// bucket so lookups never resolve a stale inode (no-op unless folding).
327    fn remove_folded_child(&mut self, parent: u64, name: &str) {
328        if !self.case_insensitive {
329            return;
330        }
331        if let Some(bucket) = self.folded_children.get_mut(&parent) {
332            bucket.remove(&fold(name));
333            if bucket.is_empty() {
334                self.folded_children.remove(&parent);
335            }
336        }
337    }
338
339    /// True if `name` is already taken in `dir` (folded when case-insensitive).
340    fn taken(&self, dir: u64, name: &str) -> bool {
341        if self.case_insensitive {
342            self.folded_children
343                .get(&dir)
344                .is_some_and(|b| b.contains_key(&fold(name)))
345        } else {
346            self.children
347                .get(&dir)
348                .is_some_and(|c| c.contains_key(name))
349        }
350    }
351
352    /// An existing *directory* child of `dir` matching `name` (folded when
353    /// case-insensitive), for merge reuse. `None` if absent or a non-dir.
354    fn dir_child_named(&self, dir: u64, name: &str) -> Option<u64> {
355        let ino = if self.case_insensitive {
356            self.folded_children
357                .get(&dir)
358                .and_then(|b| b.get(&fold(name)).copied())
359        } else {
360            self.children.get(&dir).and_then(|c| c.get(name).copied())
361        }?;
362        self.is_dir(ino).then_some(ino)
363    }
364
365    /// Cheap rename-relevance gate for the child of `dir` whose current key is
366    /// `name` and pre-disambiguation name is `rendered`: true if perturbing that
367    /// child (removal, or a dir's introducing-id change) could rename any sibling
368    /// in a fresh build. O(log)-probe based on the fresh-build invariant: a
369    /// non-empty collision group always occupies its base key, with no free
370    /// candidate rank below an occupied member. False positives only cost a
371    /// redundant subtree rebuild; false negatives would break equivalence.
372    fn collision_gate(&self, dir: u64, name: &str, rendered: &str) -> bool {
373        // A suffixed member, or a literal name shaped like one (it may be the key
374        // a pushed group member reclaims), is always rename-relevant.
375        if name != rendered || is_suffix_shaped(name) {
376            return true;
377        }
378        let Some(kids) = self.children.get(&dir) else {
379            return false;
380        };
381        // `name == rendered`: this child owns its base key. Rename-relevant iff
382        // some other child (file or dir) shares the rendered name — probe the
383        // generated candidate keys until the first free rank.
384        for k in 2u32.. {
385            let candidate = suffix_candidate(rendered, k);
386            match kids.get(&candidate) {
387                None => return false,
388                Some(c) => {
389                    if self
390                        .nodes
391                        .get(c)
392                        .is_some_and(|n| n.rendered_name == rendered)
393                    {
394                        return true;
395                    }
396                }
397            }
398        }
399        unreachable!("probe terminates at the first free candidate rank")
400    }
401
402    /// All track ids referenced by file nodes (used to prune stale cache entries).
403    pub fn track_ids(&self) -> std::collections::HashSet<i64> {
404        self.nodes
405            .values()
406            .filter_map(|n| match &n.kind {
407                NodeKind::File { track_id } => Some(*track_id),
408                NodeKind::Dir => None,
409            })
410            .collect()
411    }
412
413    /// Inodes of `dir`'s direct children whose pre-disambiguation name is `rendered`.
414    fn children_by_rendered_with_examined(&self, dir: u64, rendered: &str) -> (Vec<u64>, usize) {
415        match self
416            .rendered_children
417            .get(&dir)
418            .and_then(|kids| kids.get(rendered))
419        {
420            None => (Vec::new(), 0),
421            Some(same_rendered) => {
422                let children: Vec<u64> = same_rendered.values().copied().collect();
423                let examined = same_rendered.len();
424                (children, examined)
425            }
426        }
427    }
428
429    /// Inodes of `dir`'s direct children whose pre-disambiguation name is `rendered`.
430    pub fn children_by_rendered(&self, dir: u64, rendered: &str) -> Vec<u64> {
431        self.children_by_rendered_with_examined(dir, rendered).0
432    }
433
434    #[cfg(test)]
435    fn children_by_rendered_examined_for_test(&self, dir: u64, rendered: &str) -> usize {
436        self.children_by_rendered_with_examined(dir, rendered).1
437    }
438
439    fn insert_rendered_child(&mut self, parent: u64, rendered: &str, name: &str, inode: u64) {
440        self.rendered_children
441            .entry(parent)
442            .or_default()
443            .entry(rendered.to_string())
444            .or_default()
445            .insert(name.to_string(), inode);
446    }
447
448    fn remove_rendered_child(&mut self, parent: u64, rendered: &str, name: &str) {
449        let Some(by_rendered) = self.rendered_children.get_mut(&parent) else {
450            return;
451        };
452        let remove_bucket = match by_rendered.get_mut(rendered) {
453            Some(same_rendered) => {
454                same_rendered.remove(name);
455                same_rendered.is_empty()
456            }
457            None => false,
458        };
459        if remove_bucket {
460            by_rendered.remove(rendered);
461        }
462    }
463
464    /// Minimum descendant track id under `ino` (a file's own id; a dir's min over
465    /// files). Returns `i64::MAX` if `ino` has no file descendants (empty subtree).
466    pub fn introducing_id(&self, ino: u64) -> i64 {
467        if let Some(NodeKind::File { track_id }) = self.nodes.get(&ino).map(|n| &n.kind) {
468            return *track_id;
469        }
470        let mut min = i64::MAX;
471        let mut stack = vec![ino];
472        while let Some(n) = stack.pop() {
473            match self.nodes.get(&n).map(|x| &x.kind) {
474                Some(NodeKind::File { track_id }) => min = min.min(*track_id),
475                _ => {
476                    if let Some(kids) = self.children.get(&n) {
477                        for &c in kids.values() {
478                            stack.push(c);
479                        }
480                    }
481                }
482            }
483        }
484        min
485    }
486
487    /// The full disambiguated path from root to `inode` (root returns "").
488    fn path_of(&self, inode: u64) -> String {
489        if inode == Self::ROOT {
490            return String::new();
491        }
492        let mut parts = Vec::new();
493        let mut cur = inode;
494        while cur != Self::ROOT {
495            let Some(n) = self.nodes.get(&cur) else {
496                break;
497            };
498            parts.push(n.name.clone());
499            cur = n.parent;
500        }
501        parts.reverse();
502        parts.join("/")
503    }
504
505    /// Remove the file node for `track_id` and prune now-empty ancestor dirs. Returns
506    /// the inode of the nearest surviving ancestor directory plus, when dirs were
507    /// pruned, the `(name, rendered_name)` of the topmost pruned dir (the direct
508    /// child the survivor lost — for collision-gated dirty bookkeeping).
509    pub fn remove_track(
510        &mut self,
511        track_id: i64,
512        _alloc: &mut InodeAllocator,
513    ) -> Option<(u64, Option<(String, String)>)> {
514        let ino = self.track_to_inode.remove(&track_id)?;
515        let parent = self.nodes.get(&ino)?.parent;
516        let names = self
517            .nodes
518            .get(&ino)
519            .map(|n| (n.name.clone(), n.rendered_name.clone()));
520        self.nodes.remove(&ino);
521        if let Some((name, rendered)) = names {
522            if let Some(kids) = self.children.get_mut(&parent) {
523                kids.remove(&name);
524            }
525            self.remove_rendered_child(parent, &rendered, &name);
526            self.remove_folded_child(parent, &name);
527        }
528        Some(self.prune_empty_dirs_upward(parent))
529    }
530
531    /// Rebuild the subtree rooted at directory `dir` so its disambiguation matches a
532    /// fresh `build_with`: collect every track currently under `dir`, remove them all
533    /// (pruning), then re-insert in ascending track-id order using each track's
534    /// RENDERED path from `new_paths`. `ensure_dir` reuses ancestors above `dir`, so
535    /// only `dir`'s subtree is rebuilt. Errs if a collected track has no entry in
536    /// `new_paths` (caller falls back to a full rebuild). See SP2 Component 3.
537    pub(crate) fn rebuild_subtree(
538        &mut self,
539        dir: u64,
540        new_paths: &std::collections::HashMap<i64, crate::refresh_diff::TrackRenderState>,
541        alloc: &mut InodeAllocator,
542    ) -> std::result::Result<(), RebuildError> {
543        let mut ids = Vec::new();
544        let mut stack = vec![dir];
545        while let Some(n) = stack.pop() {
546            match self.nodes.get(&n).map(|x| x.kind.clone()) {
547                Some(NodeKind::File { track_id }) => ids.push(track_id),
548                _ => {
549                    if let Some(kids) = self.children.get(&n) {
550                        for &c in kids.values() {
551                            stack.push(c);
552                        }
553                    }
554                }
555            }
556        }
557        for id in &ids {
558            self.remove_track(*id, alloc);
559        }
560        ids.sort_unstable();
561        for id in ids {
562            let path = new_paths
563                .get(&id)
564                .map(|s| s.path.as_str())
565                .ok_or(RebuildError::MissingRenderedPath(id))?;
566            self.insert_file(id, path, alloc);
567        }
568        Ok(())
569    }
570
571    /// Apply an incremental change set in place, producing a tree byte-identical to a
572    /// full `build_with` over the same final track set. `new_paths` maps every CURRENT
573    /// Removal-side dirty propagation: walk `leaf` -> root. At each level the
574    /// parent is dirtied only when the child link is rename-relevant
575    /// (`collision_gate`, O(log)) AND the removed `id` was the child's
576    /// introducing id (for the leaf itself `introducing_id` is an O(1) read of
577    /// its own track id; the O(subtree) walk runs only at gated dir levels). A
578    /// gated level where the min did NOT change ends the walk: `id` can't be
579    /// the min of any ancestor either.
580    fn dirty_removed_ancestors(
581        &self,
582        leaf: u64,
583        id: i64,
584        dirty: &mut std::collections::HashSet<u64>,
585    ) {
586        let mut child = leaf;
587        while child != Self::ROOT {
588            let Some((parent, name, rendered)) = self
589                .node(child)
590                .map(|n| (n.parent, n.name.clone(), n.rendered_name.clone()))
591            else {
592                break;
593            };
594            if self.collision_gate(parent, &name, &rendered) {
595                if self.introducing_id(child) == id {
596                    dirty.insert(parent);
597                } else {
598                    break;
599                }
600            }
601            child = parent;
602        }
603    }
604
605    /// Add-side dirty propagation: min-flip walk `d` -> root, collision-gated
606    /// per level. A gated level where the added `id` is not the new min ends
607    /// the walk: ancestor mins only decrease, so no higher flip is possible.
608    fn dirty_min_flip_ancestors(
609        &self,
610        d: u64,
611        id: i64,
612        dirty: &mut std::collections::HashSet<u64>,
613    ) {
614        let mut child = d;
615        while child != Self::ROOT {
616            let Some((parent, name, rendered)) = self
617                .node(child)
618                .map(|n| (n.parent, n.name.clone(), n.rendered_name.clone()))
619            else {
620                break;
621            };
622            if self.collision_gate(parent, &name, &rendered) {
623                if id < self.introducing_id(child) {
624                    dirty.insert(parent);
625                } else {
626                    break;
627                }
628            }
629            child = parent;
630        }
631    }
632
633    /// track id to its rendered path. Returns `Err(RebuildError)` on any inconsistency
634    /// (caller falls back to full build). See SP2 Component 3.
635    ///
636    /// Cost is O(changed) when no rendered names collide (#69): a parent dir is
637    /// dirtied — and its subtree rebuilt — only when `collision_gate` says the
638    /// change can actually rename a sibling, and the O(subtree) `introducing_id`
639    /// walks run only at gated levels. Returns the number of `rebuild_subtree`
640    /// calls performed — the tests' observability for the O(changed) contract
641    /// (a needless rebuild produces the same tree, so only the count can pin it).
642    pub(crate) fn apply_changes(
643        &mut self,
644        new_paths: &std::collections::HashMap<i64, crate::refresh_diff::TrackRenderState>,
645        changed: &[i64],
646        added: &[i64],
647        removed: &[i64],
648        alloc: &mut InodeAllocator,
649    ) -> std::result::Result<usize, RebuildError> {
650        use std::collections::HashSet;
651        let mut dirty: HashSet<u64> = HashSet::new();
652
653        // Partition `changed` into path-moved vs unchanged-path (using current tree).
654        let mut moved_out: Vec<i64> = Vec::new(); // remove old position
655        let mut moved_in: Vec<i64> = Vec::new(); // insert new position
656        for &id in changed {
657            let new_path = new_paths
658                .get(&id)
659                .map(|s| s.path.as_str())
660                .ok_or(RebuildError::MissingRenderedPath(id))?;
661            match self.inode_of_track(id) {
662                Some(ino) if self.path_of(ino) == new_path => { /* path stable: nothing */ }
663                Some(_) => {
664                    moved_out.push(id);
665                    moved_in.push(id);
666                }
667                None => {
668                    // A `changed` id absent from the current tree is unexpected,
669                    // but the add path is a superset that still yields a correct
670                    // tree, so we tolerate it as an insert rather than bailing.
671                    moved_in.push(id);
672                }
673            }
674        }
675
676        // (1) Dirty set on the OLD tree, BEFORE mutating.
677        for &id in removed.iter().chain(moved_out.iter()) {
678            let Some(leaf) = self.inode_of_track(id) else {
679                continue;
680            };
681            self.dirty_removed_ancestors(leaf, id, &mut dirty);
682        }
683        for &id in added.iter().chain(moved_in.iter()) {
684            let rendered = new_paths
685                .get(&id)
686                .map(|s| s.path.as_str())
687                .ok_or(RebuildError::MissingRenderedPath(id))?;
688            let comps: Vec<&str> = rendered.split('/').filter(|c| !c.is_empty()).collect();
689            let (d, consumed) = self.deepest_existing_ancestor(rendered);
690            // The first new component is the only insertion into existing
691            // structure; it can shift siblings only if its base key is occupied
692            // (fresh-build invariant: a non-empty group owns its base key).
693            if comps.get(consumed).is_some_and(|c| {
694                self.children
695                    .get(&d)
696                    .is_some_and(|kids| kids.contains_key(*c))
697            }) {
698                dirty.insert(d);
699            }
700            self.dirty_min_flip_ancestors(d, id, &mut dirty);
701        }
702
703        // (2) Structural mutation. A pruned dir chain is rename-relevant for the
704        // surviving parent only on a rendered-name collision (gated like step 1).
705        for &id in removed.iter().chain(moved_out.iter()) {
706            if let Some((surv, Some((name, rendered)))) = self.remove_track(id, alloc)
707                && self.collision_gate(surv, &name, &rendered)
708            {
709                dirty.insert(surv);
710            }
711        }
712        // Insert in ascending id order: two pending ids landing on the same fresh
713        // key (no existing collision, so no rebuild repairs it) must rank by id
714        // exactly like a fresh build, not by added-before-moved processing order.
715        let mut to_insert: Vec<i64> = added.iter().chain(moved_in.iter()).copied().collect();
716        to_insert.sort_unstable();
717        for id in to_insert {
718            let rendered = new_paths
719                .get(&id)
720                .map(|s| s.path.as_str())
721                .ok_or(RebuildError::MissingRenderedPath(id))?;
722            self.insert_file(id, rendered, alloc);
723        }
724
725        // (3) Keep only dirty dirs that still exist; (4) reduce to top-most and rebuild.
726        let mut live_dirty: Vec<u64> = dirty
727            .into_iter()
728            .filter(|d| self.node(*d).is_some())
729            .collect();
730        // Shallow first, by component count: ROOT's path is "" (0 components),
731        // which must sort before single-component dirs ("A", also 0 slashes).
732        live_dirty.sort_by_key(|d| {
733            self.path_of(*d)
734                .split('/')
735                .filter(|c| !c.is_empty())
736                .count()
737        });
738        let mut done: HashSet<u64> = HashSet::new();
739        let mut rebuilds = 0usize;
740        for d in live_dirty {
741            // Re-check: a shallower rebuild_subtree may have pruned this dir.
742            if self.node(d).is_none() {
743                continue;
744            }
745            // Skip if an ancestor is already rebuilt.
746            if self.ancestor_in(d, &done) {
747                continue;
748            }
749            self.rebuild_subtree(d, new_paths, alloc)?;
750            rebuilds += 1;
751            done.insert(d);
752        }
753        Ok(rebuilds)
754    }
755
756    /// The deepest directory that exists in the current tree along the RENDERED path
757    /// `rendered` (navigating by `rendered_name`), plus the number of leading path
758    /// components it consumed — `components[consumed]` is the first component that
759    /// would be newly created. Returns (ROOT, 0) if nothing below root exists.
760    fn deepest_existing_ancestor(&self, rendered: &str) -> (u64, usize) {
761        let comps: Vec<&str> = rendered.split('/').filter(|c| !c.is_empty()).collect();
762        let mut dir = Self::ROOT;
763        let mut consumed = 0;
764        // walk dir components only (exclude the final filename component)
765        for comp in &comps[..comps.len().saturating_sub(1)] {
766            let next = self
767                .children_by_rendered(dir, comp)
768                .into_iter()
769                .find(|&c| self.is_dir(c));
770            match next {
771                Some(c) => {
772                    dir = c;
773                    consumed += 1;
774                }
775                None => break,
776            }
777        }
778        (dir, consumed)
779    }
780
781    /// True if any inode in `set` is an ancestor of `node` (or equals it).
782    fn ancestor_in(&self, node: u64, set: &std::collections::HashSet<u64>) -> bool {
783        let mut cur = node;
784        loop {
785            if set.contains(&cur) {
786                return true;
787            }
788            if cur == Self::ROOT {
789                return false;
790            }
791            cur = match self.node(cur) {
792                Some(n) => n.parent,
793                None => return false,
794            };
795        }
796    }
797
798    /// Walk up from `dir`, removing empty directories; return the first non-empty
799    /// (surviving) ancestor and the `(name, rendered_name)` of the last (topmost)
800    /// dir removed, if any.
801    fn prune_empty_dirs_upward(&mut self, mut dir: u64) -> (u64, Option<(String, String)>) {
802        let mut last_pruned = None;
803        while dir != Self::ROOT && self.children.get(&dir).is_none_or(OrdMap::is_empty) {
804            let Some(node) = self.nodes.get(&dir) else {
805                break;
806            };
807            let parent = node.parent;
808            let names = self
809                .nodes
810                .get(&dir)
811                .map(|n| (n.name.clone(), n.rendered_name.clone()));
812            self.children.remove(&dir);
813            self.rendered_children.remove(&dir);
814            self.folded_children.remove(&dir);
815            self.nodes.remove(&dir);
816            if let Some((name, rendered)) = &names {
817                if let Some(kids) = self.children.get_mut(&parent) {
818                    kids.remove(name);
819                }
820                self.remove_rendered_child(parent, rendered, name);
821                self.remove_folded_child(parent, name);
822            }
823            last_pruned = names;
824            dir = parent;
825        }
826        (dir, last_pruned)
827    }
828}
829
830/// Join a parent path and a child name with `/`, treating an empty parent as root.
831fn join_path(parent: &str, name: &str) -> String {
832    if parent.is_empty() {
833        name.to_string()
834    } else {
835        format!("{parent}/{name}")
836    }
837}
838
839/// Split `name` the way `disambiguate` does: the extension is everything after
840/// the last `.`, unless the dot is the leading character (a dotfile stays whole).
841fn split_suffix_parts(name: &str) -> (&str, Option<&str>) {
842    match name.rfind('.') {
843        Some(i) if i > 0 => (&name[..i], Some(&name[i + 1..])),
844        _ => (name, None),
845    }
846}
847
848/// Linux NAME_MAX: a single path component may be at most this many *bytes*.
849/// A longer component makes lookup/readdir/stat fail with ENAMETOOLONG.
850const NAME_MAX: usize = 255;
851
852/// Largest char-boundary prefix of `s` that is at most `max` bytes.
853fn truncate_bytes(s: &str, max: usize) -> &str {
854    if s.len() <= max {
855        return s;
856    }
857    let mut end = max;
858    while end > 0 && !s.is_char_boundary(end) {
859        end -= 1;
860    }
861    &s[..end]
862}
863
864/// Truncate a rendered component to NAME_MAX bytes. For a leaf (`preserve_ext`),
865/// the stem is trimmed so `stem.ext` fits while the extension survives; if the
866/// extension alone is too long, the whole name is truncated. Borrows when the
867/// component already fits, so the common short-name path allocates nothing.
868fn truncate_component(name: &str, preserve_ext: bool) -> Cow<'_, str> {
869    if name.len() <= NAME_MAX {
870        return Cow::Borrowed(name);
871    }
872    if preserve_ext && let (stem, Some(ext)) = split_suffix_parts(name) {
873        // +1 for the '.' separator.
874        if let Some(budget) = NAME_MAX.checked_sub(ext.len() + 1).filter(|b| *b > 0) {
875            let stem_t = truncate_bytes(stem, budget);
876            return Cow::Owned(format!("{stem_t}.{ext}"));
877        }
878    }
879    Cow::Owned(truncate_bytes(name, NAME_MAX).to_string())
880}
881
882/// The rank-`k` disambiguated candidate for `name` (k >= 2): ` (k)` appended to
883/// the stem, before any extension. Single source of the suffix format —
884/// `disambiguate` generates with it and `collision_gate` probes with it.
885fn suffix_candidate(name: &str, k: u32) -> String {
886    let (stem, ext) = split_suffix_parts(name);
887    let suffix = format!(" ({k})");
888    let ext_part = match ext {
889        Some(e) => format!(".{e}"),
890        None => String::new(),
891    };
892    let budget = NAME_MAX.saturating_sub(suffix.len() + ext_part.len());
893    let stem_t = truncate_bytes(stem, budget);
894    format!("{stem_t}{suffix}{ext_part}")
895}
896
897/// True if `name` could be a `suffix_candidate` output. Such a name may be the
898/// key another rendered-name group's member would claim in a fresh build, so
899/// perturbing it must conservatively trigger a parent rebuild.
900fn is_suffix_shaped(name: &str) -> bool {
901    let (stem, _) = split_suffix_parts(name);
902    let Some(open) = stem.rfind(" (") else {
903        return false;
904    };
905    let Some(inner) = stem[open + 2..].strip_suffix(')') else {
906        return false;
907    };
908    inner.parse::<u32>().is_ok_and(|k| k >= 2)
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914    use crate::refresh_diff::TrackRenderState;
915    use musefs_db::Format;
916
917    fn trs(path: &str) -> TrackRenderState {
918        TrackRenderState {
919            content_version: 0,
920            format: Format::Flac,
921            path: path.into(),
922        }
923    }
924
925    #[test]
926    fn build_with_keeps_inodes_stable_across_rebuilds() {
927        let mut alloc = InodeAllocator::new(false);
928        let t1 = VirtualTree::build_with(&[(10, "Alice/Song.flac".into())], &mut alloc);
929        let alice1 = t1.lookup(VirtualTree::ROOT, "Alice").unwrap();
930        let song1 = t1.lookup(alice1, "Song.flac").unwrap();
931        let t2 = VirtualTree::build_with(
932            &[
933                (10, "Alice/Song.flac".into()),
934                (20, "Bob/Other.flac".into()),
935            ],
936            &mut alloc,
937        );
938        let alice2 = t2.lookup(VirtualTree::ROOT, "Alice").unwrap();
939        let song2 = t2.lookup(alice2, "Song.flac").unwrap();
940        assert_eq!(alice1, alice2);
941        assert_eq!(song1, song2);
942        let bob2 = t2.lookup(VirtualTree::ROOT, "Bob").unwrap();
943        assert!(bob2 != alice2 && bob2 != song2);
944    }
945
946    #[test]
947    fn inode_of_track_maps_file_nodes() {
948        let t = VirtualTree::build(&[(10, "Alice/Song.flac".into()), (20, "Bob/Tune.flac".into())]);
949        let alice = t.lookup(VirtualTree::ROOT, "Alice").unwrap();
950        let song = t.lookup(alice, "Song.flac").unwrap();
951        assert_eq!(t.inode_of_track(10), Some(song));
952        assert!(t.inode_of_track(20).is_some());
953        assert_eq!(t.inode_of_track(999), None);
954    }
955
956    #[test]
957    fn build_with_does_not_recycle_a_vanished_inode() {
958        let mut alloc = InodeAllocator::new(false);
959        let t1 = VirtualTree::build_with(&[(10, "Gone/X.flac".into())], &mut alloc);
960        let gone = t1.lookup(VirtualTree::ROOT, "Gone").unwrap();
961        let x = t1.lookup(gone, "X.flac").unwrap();
962        let t2 = VirtualTree::build_with(&[(20, "New/Y.flac".into())], &mut alloc);
963        let new = t2.lookup(VirtualTree::ROOT, "New").unwrap();
964        let y = t2.lookup(new, "Y.flac").unwrap();
965        assert!(new != gone && new != x && y != gone && y != x);
966    }
967
968    #[test]
969    fn prune_retired_bounds_map_under_churn() {
970        let mut alloc = InodeAllocator::new(false);
971        for generation in 0..100 {
972            let entries = vec![(1, format!("Gen{generation}/a.flac"))];
973            let tree = VirtualTree::build_with(&entries, &mut alloc);
974            alloc.prune_retired(&tree);
975            assert!(
976                alloc.paths.len() <= 2 * tree.nodes.len(),
977                "gen {generation}: map {} exceeds 2x live {}",
978                alloc.paths.len(),
979                tree.nodes.len()
980            );
981        }
982    }
983
984    #[test]
985    fn prune_retired_keeps_live_inodes_stable() {
986        let mut alloc = InodeAllocator::new(false);
987        let tree = VirtualTree::build_with(&[(1, "Keep/song.flac".into())], &mut alloc);
988        let keep_dir = tree.lookup(VirtualTree::ROOT, "Keep").unwrap();
989        let keep_file = tree.lookup(keep_dir, "song.flac").unwrap();
990        let mut last = tree;
991        for generation in 0..10 {
992            let entries = vec![
993                (1, "Keep/song.flac".to_string()),
994                (2, format!("Gen{generation}/x.flac")),
995            ];
996            last = VirtualTree::build_with(&entries, &mut alloc);
997            alloc.prune_retired(&last);
998        }
999        let d = last.lookup(VirtualTree::ROOT, "Keep").unwrap();
1000        let f = last.lookup(d, "song.flac").unwrap();
1001        assert_eq!((d, f), (keep_dir, keep_file), "live paths must keep inodes");
1002    }
1003
1004    #[test]
1005    fn pruned_path_reborn_gets_fresh_inode_never_recycled() {
1006        let mut alloc = InodeAllocator::new(false);
1007        let t1 = VirtualTree::build_with(&[(1, "Gone/x.flac".into())], &mut alloc);
1008        let gone_dir = t1.lookup(VirtualTree::ROOT, "Gone").unwrap();
1009        let gone_file = t1.lookup(gone_dir, "x.flac").unwrap();
1010        // Churn well past the threshold so a prune drops the retired entries.
1011        for generation in 0..10 {
1012            let t = VirtualTree::build_with(&[(1, format!("Gen{generation}/x.flac"))], &mut alloc);
1013            alloc.prune_retired(&t);
1014        }
1015        assert!(
1016            !alloc.paths.contains_key("Gone"),
1017            "retired path must be pruned"
1018        );
1019        // Rebirth: same rendered path, strictly fresh inodes (next is monotone).
1020        let t2 = VirtualTree::build_with(&[(1, "Gone/x.flac".into())], &mut alloc);
1021        let d2 = t2.lookup(VirtualTree::ROOT, "Gone").unwrap();
1022        let f2 = t2.lookup(d2, "x.flac").unwrap();
1023        assert!(
1024            d2 > gone_file && f2 > gone_file,
1025            "fresh inodes, never recycled"
1026        );
1027        assert_ne!(d2, gone_dir);
1028        assert_ne!(f2, gone_file);
1029    }
1030
1031    #[test]
1032    fn prune_retired_waits_for_threshold() {
1033        // Drives the map to exactly 2x live nodes: prune must NOT fire at
1034        // equality (pins the `<=` boundary for the mutation gate).
1035        let mut alloc = InodeAllocator::new(false);
1036        let t1 = VirtualTree::build_with(&[(1, "A/x.flac".into())], &mut alloc);
1037        let a_dir = t1.lookup(VirtualTree::ROOT, "A").unwrap();
1038        // paths: "", A, A/x.flac = 3
1039        let t2 = VirtualTree::build_with(&[(1, "B/x.flac".into())], &mut alloc);
1040        alloc.prune_retired(&t2); // paths 5, live 3 -> 5 <= 6, no prune
1041        let t3 = VirtualTree::build_with(&[(1, "B/y.flac".into())], &mut alloc);
1042        alloc.prune_retired(&t3); // paths 6, live 3 -> 6 <= 6, still no prune
1043        assert_eq!(
1044            alloc.paths.get("A"),
1045            Some(&a_dir),
1046            "at exactly 2x live the retired entries must survive"
1047        );
1048        let t4 = VirtualTree::build_with(&[(1, "C/x.flac".into())], &mut alloc);
1049        alloc.prune_retired(&t4); // paths 8 > 6: prune fires
1050        assert!(
1051            !alloc.paths.contains_key("A"),
1052            "past 2x live the prune must fire"
1053        );
1054        assert_eq!(
1055            alloc.paths.len(),
1056            t4.nodes.len(),
1057            "pruned map is exactly the live set"
1058        );
1059    }
1060
1061    #[test]
1062    fn disambiguate_keeps_dotfile_whole_and_splits_normal_ext() {
1063        let t = VirtualTree::build(&[
1064            (10, "D/.hidden".into()),
1065            (20, "D/.hidden".into()),
1066            (30, "D/a.ext".into()),
1067            (40, "D/a.ext".into()),
1068        ]);
1069        let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1070        assert!(t.lookup(d, ".hidden").is_some());
1071        assert!(t.lookup(d, ".hidden (2)").is_some());
1072        assert!(
1073            t.lookup(d, " (2).hidden").is_none(),
1074            "must not split at the index-0 dot"
1075        );
1076        assert!(t.lookup(d, "a.ext").is_some());
1077        assert!(t.lookup(d, "a (2).ext").is_some());
1078    }
1079
1080    #[test]
1081    fn disambiguate_resolves_three_way_collision() {
1082        let t = VirtualTree::build(&[
1083            (10, "D/song.flac".into()),
1084            (20, "D/song.flac".into()),
1085            (30, "D/song.flac".into()),
1086        ]);
1087        let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1088        assert!(t.lookup(d, "song.flac").is_some());
1089        assert!(t.lookup(d, "song (2).flac").is_some());
1090        assert!(t.lookup(d, "song (3).flac").is_some());
1091    }
1092
1093    #[test]
1094    fn case_insensitive_merges_directories() {
1095        // Two artist dirs differing only by case collapse into one; both titles
1096        // live under the first-seen casing.
1097        let entries = vec![(1i64, "Foo/A".to_string()), (2i64, "foo/B".to_string())];
1098        let tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(true), true);
1099        let foo = tree.lookup(VirtualTree::ROOT, "Foo").expect("Foo dir");
1100        // Case-insensitive lookup resolves any casing to the same inode.
1101        assert_eq!(tree.lookup(VirtualTree::ROOT, "foo"), Some(foo));
1102        assert_eq!(tree.lookup(VirtualTree::ROOT, "FOO"), Some(foo));
1103        // Exactly one child of root (the merged dir), with both files under it.
1104        assert_eq!(tree.children(VirtualTree::ROOT).unwrap().len(), 1);
1105        assert!(tree.lookup(foo, "A").is_some());
1106        assert!(tree.lookup(foo, "B").is_some());
1107        assert_eq!(tree.children(foo).unwrap().len(), 2);
1108    }
1109
1110    #[test]
1111    fn case_insensitive_disambiguates_leaf_files() {
1112        // Two files in the same dir whose names differ only by case must NOT
1113        // collide: the second is disambiguated.
1114        let entries = vec![
1115            (1i64, "Dir/Song".to_string()),
1116            (2i64, "Dir/song".to_string()),
1117        ];
1118        let tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(true), true);
1119        let dir = tree.lookup(VirtualTree::ROOT, "Dir").expect("Dir");
1120        let names: Vec<String> = tree.children(dir).unwrap().keys().cloned().collect();
1121        // First-seen "Song" keeps its name; "song" becomes "song (2)".
1122        assert!(names.contains(&"Song".to_string()));
1123        assert!(names.contains(&"song (2)".to_string()));
1124        assert_eq!(names.len(), 2);
1125    }
1126
1127    #[test]
1128    fn case_sensitive_keeps_both_dirs_separate() {
1129        // Control: with folding OFF, "Foo" and "foo" are distinct dirs and a
1130        // differently-cased query misses.
1131        let entries = vec![(1i64, "Foo/A".to_string()), (2i64, "foo/B".to_string())];
1132        let tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(false), false);
1133        assert_eq!(tree.children(VirtualTree::ROOT).unwrap().len(), 2);
1134        assert_ne!(
1135            tree.lookup(VirtualTree::ROOT, "Foo"),
1136            tree.lookup(VirtualTree::ROOT, "foo")
1137        );
1138        assert_eq!(tree.lookup(VirtualTree::ROOT, "FOO"), None);
1139    }
1140
1141    #[test]
1142    fn case_insensitive_removal_keeps_folded_lookup_consistent() {
1143        // A folded tree mutated in place must not resolve a removed name via the
1144        // folded index (the index is maintained on removal, not just insert).
1145        let entries = vec![
1146            (1i64, "Dir/Song".to_string()),
1147            (2i64, "Dir/Other".to_string()),
1148        ];
1149        let mut tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(true), true);
1150        let dir = tree.lookup(VirtualTree::ROOT, "dir").expect("dir");
1151        assert!(tree.lookup(dir, "song").is_some());
1152
1153        tree.remove_track(1, &mut InodeAllocator::new(false));
1154
1155        // The removed file no longer resolves (any casing); the sibling still does.
1156        assert_eq!(tree.lookup(dir, "song"), None);
1157        assert_eq!(tree.lookup(dir, "Song"), None);
1158        assert!(tree.lookup(dir, "other").is_some());
1159    }
1160
1161    #[test]
1162    fn folded_dir_recase_keeps_survivor_inode() {
1163        // #305: track 1 seen first wins the merged directory's casing ("Foo");
1164        // track 2 merges under it. Removing track 1 lets the next full rebuild
1165        // re-derive the casing from track 2 alone, rendering "foo". Track 2's
1166        // rendered path is unchanged, so its inode must be stable.
1167        let mut alloc = InodeAllocator::new(true);
1168        let entries = vec![(1i64, "Foo/A".to_string()), (2i64, "foo/B".to_string())];
1169        let tree = VirtualTree::build_with_ci(&entries, &mut alloc, true);
1170        let before = tree.inode_of_track(2).expect("track 2 inode");
1171
1172        let rebuilt = VirtualTree::build_with_ci(&[(2i64, "foo/B".to_string())], &mut alloc, true);
1173        let after = rebuilt
1174            .inode_of_track(2)
1175            .expect("track 2 inode after rebuild");
1176
1177        assert_eq!(
1178            before, after,
1179            "survivor inode must survive a folded dir re-case"
1180        );
1181        // Scope is inode stability only: the directory legitimately re-cases to "foo".
1182        assert!(rebuilt.lookup(VirtualTree::ROOT, "foo").is_some());
1183    }
1184
1185    #[test]
1186    fn folded_inode_key_keeps_disambiguated_siblings_distinct() {
1187        // Guard that folding the key never collapses a legitimately-disambiguated
1188        // pair: "Song" and "song" fold equal, so the second becomes "song (2)";
1189        // their fold-keys ("dir/song" vs "dir/song (2)") differ, so do their inodes.
1190        let mut alloc = InodeAllocator::new(true);
1191        let entries = vec![
1192            (1i64, "Dir/Song".to_string()),
1193            (2i64, "Dir/song".to_string()),
1194        ];
1195        let tree = VirtualTree::build_with_ci(&entries, &mut alloc, true);
1196        let a = tree.inode_of_track(1).expect("track 1 inode");
1197        let b = tree.inode_of_track(2).expect("track 2 inode");
1198        assert_ne!(
1199            a, b,
1200            "disambiguated folded siblings must not collapse to one inode"
1201        );
1202
1203        let dir = tree.lookup(VirtualTree::ROOT, "Dir").expect("Dir");
1204        assert_eq!(tree.lookup(dir, "Song"), Some(a));
1205        assert_eq!(tree.lookup(dir, "song (2)"), Some(b));
1206    }
1207
1208    #[test]
1209    fn child_by_rendered_finds_disambiguated_node() {
1210        let t = VirtualTree::build(&[(10, "D/song.flac".into()), (20, "D/song.flac".into())]);
1211        let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1212        let base = t.lookup(d, "song.flac").unwrap();
1213        let suffixed = t.lookup(d, "song (2).flac").unwrap();
1214
1215        assert_eq!(t.children_by_rendered(d, "song.flac"), vec![suffixed, base]);
1216        assert_eq!(t.children_by_rendered_examined_for_test(d, "song.flac"), 2);
1217    }
1218
1219    #[test]
1220    fn deepest_existing_ancestor_preserves_rendered_dir_vs_file_order() {
1221        let t = VirtualTree::build(&[(1, "X".into()), (2, "X/a.flac".into())]);
1222        let file = t.lookup(VirtualTree::ROOT, "X").unwrap();
1223        let dir = t.lookup(VirtualTree::ROOT, "X (2)").unwrap();
1224
1225        assert_eq!(
1226            t.children_by_rendered(VirtualTree::ROOT, "X"),
1227            vec![file, dir]
1228        );
1229        assert_eq!(
1230            t.deepest_existing_ancestor("X/new.flac"),
1231            (dir, 1),
1232            "same-rendered file must not hide the matching directory"
1233        );
1234    }
1235
1236    #[test]
1237    fn children_by_rendered_updates_when_collision_member_removed() {
1238        let mut alloc = InodeAllocator::new(false);
1239        let mut t = VirtualTree::build_with(
1240            &[(10, "D/song.flac".into()), (20, "D/song.flac".into())],
1241            &mut alloc,
1242        );
1243        let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1244        let survivor = t.lookup(d, "song (2).flac").unwrap();
1245
1246        t.remove_track(10, &mut alloc);
1247
1248        assert_eq!(t.children_by_rendered(d, "song.flac"), vec![survivor]);
1249        assert_eq!(t.children_by_rendered_examined_for_test(d, "song.flac"), 1);
1250    }
1251
1252    #[test]
1253    fn children_by_rendered_updates_when_empty_dir_pruned() {
1254        let mut alloc = InodeAllocator::new(false);
1255        let mut t = VirtualTree::build_with(
1256            &[(10, "A/B/x.flac".into()), (20, "A/C/y.flac".into())],
1257            &mut alloc,
1258        );
1259        let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1260        assert_eq!(t.children_by_rendered_examined_for_test(a, "B"), 1);
1261
1262        t.remove_track(10, &mut alloc);
1263
1264        assert!(t.children_by_rendered(a, "B").is_empty());
1265        assert_eq!(t.children_by_rendered_examined_for_test(a, "B"), 0);
1266        assert_eq!(t.children_by_rendered_examined_for_test(a, "C"), 1);
1267    }
1268
1269    #[test]
1270    fn deepest_existing_ancestor_rendered_miss_does_not_scan_root_fanout() {
1271        let entries: Vec<(i64, String)> = (0..1024)
1272            .map(|i| {
1273                (
1274                    i64::from(i),
1275                    format!("Artist {i:04}/Album {i:04}/Track.flac"),
1276                )
1277            })
1278            .collect();
1279        let t = VirtualTree::build(&entries);
1280
1281        assert_eq!(
1282            t.deepest_existing_ancestor("Unknown/Unknown/new.flac"),
1283            (VirtualTree::ROOT, 0)
1284        );
1285        assert_eq!(
1286            t.children_by_rendered_examined_for_test(VirtualTree::ROOT, "Unknown"),
1287            0,
1288            "rendered-name miss should examine no same-rendered candidates"
1289        );
1290    }
1291
1292    #[test]
1293    fn introducing_id_is_min_descendant_track_id() {
1294        let mut alloc = InodeAllocator::new(false);
1295        let t = VirtualTree::build_with(
1296            &[(30, "A/B/x.flac".into()), (10, "A/C/y.flac".into())],
1297            &mut alloc,
1298        );
1299        let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1300        assert_eq!(t.introducing_id(a), 10);
1301    }
1302
1303    #[test]
1304    fn remove_track_prunes_empty_ancestors_b() {
1305        let mut alloc = InodeAllocator::new(false);
1306        let mut t = VirtualTree::build_with(
1307            &[(10, "A/B/x.flac".into()), (20, "C/y.flac".into())],
1308            &mut alloc,
1309        );
1310        t.remove_track(10, &mut alloc);
1311        assert!(t.inode_of_track(10).is_none());
1312        assert!(t.lookup(VirtualTree::ROOT, "A").is_none());
1313        assert!(t.lookup(VirtualTree::ROOT, "C").is_some());
1314    }
1315
1316    #[test]
1317    fn remove_track_keeps_parent_with_surviving_sibling() {
1318        let mut alloc = InodeAllocator::new(false);
1319        let mut t = VirtualTree::build_with(
1320            &[(10, "A/x.flac".into()), (20, "A/y.flac".into())],
1321            &mut alloc,
1322        );
1323        let surviving = t.remove_track(10, &mut alloc);
1324        assert!(t.inode_of_track(10).is_none());
1325        // `A` must survive because `y.flac` still lives under it; remove_track
1326        // returns A's inode (nearest surviving ancestor) and no pruned dir.
1327        let a = t.lookup(VirtualTree::ROOT, "A").expect("A must survive");
1328        assert_eq!(surviving, Some((a, None)));
1329        assert!(t.lookup(a, "y.flac").is_some());
1330    }
1331
1332    fn paths_of(t: &VirtualTree) -> std::collections::BTreeMap<String, u64> {
1333        let mut out = std::collections::BTreeMap::new();
1334        let mut stack = vec![(VirtualTree::ROOT, String::new())];
1335        while let Some((ino, pfx)) = stack.pop() {
1336            if let Some(kids) = t.children(ino) {
1337                for (name, &child) in kids {
1338                    let p = if pfx.is_empty() {
1339                        name.clone()
1340                    } else {
1341                        format!("{pfx}/{name}")
1342                    };
1343                    if t.is_dir(child) {
1344                        stack.push((child, p));
1345                    } else {
1346                        out.insert(p, child);
1347                    }
1348                }
1349            }
1350        }
1351        out
1352    }
1353
1354    #[test]
1355    fn rebuild_subtree_reports_missing_rendered_path() {
1356        use std::collections::HashMap;
1357        let mut alloc = InodeAllocator::new(false);
1358        let mut tree = VirtualTree::build_with(&[(10, "Alice/Song.flac".into())], &mut alloc);
1359        let dir = tree.lookup(VirtualTree::ROOT, "Alice").unwrap();
1360        let new_paths: HashMap<i64, TrackRenderState> = HashMap::new(); // omits track 10
1361        let err = tree
1362            .rebuild_subtree(dir, &new_paths, &mut alloc)
1363            .unwrap_err();
1364        assert_eq!(err, RebuildError::MissingRenderedPath(10));
1365    }
1366
1367    #[test]
1368    fn rebuild_subtree_reclaims_freed_base_name() {
1369        let mut alloc = InodeAllocator::new(false);
1370        let mut t = VirtualTree::build_with(
1371            &[(10, "D/song.flac".into()), (20, "D/song.flac".into())],
1372            &mut alloc,
1373        );
1374        let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1375        t.remove_track(10, &mut alloc);
1376        // new_paths after removal: only id 20 remains, rendered "D/song.flac".
1377        let mut np = std::collections::HashMap::new();
1378        np.insert(20, trs("D/song.flac"));
1379        t.rebuild_subtree(d, &np, &mut alloc).unwrap();
1380        let reborn = t.lookup(d, "song.flac").unwrap();
1381        assert_eq!(t.inode_of_track(20), Some(reborn));
1382        assert!(t.lookup(d, "song (2).flac").is_none());
1383    }
1384
1385    #[test]
1386    fn rebuild_subtree_matches_build_for_dir_vs_file() {
1387        // $album="X.flac" produces dir "X.flac"; a sibling file also "X.flac".
1388        let entries = vec![
1389            (1, "P/X.flac".to_string()),
1390            (2, "P/X.flac/t.flac".to_string()),
1391        ];
1392        let reference = VirtualTree::build(&entries);
1393        let mut alloc = InodeAllocator::new(false);
1394        let mut t = VirtualTree::build_with(&entries, &mut alloc);
1395        let p = t.lookup(VirtualTree::ROOT, "P").unwrap();
1396        let np: std::collections::HashMap<i64, TrackRenderState> =
1397            entries.iter().map(|&(id, ref p)| (id, trs(p))).collect();
1398        t.rebuild_subtree(p, &np, &mut alloc).unwrap();
1399        assert_eq!(
1400            paths_of(&t).keys().collect::<Vec<_>>(),
1401            paths_of(&reference).keys().collect::<Vec<_>>()
1402        );
1403    }
1404
1405    #[test]
1406    fn apply_changes_handles_dir_vs_file_min_id_flip() {
1407        // P has dir "X.flac" (from $album="X.flac", tracks 1 & 9) and file "X.flac"
1408        // (track 5). Ascending id: dir introduced by 1 claims "X.flac"; file 5 -> "X (2).flac".
1409        let entries = vec![
1410            (1, "X.flac/a.flac".to_string()),
1411            (9, "X.flac/b.flac".to_string()),
1412            (5, "X.flac".to_string()), // a FILE rendered "X.flac" in root
1413        ];
1414        let mut alloc = InodeAllocator::new(false);
1415        let mut t = VirtualTree::build_with(&entries, &mut alloc);
1416        // Delete track 1 (the dir's min). Dir's introducing id rises to 9; file 5 (id 5 < 9)
1417        // should now claim base "X.flac" and the dir become "X.flac (2)".
1418        // Production establishes the build path's canonical order by sorting
1419        // ascending by id in `render_entries` (its `order_entries` helper, #188)
1420        // — not by inheriting `list_tracks`'s ORDER BY. The reference must use
1421        // that same canonical order to be a meaningful oracle; the inner build
1422        // primitive deliberately does NOT sort (these tests feed it id-unordered
1423        // inputs).
1424        let mut new_entries = vec![(9, "X.flac/b.flac".to_string()), (5, "X.flac".to_string())];
1425        new_entries.sort_by_key(|(id, _)| *id);
1426        let reference = VirtualTree::build(&new_entries);
1427        let new_paths: std::collections::HashMap<i64, TrackRenderState> = new_entries
1428            .iter()
1429            .map(|&(id, ref p)| (id, trs(p)))
1430            .collect();
1431        t.apply_changes(&new_paths, &[], &[], &[1], &mut alloc)
1432            .unwrap();
1433        assert_eq!(
1434            paths_of(&t).keys().collect::<Vec<_>>(),
1435            paths_of(&reference).keys().collect::<Vec<_>>(),
1436            "dir-vs-file min-id flip must match a full rebuild"
1437        );
1438    }
1439
1440    #[test]
1441    fn apply_changes_handles_add_side_min_id_flip() {
1442        // Initial: file "X.flac" (id 2) claims the base name; dir "X.flac"
1443        // (introduced by id 5) is disambiguated to "X.flac (2)".
1444        let entries = vec![(2, "X.flac".to_string()), (5, "X.flac/a.flac".to_string())];
1445        let mut alloc = InodeAllocator::new(false);
1446        let mut t = VirtualTree::build_with(&entries, &mut alloc);
1447        // ADD track 1 under the dir: its id (1) is now the dir's min (< file's 2), so a
1448        // full rebuild gives the DIR the base name and the file becomes "X.flac (2)".
1449        let mut new_entries = vec![
1450            (1, "X.flac/b.flac".to_string()),
1451            (2, "X.flac".to_string()),
1452            (5, "X.flac/a.flac".to_string()),
1453        ];
1454        new_entries.sort_by_key(|(id, _)| *id);
1455        let reference = VirtualTree::build(&new_entries);
1456        let new_paths: std::collections::HashMap<i64, TrackRenderState> = new_entries
1457            .iter()
1458            .map(|&(id, ref p)| (id, trs(p)))
1459            .collect();
1460        t.apply_changes(&new_paths, &[], &[1], &[], &mut alloc)
1461            .unwrap();
1462        assert_eq!(
1463            paths_of(&t).keys().collect::<Vec<_>>(),
1464            paths_of(&reference).keys().collect::<Vec<_>>(),
1465            "add-side dir-vs-file min-id flip must match a full rebuild"
1466        );
1467    }
1468
1469    #[test]
1470    fn apply_changes_handles_moved_track_across_dirs() {
1471        // A track moves from one album dir to another (a `changed` id whose rendered
1472        // path differs from its current placement): it must be removed from the old
1473        // leaf and inserted at the new one, matching a canonical full rebuild.
1474        let entries = vec![
1475            (1, "Old/a.flac".to_string()),
1476            (2, "Old/b.flac".to_string()),
1477            (3, "New/c.flac".to_string()),
1478        ];
1479        let mut alloc = InodeAllocator::new(false);
1480        let mut t = VirtualTree::build_with(&entries, &mut alloc);
1481        // Track 2 moves Old -> New.
1482        let mut new_entries = vec![
1483            (1, "Old/a.flac".to_string()),
1484            (2, "New/b.flac".to_string()),
1485            (3, "New/c.flac".to_string()),
1486        ];
1487        new_entries.sort_by_key(|(id, _)| *id);
1488        let reference = VirtualTree::build(&new_entries);
1489        let new_paths: std::collections::HashMap<i64, TrackRenderState> = new_entries
1490            .iter()
1491            .map(|&(id, ref p)| (id, trs(p)))
1492            .collect();
1493        t.apply_changes(&new_paths, &[2], &[], &[], &mut alloc)
1494            .unwrap();
1495        assert_eq!(
1496            paths_of(&t).keys().collect::<Vec<_>>(),
1497            paths_of(&reference).keys().collect::<Vec<_>>(),
1498            "moved track must match a full rebuild"
1499        );
1500    }
1501
1502    #[test]
1503    fn apply_changes_unchanged_path_is_noop_for_changed_id() {
1504        // A `changed` id whose rendered path is identical (e.g. a tag edit that does
1505        // not affect the template) must leave structure untouched.
1506        let entries = vec![(1, "A/x.flac".to_string()), (2, "A/y.flac".to_string())];
1507        let mut alloc = InodeAllocator::new(false);
1508        let mut t = VirtualTree::build_with(&entries, &mut alloc);
1509        let reference = VirtualTree::build(&entries);
1510        let new_paths: std::collections::HashMap<i64, TrackRenderState> =
1511            entries.iter().map(|&(id, ref p)| (id, trs(p))).collect();
1512        let rebuilds = t
1513            .apply_changes(&new_paths, &[1], &[], &[], &mut alloc)
1514            .unwrap();
1515        assert_eq!(rebuilds, 0, "a stable-path changed id must rebuild nothing");
1516        assert_eq!(
1517            paths_of(&t).keys().collect::<Vec<_>>(),
1518            paths_of(&reference).keys().collect::<Vec<_>>(),
1519            "unchanged-path changed id must be a no-op"
1520        );
1521    }
1522
1523    /// Oracle helper for the collision pins below: apply `changed`/`added`/`removed`
1524    /// against `before`, then require full `equiv` (inodes included) with a
1525    /// `build_with` over `after` on a cloned allocator — the same oracle the
1526    /// facade's debug-assert uses — AND that exactly `expected_rebuilds`
1527    /// subtree rebuilds ran (the O(changed) contract: a needless rebuild yields
1528    /// the same tree, so only the count can pin it).
1529    ///
1530    /// `after` is sorted ascending by id before building the reference, mirroring
1531    /// the canonical order production establishes in `render_entries` (#188). The
1532    /// `build_with` primitive itself does NOT sort, so the oracle must.
1533    fn assert_apply_matches_build(
1534        before: &[(i64, String)],
1535        after: &[(i64, String)],
1536        changed: &[i64],
1537        added: &[i64],
1538        removed: &[i64],
1539        expected_rebuilds: usize,
1540    ) {
1541        let mut alloc = InodeAllocator::new(false);
1542        let mut t = VirtualTree::build_with(before, &mut alloc);
1543        let mut after_sorted = after.to_vec();
1544        after_sorted.sort_by_key(|(id, _)| *id);
1545        let new_paths: std::collections::HashMap<i64, TrackRenderState> = after_sorted
1546            .iter()
1547            .map(|&(id, ref p)| (id, trs(p)))
1548            .collect();
1549        let rebuilds = t
1550            .apply_changes(&new_paths, changed, added, removed, &mut alloc)
1551            .unwrap();
1552        assert_eq!(rebuilds, expected_rebuilds, "subtree rebuild count");
1553        let mut ref_alloc = alloc.clone();
1554        let reference = VirtualTree::build_with(&after_sorted, &mut ref_alloc);
1555        assert!(
1556            t.equiv(&reference),
1557            "incremental tree diverged from build_with\n  applied: {:?}\n  reference: {:?}",
1558            paths_of(&t).keys().collect::<Vec<_>>(),
1559            paths_of(&reference).keys().collect::<Vec<_>>(),
1560        );
1561    }
1562
1563    #[test]
1564    fn apply_changes_remove_base_of_collision_group_renames_survivor() {
1565        // ids 1,2 both render "A/t.flac": 1 -> "t.flac", 2 -> "t (2).flac".
1566        // Removing 1 must give 2 the base name back.
1567        let before = vec![(1, "A/t.flac".to_string()), (2, "A/t.flac".to_string())];
1568        let after = vec![(2, "A/t.flac".to_string())];
1569        assert_apply_matches_build(&before, &after, &[], &[], &[1], 1);
1570    }
1571
1572    #[test]
1573    fn apply_changes_remove_literal_suffix_name_frees_key_for_pushed_member() {
1574        // id 2's RENDERED name is literally "t (2).flac" (its name equals its
1575        // rendered name), which pushed group-"t.flac" member id 3 to "t (3).flac".
1576        // Removing 2 frees the key: a fresh build puts 3 at "t (2).flac".
1577        let before = vec![
1578            (1, "A/t.flac".to_string()),
1579            (2, "A/t (2).flac".to_string()),
1580            (3, "A/t.flac".to_string()),
1581        ];
1582        let after = vec![(1, "A/t.flac".to_string()), (3, "A/t.flac".to_string())];
1583        assert_apply_matches_build(&before, &after, &[], &[], &[2], 1);
1584    }
1585
1586    #[test]
1587    fn apply_changes_add_smaller_id_into_collision_group_shifts_member() {
1588        // id 5 holds base "t.flac"; adding id 2 with the same rendered name must
1589        // re-rank the group: 2 -> "t.flac", 5 -> "t (2).flac".
1590        let before = vec![(5, "A/t.flac".to_string())];
1591        let after = vec![(2, "A/t.flac".to_string()), (5, "A/t.flac".to_string())];
1592        assert_apply_matches_build(&before, &after, &[], &[2], &[], 1);
1593    }
1594
1595    #[test]
1596    fn apply_changes_dir_reclaims_base_name_when_colliding_file_removed() {
1597        // File id 1 rendered "X" owns the base; the dir for id 2's "X/a.flac" was
1598        // disambiguated to "X (2)". Removing the file must rename the dir to "X".
1599        let before = vec![(1, "X".to_string()), (2, "X/a.flac".to_string())];
1600        let after = vec![(2, "X/a.flac".to_string())];
1601        assert_apply_matches_build(&before, &after, &[], &[], &[1], 1);
1602    }
1603
1604    #[test]
1605    fn apply_changes_remove_min_id_without_collisions_matches_build() {
1606        // The removed id is the introducing id of its whole ancestor chain but no
1607        // rendered names collide: equivalence must hold with no sibling churn.
1608        let before = vec![
1609            (1, "A/B/t1.flac".to_string()),
1610            (2, "A/B/t2.flac".to_string()),
1611            (3, "C/u.flac".to_string()),
1612        ];
1613        let after = vec![(2, "A/B/t2.flac".to_string()), (3, "C/u.flac".to_string())];
1614        assert_apply_matches_build(&before, &after, &[], &[], &[1], 0);
1615    }
1616
1617    #[test]
1618    fn apply_changes_add_new_top_level_dir_with_min_id_matches_build() {
1619        // The added id is smaller than every existing id (a root-wide min flip)
1620        // and lands under a brand-new top-level dir with no collisions.
1621        let before = vec![(5, "A/t1.flac".to_string()), (6, "A/t2.flac".to_string())];
1622        let after = vec![
1623            (1, "B/u.flac".to_string()),
1624            (5, "A/t1.flac".to_string()),
1625            (6, "A/t2.flac".to_string()),
1626        ];
1627        assert_apply_matches_build(&before, &after, &[], &[1], &[], 0);
1628    }
1629
1630    #[test]
1631    fn apply_changes_moved_and_added_ids_colliding_on_fresh_key_rank_by_id() {
1632        // id 3 (moved) and id 7 (added) both land on the brand-new key "B/t.flac".
1633        // Neither collides with an EXISTING child, so no rebuild is triggered; the
1634        // insertions themselves must still rank by id (3 -> base, 7 -> " (2)"),
1635        // not by added-before-moved processing order.
1636        let before = vec![
1637            (3, "A/old.flac".to_string()),
1638            (5, "A/keep.flac".to_string()),
1639        ];
1640        let after = vec![
1641            (3, "B/t.flac".to_string()),
1642            (5, "A/keep.flac".to_string()),
1643            (7, "B/t.flac".to_string()),
1644        ];
1645        assert_apply_matches_build(&before, &after, &[3], &[7], &[], 0);
1646    }
1647
1648    #[test]
1649    fn apply_changes_same_dir_move_with_colliding_dir_rebuilds_root_once() {
1650        // Dir "D" (introduced by id 1) collides with file id 2 rendered "D", and
1651        // id 1 moves WITHIN D. The removal-side walk conservatively dirties ROOT
1652        // (it can't know the track lands back inside D), but exactly once — the
1653        // add-side equality (id == D's introducing id) must not double anything.
1654        let before = vec![
1655            (1, "D/x.flac".to_string()),
1656            (2, "D".to_string()),
1657            (3, "D/z.flac".to_string()),
1658        ];
1659        let after = vec![
1660            (1, "D/y.flac".to_string()),
1661            (2, "D".to_string()),
1662            (3, "D/z.flac".to_string()),
1663        ];
1664        assert_apply_matches_build(&before, &after, &[1], &[], &[], 1);
1665    }
1666
1667    #[test]
1668    fn apply_changes_added_min_id_under_colliding_dir_rebuilds_parent() {
1669        // File id 2 rendered "D" owns the base key; the dir for id 5's
1670        // "D/x.flac" was disambiguated to "D (2)". Adding id 1 under the dir
1671        // flips its introducing id below the file's — a fresh build now gives
1672        // the DIR the base name. The add-side min-flip walk must catch this.
1673        let before = vec![(2, "D".to_string()), (5, "D/x.flac".to_string())];
1674        let after = vec![
1675            (1, "D/y.flac".to_string()),
1676            (2, "D".to_string()),
1677            (5, "D/x.flac".to_string()),
1678        ];
1679        assert_apply_matches_build(&before, &after, &[], &[1], &[], 1);
1680    }
1681
1682    #[test]
1683    fn apply_changes_rebuilds_topmost_dirty_dir_only() {
1684        // Two collision-gated removals dirty ROOT and "A". Shallow-first
1685        // reduction must rebuild ROOT once and skip "A" as covered.
1686        let before = vec![
1687            (1, "t.flac".to_string()),
1688            (2, "t.flac".to_string()),
1689            (10, "A/u.flac".to_string()),
1690            (11, "A/u.flac".to_string()),
1691        ];
1692        let after = vec![(2, "t.flac".to_string()), (11, "A/u.flac".to_string())];
1693        assert_apply_matches_build(&before, &after, &[], &[], &[1, 10], 1);
1694    }
1695
1696    #[test]
1697    fn rebuild_subtree_recurses_multi_level_and_prunes_intermediate() {
1698        // A two-level subtree under the rebuilt dir: the DFS must reach `t.flac`
1699        // through the intermediate `Sub` dir, and re-inserting only the survivor
1700        // must prune the now-empty intermediate dir.
1701        let entries = vec![
1702            (1, "P/Sub/t.flac".to_string()),
1703            (2, "P/Sub/u.flac".to_string()),
1704            (3, "P/keep.flac".to_string()),
1705        ];
1706        let mut alloc = InodeAllocator::new(false);
1707        let mut t = VirtualTree::build_with(&entries, &mut alloc);
1708        let p = t.lookup(VirtualTree::ROOT, "P").unwrap();
1709        // Drop both tracks under Sub; rebuild P from the survivors only.
1710        t.remove_track(1, &mut alloc);
1711        t.remove_track(2, &mut alloc);
1712        let new_entries = vec![(3, "P/keep.flac".to_string())];
1713        let reference = VirtualTree::build(&new_entries);
1714        let np: std::collections::HashMap<i64, TrackRenderState> = new_entries
1715            .iter()
1716            .map(|&(id, ref p)| (id, trs(p)))
1717            .collect();
1718        t.rebuild_subtree(p, &np, &mut alloc).unwrap();
1719        let p2 = t.lookup(VirtualTree::ROOT, "P").unwrap();
1720        assert!(
1721            t.lookup(p2, "Sub").is_none(),
1722            "empty intermediate dir pruned"
1723        );
1724        assert!(t.lookup(p2, "keep.flac").is_some());
1725        assert_eq!(
1726            paths_of(&t).keys().collect::<Vec<_>>(),
1727            paths_of(&reference).keys().collect::<Vec<_>>()
1728        );
1729    }
1730
1731    #[test]
1732    fn equiv_distinguishes_structurally_different_trees() {
1733        // `build` is deterministic (fresh allocator from the same base), so two
1734        // builds of identical entries are equiv; different structure is not.
1735        let a = VirtualTree::build(&[(10, "A/x.flac".into())]);
1736        let same = VirtualTree::build(&[(10, "A/x.flac".into())]);
1737        let different = VirtualTree::build(&[(10, "B/x.flac".into())]);
1738        assert!(a.equiv(&same), "identical builds must be equiv");
1739        assert!(
1740            !a.equiv(&different),
1741            "different structure must not be equiv (guards equiv->true)"
1742        );
1743    }
1744
1745    #[test]
1746    fn path_of_returns_full_disambiguated_path() {
1747        let t = VirtualTree::build(&[(10, "A/B/x.flac".into())]);
1748        let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1749        let b = t.lookup(a, "B").unwrap();
1750        let x = t.lookup(b, "x.flac").unwrap();
1751        assert_eq!(t.path_of(x), "A/B/x.flac");
1752        assert_eq!(t.path_of(b), "A/B");
1753        assert_eq!(t.path_of(VirtualTree::ROOT), "");
1754    }
1755
1756    #[test]
1757    fn deepest_existing_ancestor_walks_existing_rendered_dirs() {
1758        let t = VirtualTree::build(&[(10, "A/B/x.flac".into())]);
1759        let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1760        let b = t.lookup(a, "B").unwrap();
1761        // Both A and B exist: the deepest existing dir for a file under A/B is B,
1762        // having consumed both dir components.
1763        assert_eq!(t.deepest_existing_ancestor("A/B/new.flac"), (b, 2));
1764        // A exists but Q does not: the walk stops at A after one component.
1765        assert_eq!(t.deepest_existing_ancestor("A/Q/new.flac"), (a, 1));
1766        // Nothing below root exists along this path.
1767        assert_eq!(
1768            t.deepest_existing_ancestor("Z/new.flac"),
1769            (VirtualTree::ROOT, 0)
1770        );
1771    }
1772
1773    #[test]
1774    fn ancestor_in_detects_ancestor_and_self() {
1775        let t = VirtualTree::build(&[(10, "A/B/x.flac".into())]);
1776        let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1777        let b = t.lookup(a, "B").unwrap();
1778        let mut set = std::collections::HashSet::new();
1779        set.insert(a);
1780        assert!(t.ancestor_in(b, &set), "A is an ancestor of B");
1781        assert!(
1782            t.ancestor_in(a, &set),
1783            "a node is its own ancestor for this check"
1784        );
1785        let empty = std::collections::HashSet::new();
1786        assert!(
1787            !t.ancestor_in(b, &empty),
1788            "no ancestor present (guards ancestor_in->false)"
1789        );
1790    }
1791
1792    #[test]
1793    fn over_long_leaf_truncates_to_255_keeping_extension() {
1794        let path = format!("{}.flac", "t".repeat(300));
1795        let t = VirtualTree::build(&[(10, path)]);
1796        let kids = t.children(VirtualTree::ROOT).unwrap();
1797        assert_eq!(kids.len(), 1);
1798        let name = kids.keys().next().unwrap();
1799        assert!(name.len() <= 255, "leaf is {} bytes", name.len());
1800        assert!(
1801            std::path::Path::new(name)
1802                .extension()
1803                .is_some_and(|ext| ext.eq_ignore_ascii_case("flac")),
1804            "extension preserved"
1805        );
1806    }
1807
1808    #[test]
1809    fn over_long_directory_component_truncates_to_255() {
1810        let path = format!("{}/Song.flac", "d".repeat(300));
1811        let t = VirtualTree::build(&[(10, path)]);
1812        let dir = t
1813            .children(VirtualTree::ROOT)
1814            .unwrap()
1815            .keys()
1816            .next()
1817            .unwrap()
1818            .clone();
1819        assert!(dir.len() <= 255, "dir is {} bytes", dir.len());
1820    }
1821
1822    #[test]
1823    fn over_long_component_truncates_on_utf8_boundary() {
1824        let path = format!("{}.flac", "€".repeat(100));
1825        let t = VirtualTree::build(&[(10, path)]);
1826        let name = t
1827            .children(VirtualTree::ROOT)
1828            .unwrap()
1829            .keys()
1830            .next()
1831            .unwrap()
1832            .clone();
1833        assert!(name.len() <= 255);
1834        assert!(
1835            std::path::Path::new(&name)
1836                .extension()
1837                .is_some_and(|ext| ext.eq_ignore_ascii_case("flac"))
1838        );
1839    }
1840
1841    #[test]
1842    fn colliding_over_long_leaves_stay_distinct_and_within_255() {
1843        let path = format!("{}.flac", "x".repeat(300));
1844        let entries: Vec<(i64, String)> = (0..12).map(|i| (i, path.clone())).collect();
1845        let t = VirtualTree::build(&entries);
1846        let kids = t.children(VirtualTree::ROOT).unwrap();
1847        assert_eq!(kids.len(), 12, "all collisions disambiguated distinctly");
1848        for name in kids.keys() {
1849            // Each name packs to the full NAME_MAX: the base leaf trims its stem to
1850            // leave room for `.flac`, and every ` (k)`-disambiguated sibling trims
1851            // per-rank to land on exactly 255 bytes. Pinning the exact length (not
1852            // just `<= 255`) guards suffix_candidate's budget arithmetic.
1853            assert_eq!(name.len(), 255, "{name:?}");
1854        }
1855    }
1856
1857    #[test]
1858    fn over_long_leaf_with_oversize_extension_truncates_whole_name() {
1859        // When the extension alone is NAME_MAX-1 bytes there is zero byte budget for
1860        // any stem, so the leaf falls back to a plain whole-name truncation rather
1861        // than emitting an empty-stem `.ext`. Guards truncate_component's
1862        // `budget > 0` filter.
1863        let path = format!("{}.{}", "s".repeat(300), "e".repeat(254));
1864        let t = VirtualTree::build(&[(10, path)]);
1865        let name = t
1866            .children(VirtualTree::ROOT)
1867            .unwrap()
1868            .keys()
1869            .next()
1870            .unwrap()
1871            .clone();
1872        assert!(name.len() <= 255, "{} bytes", name.len());
1873        assert!(
1874            !name.starts_with('.'),
1875            "no empty-stem leading dot: {name:?}"
1876        );
1877        assert!(
1878            name.starts_with('s'),
1879            "whole-name truncation keeps the stem prefix"
1880        );
1881    }
1882
1883    #[test]
1884    fn over_long_collisions_render_deterministically() {
1885        let path = format!("{}.flac", "y".repeat(300));
1886        let entries: Vec<(i64, String)> = (0..5).map(|i| (i, path.clone())).collect();
1887        let a = VirtualTree::build(&entries);
1888        let b = VirtualTree::build(&entries);
1889        let ak: Vec<_> = a
1890            .children(VirtualTree::ROOT)
1891            .unwrap()
1892            .keys()
1893            .cloned()
1894            .collect();
1895        let bk: Vec<_> = b
1896            .children(VirtualTree::ROOT)
1897            .unwrap()
1898            .keys()
1899            .cloned()
1900            .collect();
1901        assert_eq!(ak, bk);
1902    }
1903
1904    #[test]
1905    fn dot_and_dotdot_plain_components_are_dropped() {
1906        // A plain field rendering to exactly "." or ".." (e.g. an artist tagged ".")
1907        // must not create a directory that collides with readdir's hardcoded "."/"..".
1908        let t = VirtualTree::build(&[(10, "./Song.flac".into()), (20, "../Tune.flac".into())]);
1909        assert!(t.lookup(VirtualTree::ROOT, ".").is_none());
1910        assert!(t.lookup(VirtualTree::ROOT, "..").is_none());
1911        // The dropped level collapses; the leaf lands directly under root.
1912        assert!(t.lookup(VirtualTree::ROOT, "Song.flac").is_some());
1913        assert!(t.lookup(VirtualTree::ROOT, "Tune.flac").is_some());
1914    }
1915}