1use im::{HashMap as ImHashMap, OrdMap};
2use std::borrow::Cow;
3
4fn fold(name: &str) -> String {
8 name.to_lowercase()
9}
10
11fn path_components(path: &str) -> impl Iterator<Item = &str> {
18 path.split('/')
19 .filter(|c| !c.is_empty() && *c != "." && *c != "..")
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum RebuildError {
26 MissingRenderedPath(i64),
28 TestInjected,
30}
31
32#[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); InodeAllocator {
53 paths,
54 next: 2,
55 fold_keys: case_insensitive,
56 }
57 }
58
59 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 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 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 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, pub rendered_name: String, pub kind: NodeKind,
118}
119
120#[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 folded_children: ImHashMap<u64, ImHashMap<String, u64>>,
135 track_to_inode: ImHashMap<i64, u64>,
136 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 pub fn build_with(entries: &[(i64, String)], alloc: &mut InodeAllocator) -> VirtualTree {
152 VirtualTree::build_with_ci(entries, alloc, false)
153 }
154
155 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 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 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 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 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 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 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 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 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 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 fn collision_gate(&self, dir: u64, name: &str, rendered: &str) -> bool {
399 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 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 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 pub fn node_count(&self) -> usize {
441 self.nodes.len()
442 }
443
444 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 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 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 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 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 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 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 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 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 let mut moved_out: Vec<i64> = Vec::new(); let mut moved_in: Vec<i64> = Vec::new(); 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 => { }
694 Some(_) => {
695 moved_out.push(id);
696 moved_in.push(id);
697 }
698 None => {
699 moved_in.push(id);
703 }
704 }
705 }
706
707 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 if comps.get(consumed).is_some_and(|c| {
725 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 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 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 let mut live_dirty: Vec<u64> = dirty
763 .into_iter()
764 .filter(|d| self.node(*d).is_some())
765 .collect();
766 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 if self.node(d).is_none() {
774 continue;
775 }
776 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 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 for comp in &comps[..comps.len().saturating_sub(1)] {
797 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 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 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
867fn 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
876fn 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
885const NAME_MAX: usize = 255;
888
889fn 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
901fn 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 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
919fn 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
934fn 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 assert_eq!(tree.node_count(), 5);
992 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 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 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 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 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 let t2 = VirtualTree::build_with(&[(1, "B/x.flac".into())], &mut alloc);
1103 alloc.prune_retired(&t2); let t3 = VirtualTree::build_with(&[(1, "B/y.flac".into())], &mut alloc);
1105 alloc.prune_retired(&t3); 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); 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 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 assert_eq!(tree.lookup(VirtualTree::ROOT, "foo"), Some(foo));
1165 assert_eq!(tree.lookup(VirtualTree::ROOT, "FOO"), Some(foo));
1166 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 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 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 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 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 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 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 assert!(rebuilt.lookup(VirtualTree::ROOT, "foo").is_some());
1246 }
1247
1248 #[test]
1249 fn folded_inode_key_keeps_disambiguated_siblings_distinct() {
1250 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 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(); 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 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 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 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()), ];
1477 let mut alloc = InodeAllocator::new(false);
1478 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1479 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(t.deepest_existing_ancestor("A/B/new.flac"), (b, 2));
1873 assert_eq!(t.deepest_existing_ancestor("A/Q/new.flac"), (a, 1));
1875 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 assert_eq!(name.len(), 255, "{name:?}");
1963 }
1964 }
1965
1966 #[test]
1967 fn over_long_leaf_with_oversize_extension_truncates_whole_name() {
1968 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 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 assert!(t.lookup(VirtualTree::ROOT, "Song.flac").is_some());
2022 assert!(t.lookup(VirtualTree::ROOT, "Tune.flac").is_some());
2023 }
2024
2025 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 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); let large = rebuilds_for_one_retag(683); 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}