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