1use im::{HashMap as ImHashMap, OrdMap};
2use std::borrow::Cow;
3
4fn fold(name: &str) -> String {
8 name.to_lowercase()
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RebuildError {
15 MissingRenderedPath(i64),
17 TestInjected,
19}
20
21#[derive(Debug, Clone)]
31pub struct InodeAllocator {
32 paths: ImHashMap<String, u64>,
33 next: u64,
34 fold_keys: bool,
35}
36
37impl InodeAllocator {
38 pub fn new(case_insensitive: bool) -> InodeAllocator {
39 let mut paths = ImHashMap::new();
40 paths.insert(String::new(), VirtualTree::ROOT); InodeAllocator {
42 paths,
43 next: 2,
44 fold_keys: case_insensitive,
45 }
46 }
47
48 fn key<'a>(&self, path: &'a str) -> Cow<'a, str> {
52 if self.fold_keys {
53 Cow::Owned(fold(path))
54 } else {
55 Cow::Borrowed(path)
56 }
57 }
58
59 fn intern(&mut self, path: &str) -> u64 {
62 let key = self.key(path);
63 if let Some(&ino) = self.paths.get(key.as_ref()) {
64 return ino;
65 }
66 let ino = self.next;
67 self.next += 1;
68 self.paths.insert(key.into_owned(), ino);
69 ino
70 }
71
72 pub(crate) fn prune_retired(&mut self, tree: &VirtualTree) {
79 if self.paths.len() <= 2 * tree.nodes.len() {
80 return;
81 }
82 let mut live = ImHashMap::new();
83 for &ino in tree.nodes.keys() {
84 live.insert(self.key(&tree.path_of(ino)).into_owned(), ino);
85 }
86 self.paths = live;
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum NodeKind {
92 Dir,
93 File { track_id: i64 },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Node {
98 pub parent: u64,
99 pub name: String, pub rendered_name: String, pub kind: NodeKind,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct VirtualTree {
108 nodes: ImHashMap<u64, Node>,
109 children: ImHashMap<u64, OrdMap<String, u64>>,
110 rendered_children: ImHashMap<u64, OrdMap<String, OrdMap<String, u64>>>,
111 folded_children: ImHashMap<u64, ImHashMap<String, u64>>,
119 track_to_inode: ImHashMap<i64, u64>,
120 case_insensitive: bool,
123}
124
125impl VirtualTree {
126 pub const ROOT: u64 = 1;
127
128 pub fn build(entries: &[(i64, String)]) -> VirtualTree {
129 VirtualTree::build_with(entries, &mut InodeAllocator::new(false))
130 }
131
132 pub fn build_with(entries: &[(i64, String)], alloc: &mut InodeAllocator) -> VirtualTree {
136 VirtualTree::build_with_ci(entries, alloc, false)
137 }
138
139 pub fn build_with_ci(
142 entries: &[(i64, String)],
143 alloc: &mut InodeAllocator,
144 case_insensitive: bool,
145 ) -> VirtualTree {
146 let mut tree = VirtualTree {
147 nodes: ImHashMap::new(),
148 children: ImHashMap::new(),
149 rendered_children: ImHashMap::new(),
150 folded_children: ImHashMap::new(),
151 track_to_inode: ImHashMap::new(),
152 case_insensitive,
153 };
154 tree.nodes.insert(
155 Self::ROOT,
156 Node {
157 parent: Self::ROOT,
158 name: String::new(),
159 rendered_name: String::new(),
160 kind: NodeKind::Dir,
161 },
162 );
163 tree.children.insert(Self::ROOT, OrdMap::new());
164 tree.rendered_children.insert(Self::ROOT, OrdMap::new());
165 for (track_id, path) in entries {
166 tree.insert_file(*track_id, path, alloc);
167 }
168 tree
169 }
170
171 pub fn equiv(&self, other: &VirtualTree) -> bool {
175 self == other
176 }
177
178 pub fn node(&self, inode: u64) -> Option<&Node> {
179 self.nodes.get(&inode)
180 }
181
182 pub fn parent(&self, inode: u64) -> Option<u64> {
185 self.nodes.get(&inode).map(|n| n.parent)
186 }
187
188 pub fn children(&self, inode: u64) -> Option<&OrdMap<String, u64>> {
189 self.children.get(&inode)
190 }
191
192 pub fn lookup(&self, parent: u64, name: &str) -> Option<u64> {
193 if self.case_insensitive {
194 self.folded_children
195 .get(&parent)
196 .and_then(|b| b.get(&fold(name)).copied())
197 } else {
198 self.children
199 .get(&parent)
200 .and_then(|c| c.get(name).copied())
201 }
202 }
203
204 pub fn is_dir(&self, inode: u64) -> bool {
205 matches!(self.nodes.get(&inode).map(|n| &n.kind), Some(NodeKind::Dir))
206 }
207
208 pub fn track_id(&self, inode: u64) -> Option<i64> {
209 match self.nodes.get(&inode).map(|n| &n.kind) {
210 Some(NodeKind::File { track_id }) => Some(*track_id),
211 _ => None,
212 }
213 }
214
215 pub fn inode_of_track(&self, track_id: i64) -> Option<u64> {
217 self.track_to_inode.get(&track_id).copied()
218 }
219
220 fn insert_file(&mut self, track_id: i64, path: &str, alloc: &mut InodeAllocator) {
221 let comps: Vec<&str> = path
222 .split('/')
223 .filter(|c| !c.is_empty() && *c != "." && *c != "..")
224 .collect();
225 if comps.is_empty() {
226 return;
227 }
228 let mut dir = Self::ROOT;
229 let mut dir_path = String::new();
230 for comp in &comps[..comps.len() - 1] {
231 let (child, child_path) = self.ensure_dir(dir, &dir_path, comp, alloc);
232 dir = child;
233 dir_path = child_path;
234 }
235 let raw_name = comps[comps.len() - 1];
236 let truncated = truncate_component(raw_name, true);
237 let raw_name = truncated.as_ref();
238 let name = self.disambiguate(dir, raw_name);
239 let full = join_path(&dir_path, &name);
240 let inode = alloc.intern(&full);
241 self.track_to_inode.insert(track_id, inode);
242 self.nodes.insert(
243 inode,
244 Node {
245 parent: dir,
246 name: name.clone(),
247 rendered_name: raw_name.to_string(),
248 kind: NodeKind::File { track_id },
249 },
250 );
251 self.children
252 .get_mut(&dir)
253 .unwrap()
254 .insert(name.clone(), inode);
255 self.insert_rendered_child(dir, raw_name, &name, inode);
256 self.insert_folded_child(dir, &name, inode);
257 }
258
259 fn ensure_dir(
260 &mut self,
261 parent: u64,
262 parent_path: &str,
263 name: &str,
264 alloc: &mut InodeAllocator,
265 ) -> (u64, String) {
266 let truncated = truncate_component(name, false);
267 let name = truncated.as_ref();
268 if let Some(existing) = self.dir_child_named(parent, name) {
269 let stored = self
270 .node(existing)
271 .expect("dir_child_named node")
272 .name
273 .clone();
274 return (existing, join_path(parent_path, &stored));
275 }
276 let unique = self.disambiguate(parent, name);
277 let full = join_path(parent_path, &unique);
278 let inode = alloc.intern(&full);
279 self.nodes.insert(
280 inode,
281 Node {
282 parent,
283 name: unique.clone(),
284 rendered_name: name.to_string(),
285 kind: NodeKind::Dir,
286 },
287 );
288 self.children.insert(inode, OrdMap::new());
289 self.rendered_children.insert(inode, OrdMap::new());
290 self.children
291 .get_mut(&parent)
292 .unwrap()
293 .insert(unique.clone(), inode);
294 self.insert_rendered_child(parent, name, &unique, inode);
295 self.insert_folded_child(parent, &unique, inode);
296 (inode, full)
297 }
298
299 fn disambiguate(&self, dir: u64, name: &str) -> String {
302 if !self.taken(dir, name) {
303 return name.to_string();
304 }
305 for k in 2u32.. {
306 let candidate = suffix_candidate(name, k);
307 if !self.taken(dir, &candidate) {
308 return candidate;
309 }
310 }
311 unreachable!("an unoccupied candidate rank always exists")
312 }
313
314 fn insert_folded_child(&mut self, parent: u64, name: &str, inode: u64) {
316 if !self.case_insensitive {
317 return;
318 }
319 self.folded_children
320 .entry(parent)
321 .or_default()
322 .insert(fold(name), inode);
323 }
324
325 fn remove_folded_child(&mut self, parent: u64, name: &str) {
328 if !self.case_insensitive {
329 return;
330 }
331 if let Some(bucket) = self.folded_children.get_mut(&parent) {
332 bucket.remove(&fold(name));
333 if bucket.is_empty() {
334 self.folded_children.remove(&parent);
335 }
336 }
337 }
338
339 fn taken(&self, dir: u64, name: &str) -> bool {
341 if self.case_insensitive {
342 self.folded_children
343 .get(&dir)
344 .is_some_and(|b| b.contains_key(&fold(name)))
345 } else {
346 self.children
347 .get(&dir)
348 .is_some_and(|c| c.contains_key(name))
349 }
350 }
351
352 fn dir_child_named(&self, dir: u64, name: &str) -> Option<u64> {
355 let ino = if self.case_insensitive {
356 self.folded_children
357 .get(&dir)
358 .and_then(|b| b.get(&fold(name)).copied())
359 } else {
360 self.children.get(&dir).and_then(|c| c.get(name).copied())
361 }?;
362 self.is_dir(ino).then_some(ino)
363 }
364
365 fn collision_gate(&self, dir: u64, name: &str, rendered: &str) -> bool {
373 if name != rendered || is_suffix_shaped(name) {
376 return true;
377 }
378 let Some(kids) = self.children.get(&dir) else {
379 return false;
380 };
381 for k in 2u32.. {
385 let candidate = suffix_candidate(rendered, k);
386 match kids.get(&candidate) {
387 None => return false,
388 Some(c) => {
389 if self
390 .nodes
391 .get(c)
392 .is_some_and(|n| n.rendered_name == rendered)
393 {
394 return true;
395 }
396 }
397 }
398 }
399 unreachable!("probe terminates at the first free candidate rank")
400 }
401
402 pub fn track_ids(&self) -> std::collections::HashSet<i64> {
404 self.nodes
405 .values()
406 .filter_map(|n| match &n.kind {
407 NodeKind::File { track_id } => Some(*track_id),
408 NodeKind::Dir => None,
409 })
410 .collect()
411 }
412
413 fn children_by_rendered_with_examined(&self, dir: u64, rendered: &str) -> (Vec<u64>, usize) {
415 match self
416 .rendered_children
417 .get(&dir)
418 .and_then(|kids| kids.get(rendered))
419 {
420 None => (Vec::new(), 0),
421 Some(same_rendered) => {
422 let children: Vec<u64> = same_rendered.values().copied().collect();
423 let examined = same_rendered.len();
424 (children, examined)
425 }
426 }
427 }
428
429 pub fn children_by_rendered(&self, dir: u64, rendered: &str) -> Vec<u64> {
431 self.children_by_rendered_with_examined(dir, rendered).0
432 }
433
434 #[cfg(test)]
435 fn children_by_rendered_examined_for_test(&self, dir: u64, rendered: &str) -> usize {
436 self.children_by_rendered_with_examined(dir, rendered).1
437 }
438
439 fn insert_rendered_child(&mut self, parent: u64, rendered: &str, name: &str, inode: u64) {
440 self.rendered_children
441 .entry(parent)
442 .or_default()
443 .entry(rendered.to_string())
444 .or_default()
445 .insert(name.to_string(), inode);
446 }
447
448 fn remove_rendered_child(&mut self, parent: u64, rendered: &str, name: &str) {
449 let Some(by_rendered) = self.rendered_children.get_mut(&parent) else {
450 return;
451 };
452 let remove_bucket = match by_rendered.get_mut(rendered) {
453 Some(same_rendered) => {
454 same_rendered.remove(name);
455 same_rendered.is_empty()
456 }
457 None => false,
458 };
459 if remove_bucket {
460 by_rendered.remove(rendered);
461 }
462 }
463
464 pub fn introducing_id(&self, ino: u64) -> i64 {
467 if let Some(NodeKind::File { track_id }) = self.nodes.get(&ino).map(|n| &n.kind) {
468 return *track_id;
469 }
470 let mut min = i64::MAX;
471 let mut stack = vec![ino];
472 while let Some(n) = stack.pop() {
473 match self.nodes.get(&n).map(|x| &x.kind) {
474 Some(NodeKind::File { track_id }) => min = min.min(*track_id),
475 _ => {
476 if let Some(kids) = self.children.get(&n) {
477 for &c in kids.values() {
478 stack.push(c);
479 }
480 }
481 }
482 }
483 }
484 min
485 }
486
487 fn path_of(&self, inode: u64) -> String {
489 if inode == Self::ROOT {
490 return String::new();
491 }
492 let mut parts = Vec::new();
493 let mut cur = inode;
494 while cur != Self::ROOT {
495 let Some(n) = self.nodes.get(&cur) else {
496 break;
497 };
498 parts.push(n.name.clone());
499 cur = n.parent;
500 }
501 parts.reverse();
502 parts.join("/")
503 }
504
505 pub fn remove_track(
510 &mut self,
511 track_id: i64,
512 _alloc: &mut InodeAllocator,
513 ) -> Option<(u64, Option<(String, String)>)> {
514 let ino = self.track_to_inode.remove(&track_id)?;
515 let parent = self.nodes.get(&ino)?.parent;
516 let names = self
517 .nodes
518 .get(&ino)
519 .map(|n| (n.name.clone(), n.rendered_name.clone()));
520 self.nodes.remove(&ino);
521 if let Some((name, rendered)) = names {
522 if let Some(kids) = self.children.get_mut(&parent) {
523 kids.remove(&name);
524 }
525 self.remove_rendered_child(parent, &rendered, &name);
526 self.remove_folded_child(parent, &name);
527 }
528 Some(self.prune_empty_dirs_upward(parent))
529 }
530
531 pub(crate) fn rebuild_subtree(
538 &mut self,
539 dir: u64,
540 new_paths: &std::collections::HashMap<i64, crate::refresh_diff::TrackRenderState>,
541 alloc: &mut InodeAllocator,
542 ) -> std::result::Result<(), RebuildError> {
543 let mut ids = Vec::new();
544 let mut stack = vec![dir];
545 while let Some(n) = stack.pop() {
546 match self.nodes.get(&n).map(|x| x.kind.clone()) {
547 Some(NodeKind::File { track_id }) => ids.push(track_id),
548 _ => {
549 if let Some(kids) = self.children.get(&n) {
550 for &c in kids.values() {
551 stack.push(c);
552 }
553 }
554 }
555 }
556 }
557 for id in &ids {
558 self.remove_track(*id, alloc);
559 }
560 ids.sort_unstable();
561 for id in ids {
562 let path = new_paths
563 .get(&id)
564 .map(|s| s.path.as_str())
565 .ok_or(RebuildError::MissingRenderedPath(id))?;
566 self.insert_file(id, path, alloc);
567 }
568 Ok(())
569 }
570
571 fn dirty_removed_ancestors(
581 &self,
582 leaf: u64,
583 id: i64,
584 dirty: &mut std::collections::HashSet<u64>,
585 ) {
586 let mut child = leaf;
587 while child != Self::ROOT {
588 let Some((parent, name, rendered)) = self
589 .node(child)
590 .map(|n| (n.parent, n.name.clone(), n.rendered_name.clone()))
591 else {
592 break;
593 };
594 if self.collision_gate(parent, &name, &rendered) {
595 if self.introducing_id(child) == id {
596 dirty.insert(parent);
597 } else {
598 break;
599 }
600 }
601 child = parent;
602 }
603 }
604
605 fn dirty_min_flip_ancestors(
609 &self,
610 d: u64,
611 id: i64,
612 dirty: &mut std::collections::HashSet<u64>,
613 ) {
614 let mut child = d;
615 while child != Self::ROOT {
616 let Some((parent, name, rendered)) = self
617 .node(child)
618 .map(|n| (n.parent, n.name.clone(), n.rendered_name.clone()))
619 else {
620 break;
621 };
622 if self.collision_gate(parent, &name, &rendered) {
623 if id < self.introducing_id(child) {
624 dirty.insert(parent);
625 } else {
626 break;
627 }
628 }
629 child = parent;
630 }
631 }
632
633 pub(crate) fn apply_changes(
643 &mut self,
644 new_paths: &std::collections::HashMap<i64, crate::refresh_diff::TrackRenderState>,
645 changed: &[i64],
646 added: &[i64],
647 removed: &[i64],
648 alloc: &mut InodeAllocator,
649 ) -> std::result::Result<usize, RebuildError> {
650 use std::collections::HashSet;
651 let mut dirty: HashSet<u64> = HashSet::new();
652
653 let mut moved_out: Vec<i64> = Vec::new(); let mut moved_in: Vec<i64> = Vec::new(); for &id in changed {
657 let new_path = new_paths
658 .get(&id)
659 .map(|s| s.path.as_str())
660 .ok_or(RebuildError::MissingRenderedPath(id))?;
661 match self.inode_of_track(id) {
662 Some(ino) if self.path_of(ino) == new_path => { }
663 Some(_) => {
664 moved_out.push(id);
665 moved_in.push(id);
666 }
667 None => {
668 moved_in.push(id);
672 }
673 }
674 }
675
676 for &id in removed.iter().chain(moved_out.iter()) {
678 let Some(leaf) = self.inode_of_track(id) else {
679 continue;
680 };
681 self.dirty_removed_ancestors(leaf, id, &mut dirty);
682 }
683 for &id in added.iter().chain(moved_in.iter()) {
684 let rendered = new_paths
685 .get(&id)
686 .map(|s| s.path.as_str())
687 .ok_or(RebuildError::MissingRenderedPath(id))?;
688 let comps: Vec<&str> = rendered.split('/').filter(|c| !c.is_empty()).collect();
689 let (d, consumed) = self.deepest_existing_ancestor(rendered);
690 if comps.get(consumed).is_some_and(|c| {
694 self.children
695 .get(&d)
696 .is_some_and(|kids| kids.contains_key(*c))
697 }) {
698 dirty.insert(d);
699 }
700 self.dirty_min_flip_ancestors(d, id, &mut dirty);
701 }
702
703 for &id in removed.iter().chain(moved_out.iter()) {
706 if let Some((surv, Some((name, rendered)))) = self.remove_track(id, alloc)
707 && self.collision_gate(surv, &name, &rendered)
708 {
709 dirty.insert(surv);
710 }
711 }
712 let mut to_insert: Vec<i64> = added.iter().chain(moved_in.iter()).copied().collect();
716 to_insert.sort_unstable();
717 for id in to_insert {
718 let rendered = new_paths
719 .get(&id)
720 .map(|s| s.path.as_str())
721 .ok_or(RebuildError::MissingRenderedPath(id))?;
722 self.insert_file(id, rendered, alloc);
723 }
724
725 let mut live_dirty: Vec<u64> = dirty
727 .into_iter()
728 .filter(|d| self.node(*d).is_some())
729 .collect();
730 live_dirty.sort_by_key(|d| {
733 self.path_of(*d)
734 .split('/')
735 .filter(|c| !c.is_empty())
736 .count()
737 });
738 let mut done: HashSet<u64> = HashSet::new();
739 let mut rebuilds = 0usize;
740 for d in live_dirty {
741 if self.node(d).is_none() {
743 continue;
744 }
745 if self.ancestor_in(d, &done) {
747 continue;
748 }
749 self.rebuild_subtree(d, new_paths, alloc)?;
750 rebuilds += 1;
751 done.insert(d);
752 }
753 Ok(rebuilds)
754 }
755
756 fn deepest_existing_ancestor(&self, rendered: &str) -> (u64, usize) {
761 let comps: Vec<&str> = rendered.split('/').filter(|c| !c.is_empty()).collect();
762 let mut dir = Self::ROOT;
763 let mut consumed = 0;
764 for comp in &comps[..comps.len().saturating_sub(1)] {
766 let next = self
767 .children_by_rendered(dir, comp)
768 .into_iter()
769 .find(|&c| self.is_dir(c));
770 match next {
771 Some(c) => {
772 dir = c;
773 consumed += 1;
774 }
775 None => break,
776 }
777 }
778 (dir, consumed)
779 }
780
781 fn ancestor_in(&self, node: u64, set: &std::collections::HashSet<u64>) -> bool {
783 let mut cur = node;
784 loop {
785 if set.contains(&cur) {
786 return true;
787 }
788 if cur == Self::ROOT {
789 return false;
790 }
791 cur = match self.node(cur) {
792 Some(n) => n.parent,
793 None => return false,
794 };
795 }
796 }
797
798 fn prune_empty_dirs_upward(&mut self, mut dir: u64) -> (u64, Option<(String, String)>) {
802 let mut last_pruned = None;
803 while dir != Self::ROOT && self.children.get(&dir).is_none_or(OrdMap::is_empty) {
804 let Some(node) = self.nodes.get(&dir) else {
805 break;
806 };
807 let parent = node.parent;
808 let names = self
809 .nodes
810 .get(&dir)
811 .map(|n| (n.name.clone(), n.rendered_name.clone()));
812 self.children.remove(&dir);
813 self.rendered_children.remove(&dir);
814 self.folded_children.remove(&dir);
815 self.nodes.remove(&dir);
816 if let Some((name, rendered)) = &names {
817 if let Some(kids) = self.children.get_mut(&parent) {
818 kids.remove(name);
819 }
820 self.remove_rendered_child(parent, rendered, name);
821 self.remove_folded_child(parent, name);
822 }
823 last_pruned = names;
824 dir = parent;
825 }
826 (dir, last_pruned)
827 }
828}
829
830fn join_path(parent: &str, name: &str) -> String {
832 if parent.is_empty() {
833 name.to_string()
834 } else {
835 format!("{parent}/{name}")
836 }
837}
838
839fn split_suffix_parts(name: &str) -> (&str, Option<&str>) {
842 match name.rfind('.') {
843 Some(i) if i > 0 => (&name[..i], Some(&name[i + 1..])),
844 _ => (name, None),
845 }
846}
847
848const NAME_MAX: usize = 255;
851
852fn truncate_bytes(s: &str, max: usize) -> &str {
854 if s.len() <= max {
855 return s;
856 }
857 let mut end = max;
858 while end > 0 && !s.is_char_boundary(end) {
859 end -= 1;
860 }
861 &s[..end]
862}
863
864fn truncate_component(name: &str, preserve_ext: bool) -> Cow<'_, str> {
869 if name.len() <= NAME_MAX {
870 return Cow::Borrowed(name);
871 }
872 if preserve_ext && let (stem, Some(ext)) = split_suffix_parts(name) {
873 if let Some(budget) = NAME_MAX.checked_sub(ext.len() + 1).filter(|b| *b > 0) {
875 let stem_t = truncate_bytes(stem, budget);
876 return Cow::Owned(format!("{stem_t}.{ext}"));
877 }
878 }
879 Cow::Owned(truncate_bytes(name, NAME_MAX).to_string())
880}
881
882fn suffix_candidate(name: &str, k: u32) -> String {
886 let (stem, ext) = split_suffix_parts(name);
887 let suffix = format!(" ({k})");
888 let ext_part = match ext {
889 Some(e) => format!(".{e}"),
890 None => String::new(),
891 };
892 let budget = NAME_MAX.saturating_sub(suffix.len() + ext_part.len());
893 let stem_t = truncate_bytes(stem, budget);
894 format!("{stem_t}{suffix}{ext_part}")
895}
896
897fn is_suffix_shaped(name: &str) -> bool {
901 let (stem, _) = split_suffix_parts(name);
902 let Some(open) = stem.rfind(" (") else {
903 return false;
904 };
905 let Some(inner) = stem[open + 2..].strip_suffix(')') else {
906 return false;
907 };
908 inner.parse::<u32>().is_ok_and(|k| k >= 2)
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914 use crate::refresh_diff::TrackRenderState;
915 use musefs_db::Format;
916
917 fn trs(path: &str) -> TrackRenderState {
918 TrackRenderState {
919 content_version: 0,
920 format: Format::Flac,
921 path: path.into(),
922 }
923 }
924
925 #[test]
926 fn build_with_keeps_inodes_stable_across_rebuilds() {
927 let mut alloc = InodeAllocator::new(false);
928 let t1 = VirtualTree::build_with(&[(10, "Alice/Song.flac".into())], &mut alloc);
929 let alice1 = t1.lookup(VirtualTree::ROOT, "Alice").unwrap();
930 let song1 = t1.lookup(alice1, "Song.flac").unwrap();
931 let t2 = VirtualTree::build_with(
932 &[
933 (10, "Alice/Song.flac".into()),
934 (20, "Bob/Other.flac".into()),
935 ],
936 &mut alloc,
937 );
938 let alice2 = t2.lookup(VirtualTree::ROOT, "Alice").unwrap();
939 let song2 = t2.lookup(alice2, "Song.flac").unwrap();
940 assert_eq!(alice1, alice2);
941 assert_eq!(song1, song2);
942 let bob2 = t2.lookup(VirtualTree::ROOT, "Bob").unwrap();
943 assert!(bob2 != alice2 && bob2 != song2);
944 }
945
946 #[test]
947 fn inode_of_track_maps_file_nodes() {
948 let t = VirtualTree::build(&[(10, "Alice/Song.flac".into()), (20, "Bob/Tune.flac".into())]);
949 let alice = t.lookup(VirtualTree::ROOT, "Alice").unwrap();
950 let song = t.lookup(alice, "Song.flac").unwrap();
951 assert_eq!(t.inode_of_track(10), Some(song));
952 assert!(t.inode_of_track(20).is_some());
953 assert_eq!(t.inode_of_track(999), None);
954 }
955
956 #[test]
957 fn build_with_does_not_recycle_a_vanished_inode() {
958 let mut alloc = InodeAllocator::new(false);
959 let t1 = VirtualTree::build_with(&[(10, "Gone/X.flac".into())], &mut alloc);
960 let gone = t1.lookup(VirtualTree::ROOT, "Gone").unwrap();
961 let x = t1.lookup(gone, "X.flac").unwrap();
962 let t2 = VirtualTree::build_with(&[(20, "New/Y.flac".into())], &mut alloc);
963 let new = t2.lookup(VirtualTree::ROOT, "New").unwrap();
964 let y = t2.lookup(new, "Y.flac").unwrap();
965 assert!(new != gone && new != x && y != gone && y != x);
966 }
967
968 #[test]
969 fn prune_retired_bounds_map_under_churn() {
970 let mut alloc = InodeAllocator::new(false);
971 for generation in 0..100 {
972 let entries = vec![(1, format!("Gen{generation}/a.flac"))];
973 let tree = VirtualTree::build_with(&entries, &mut alloc);
974 alloc.prune_retired(&tree);
975 assert!(
976 alloc.paths.len() <= 2 * tree.nodes.len(),
977 "gen {generation}: map {} exceeds 2x live {}",
978 alloc.paths.len(),
979 tree.nodes.len()
980 );
981 }
982 }
983
984 #[test]
985 fn prune_retired_keeps_live_inodes_stable() {
986 let mut alloc = InodeAllocator::new(false);
987 let tree = VirtualTree::build_with(&[(1, "Keep/song.flac".into())], &mut alloc);
988 let keep_dir = tree.lookup(VirtualTree::ROOT, "Keep").unwrap();
989 let keep_file = tree.lookup(keep_dir, "song.flac").unwrap();
990 let mut last = tree;
991 for generation in 0..10 {
992 let entries = vec![
993 (1, "Keep/song.flac".to_string()),
994 (2, format!("Gen{generation}/x.flac")),
995 ];
996 last = VirtualTree::build_with(&entries, &mut alloc);
997 alloc.prune_retired(&last);
998 }
999 let d = last.lookup(VirtualTree::ROOT, "Keep").unwrap();
1000 let f = last.lookup(d, "song.flac").unwrap();
1001 assert_eq!((d, f), (keep_dir, keep_file), "live paths must keep inodes");
1002 }
1003
1004 #[test]
1005 fn pruned_path_reborn_gets_fresh_inode_never_recycled() {
1006 let mut alloc = InodeAllocator::new(false);
1007 let t1 = VirtualTree::build_with(&[(1, "Gone/x.flac".into())], &mut alloc);
1008 let gone_dir = t1.lookup(VirtualTree::ROOT, "Gone").unwrap();
1009 let gone_file = t1.lookup(gone_dir, "x.flac").unwrap();
1010 for generation in 0..10 {
1012 let t = VirtualTree::build_with(&[(1, format!("Gen{generation}/x.flac"))], &mut alloc);
1013 alloc.prune_retired(&t);
1014 }
1015 assert!(
1016 !alloc.paths.contains_key("Gone"),
1017 "retired path must be pruned"
1018 );
1019 let t2 = VirtualTree::build_with(&[(1, "Gone/x.flac".into())], &mut alloc);
1021 let d2 = t2.lookup(VirtualTree::ROOT, "Gone").unwrap();
1022 let f2 = t2.lookup(d2, "x.flac").unwrap();
1023 assert!(
1024 d2 > gone_file && f2 > gone_file,
1025 "fresh inodes, never recycled"
1026 );
1027 assert_ne!(d2, gone_dir);
1028 assert_ne!(f2, gone_file);
1029 }
1030
1031 #[test]
1032 fn prune_retired_waits_for_threshold() {
1033 let mut alloc = InodeAllocator::new(false);
1036 let t1 = VirtualTree::build_with(&[(1, "A/x.flac".into())], &mut alloc);
1037 let a_dir = t1.lookup(VirtualTree::ROOT, "A").unwrap();
1038 let t2 = VirtualTree::build_with(&[(1, "B/x.flac".into())], &mut alloc);
1040 alloc.prune_retired(&t2); let t3 = VirtualTree::build_with(&[(1, "B/y.flac".into())], &mut alloc);
1042 alloc.prune_retired(&t3); assert_eq!(
1044 alloc.paths.get("A"),
1045 Some(&a_dir),
1046 "at exactly 2x live the retired entries must survive"
1047 );
1048 let t4 = VirtualTree::build_with(&[(1, "C/x.flac".into())], &mut alloc);
1049 alloc.prune_retired(&t4); assert!(
1051 !alloc.paths.contains_key("A"),
1052 "past 2x live the prune must fire"
1053 );
1054 assert_eq!(
1055 alloc.paths.len(),
1056 t4.nodes.len(),
1057 "pruned map is exactly the live set"
1058 );
1059 }
1060
1061 #[test]
1062 fn disambiguate_keeps_dotfile_whole_and_splits_normal_ext() {
1063 let t = VirtualTree::build(&[
1064 (10, "D/.hidden".into()),
1065 (20, "D/.hidden".into()),
1066 (30, "D/a.ext".into()),
1067 (40, "D/a.ext".into()),
1068 ]);
1069 let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1070 assert!(t.lookup(d, ".hidden").is_some());
1071 assert!(t.lookup(d, ".hidden (2)").is_some());
1072 assert!(
1073 t.lookup(d, " (2).hidden").is_none(),
1074 "must not split at the index-0 dot"
1075 );
1076 assert!(t.lookup(d, "a.ext").is_some());
1077 assert!(t.lookup(d, "a (2).ext").is_some());
1078 }
1079
1080 #[test]
1081 fn disambiguate_resolves_three_way_collision() {
1082 let t = VirtualTree::build(&[
1083 (10, "D/song.flac".into()),
1084 (20, "D/song.flac".into()),
1085 (30, "D/song.flac".into()),
1086 ]);
1087 let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1088 assert!(t.lookup(d, "song.flac").is_some());
1089 assert!(t.lookup(d, "song (2).flac").is_some());
1090 assert!(t.lookup(d, "song (3).flac").is_some());
1091 }
1092
1093 #[test]
1094 fn case_insensitive_merges_directories() {
1095 let entries = vec![(1i64, "Foo/A".to_string()), (2i64, "foo/B".to_string())];
1098 let tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(true), true);
1099 let foo = tree.lookup(VirtualTree::ROOT, "Foo").expect("Foo dir");
1100 assert_eq!(tree.lookup(VirtualTree::ROOT, "foo"), Some(foo));
1102 assert_eq!(tree.lookup(VirtualTree::ROOT, "FOO"), Some(foo));
1103 assert_eq!(tree.children(VirtualTree::ROOT).unwrap().len(), 1);
1105 assert!(tree.lookup(foo, "A").is_some());
1106 assert!(tree.lookup(foo, "B").is_some());
1107 assert_eq!(tree.children(foo).unwrap().len(), 2);
1108 }
1109
1110 #[test]
1111 fn case_insensitive_disambiguates_leaf_files() {
1112 let entries = vec![
1115 (1i64, "Dir/Song".to_string()),
1116 (2i64, "Dir/song".to_string()),
1117 ];
1118 let tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(true), true);
1119 let dir = tree.lookup(VirtualTree::ROOT, "Dir").expect("Dir");
1120 let names: Vec<String> = tree.children(dir).unwrap().keys().cloned().collect();
1121 assert!(names.contains(&"Song".to_string()));
1123 assert!(names.contains(&"song (2)".to_string()));
1124 assert_eq!(names.len(), 2);
1125 }
1126
1127 #[test]
1128 fn case_sensitive_keeps_both_dirs_separate() {
1129 let entries = vec![(1i64, "Foo/A".to_string()), (2i64, "foo/B".to_string())];
1132 let tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(false), false);
1133 assert_eq!(tree.children(VirtualTree::ROOT).unwrap().len(), 2);
1134 assert_ne!(
1135 tree.lookup(VirtualTree::ROOT, "Foo"),
1136 tree.lookup(VirtualTree::ROOT, "foo")
1137 );
1138 assert_eq!(tree.lookup(VirtualTree::ROOT, "FOO"), None);
1139 }
1140
1141 #[test]
1142 fn case_insensitive_removal_keeps_folded_lookup_consistent() {
1143 let entries = vec![
1146 (1i64, "Dir/Song".to_string()),
1147 (2i64, "Dir/Other".to_string()),
1148 ];
1149 let mut tree = VirtualTree::build_with_ci(&entries, &mut InodeAllocator::new(true), true);
1150 let dir = tree.lookup(VirtualTree::ROOT, "dir").expect("dir");
1151 assert!(tree.lookup(dir, "song").is_some());
1152
1153 tree.remove_track(1, &mut InodeAllocator::new(false));
1154
1155 assert_eq!(tree.lookup(dir, "song"), None);
1157 assert_eq!(tree.lookup(dir, "Song"), None);
1158 assert!(tree.lookup(dir, "other").is_some());
1159 }
1160
1161 #[test]
1162 fn folded_dir_recase_keeps_survivor_inode() {
1163 let mut alloc = InodeAllocator::new(true);
1168 let entries = vec![(1i64, "Foo/A".to_string()), (2i64, "foo/B".to_string())];
1169 let tree = VirtualTree::build_with_ci(&entries, &mut alloc, true);
1170 let before = tree.inode_of_track(2).expect("track 2 inode");
1171
1172 let rebuilt = VirtualTree::build_with_ci(&[(2i64, "foo/B".to_string())], &mut alloc, true);
1173 let after = rebuilt
1174 .inode_of_track(2)
1175 .expect("track 2 inode after rebuild");
1176
1177 assert_eq!(
1178 before, after,
1179 "survivor inode must survive a folded dir re-case"
1180 );
1181 assert!(rebuilt.lookup(VirtualTree::ROOT, "foo").is_some());
1183 }
1184
1185 #[test]
1186 fn folded_inode_key_keeps_disambiguated_siblings_distinct() {
1187 let mut alloc = InodeAllocator::new(true);
1191 let entries = vec![
1192 (1i64, "Dir/Song".to_string()),
1193 (2i64, "Dir/song".to_string()),
1194 ];
1195 let tree = VirtualTree::build_with_ci(&entries, &mut alloc, true);
1196 let a = tree.inode_of_track(1).expect("track 1 inode");
1197 let b = tree.inode_of_track(2).expect("track 2 inode");
1198 assert_ne!(
1199 a, b,
1200 "disambiguated folded siblings must not collapse to one inode"
1201 );
1202
1203 let dir = tree.lookup(VirtualTree::ROOT, "Dir").expect("Dir");
1204 assert_eq!(tree.lookup(dir, "Song"), Some(a));
1205 assert_eq!(tree.lookup(dir, "song (2)"), Some(b));
1206 }
1207
1208 #[test]
1209 fn child_by_rendered_finds_disambiguated_node() {
1210 let t = VirtualTree::build(&[(10, "D/song.flac".into()), (20, "D/song.flac".into())]);
1211 let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1212 let base = t.lookup(d, "song.flac").unwrap();
1213 let suffixed = t.lookup(d, "song (2).flac").unwrap();
1214
1215 assert_eq!(t.children_by_rendered(d, "song.flac"), vec![suffixed, base]);
1216 assert_eq!(t.children_by_rendered_examined_for_test(d, "song.flac"), 2);
1217 }
1218
1219 #[test]
1220 fn deepest_existing_ancestor_preserves_rendered_dir_vs_file_order() {
1221 let t = VirtualTree::build(&[(1, "X".into()), (2, "X/a.flac".into())]);
1222 let file = t.lookup(VirtualTree::ROOT, "X").unwrap();
1223 let dir = t.lookup(VirtualTree::ROOT, "X (2)").unwrap();
1224
1225 assert_eq!(
1226 t.children_by_rendered(VirtualTree::ROOT, "X"),
1227 vec![file, dir]
1228 );
1229 assert_eq!(
1230 t.deepest_existing_ancestor("X/new.flac"),
1231 (dir, 1),
1232 "same-rendered file must not hide the matching directory"
1233 );
1234 }
1235
1236 #[test]
1237 fn children_by_rendered_updates_when_collision_member_removed() {
1238 let mut alloc = InodeAllocator::new(false);
1239 let mut t = VirtualTree::build_with(
1240 &[(10, "D/song.flac".into()), (20, "D/song.flac".into())],
1241 &mut alloc,
1242 );
1243 let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1244 let survivor = t.lookup(d, "song (2).flac").unwrap();
1245
1246 t.remove_track(10, &mut alloc);
1247
1248 assert_eq!(t.children_by_rendered(d, "song.flac"), vec![survivor]);
1249 assert_eq!(t.children_by_rendered_examined_for_test(d, "song.flac"), 1);
1250 }
1251
1252 #[test]
1253 fn children_by_rendered_updates_when_empty_dir_pruned() {
1254 let mut alloc = InodeAllocator::new(false);
1255 let mut t = VirtualTree::build_with(
1256 &[(10, "A/B/x.flac".into()), (20, "A/C/y.flac".into())],
1257 &mut alloc,
1258 );
1259 let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1260 assert_eq!(t.children_by_rendered_examined_for_test(a, "B"), 1);
1261
1262 t.remove_track(10, &mut alloc);
1263
1264 assert!(t.children_by_rendered(a, "B").is_empty());
1265 assert_eq!(t.children_by_rendered_examined_for_test(a, "B"), 0);
1266 assert_eq!(t.children_by_rendered_examined_for_test(a, "C"), 1);
1267 }
1268
1269 #[test]
1270 fn deepest_existing_ancestor_rendered_miss_does_not_scan_root_fanout() {
1271 let entries: Vec<(i64, String)> = (0..1024)
1272 .map(|i| {
1273 (
1274 i64::from(i),
1275 format!("Artist {i:04}/Album {i:04}/Track.flac"),
1276 )
1277 })
1278 .collect();
1279 let t = VirtualTree::build(&entries);
1280
1281 assert_eq!(
1282 t.deepest_existing_ancestor("Unknown/Unknown/new.flac"),
1283 (VirtualTree::ROOT, 0)
1284 );
1285 assert_eq!(
1286 t.children_by_rendered_examined_for_test(VirtualTree::ROOT, "Unknown"),
1287 0,
1288 "rendered-name miss should examine no same-rendered candidates"
1289 );
1290 }
1291
1292 #[test]
1293 fn introducing_id_is_min_descendant_track_id() {
1294 let mut alloc = InodeAllocator::new(false);
1295 let t = VirtualTree::build_with(
1296 &[(30, "A/B/x.flac".into()), (10, "A/C/y.flac".into())],
1297 &mut alloc,
1298 );
1299 let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1300 assert_eq!(t.introducing_id(a), 10);
1301 }
1302
1303 #[test]
1304 fn remove_track_prunes_empty_ancestors_b() {
1305 let mut alloc = InodeAllocator::new(false);
1306 let mut t = VirtualTree::build_with(
1307 &[(10, "A/B/x.flac".into()), (20, "C/y.flac".into())],
1308 &mut alloc,
1309 );
1310 t.remove_track(10, &mut alloc);
1311 assert!(t.inode_of_track(10).is_none());
1312 assert!(t.lookup(VirtualTree::ROOT, "A").is_none());
1313 assert!(t.lookup(VirtualTree::ROOT, "C").is_some());
1314 }
1315
1316 #[test]
1317 fn remove_track_keeps_parent_with_surviving_sibling() {
1318 let mut alloc = InodeAllocator::new(false);
1319 let mut t = VirtualTree::build_with(
1320 &[(10, "A/x.flac".into()), (20, "A/y.flac".into())],
1321 &mut alloc,
1322 );
1323 let surviving = t.remove_track(10, &mut alloc);
1324 assert!(t.inode_of_track(10).is_none());
1325 let a = t.lookup(VirtualTree::ROOT, "A").expect("A must survive");
1328 assert_eq!(surviving, Some((a, None)));
1329 assert!(t.lookup(a, "y.flac").is_some());
1330 }
1331
1332 fn paths_of(t: &VirtualTree) -> std::collections::BTreeMap<String, u64> {
1333 let mut out = std::collections::BTreeMap::new();
1334 let mut stack = vec![(VirtualTree::ROOT, String::new())];
1335 while let Some((ino, pfx)) = stack.pop() {
1336 if let Some(kids) = t.children(ino) {
1337 for (name, &child) in kids {
1338 let p = if pfx.is_empty() {
1339 name.clone()
1340 } else {
1341 format!("{pfx}/{name}")
1342 };
1343 if t.is_dir(child) {
1344 stack.push((child, p));
1345 } else {
1346 out.insert(p, child);
1347 }
1348 }
1349 }
1350 }
1351 out
1352 }
1353
1354 #[test]
1355 fn rebuild_subtree_reports_missing_rendered_path() {
1356 use std::collections::HashMap;
1357 let mut alloc = InodeAllocator::new(false);
1358 let mut tree = VirtualTree::build_with(&[(10, "Alice/Song.flac".into())], &mut alloc);
1359 let dir = tree.lookup(VirtualTree::ROOT, "Alice").unwrap();
1360 let new_paths: HashMap<i64, TrackRenderState> = HashMap::new(); let err = tree
1362 .rebuild_subtree(dir, &new_paths, &mut alloc)
1363 .unwrap_err();
1364 assert_eq!(err, RebuildError::MissingRenderedPath(10));
1365 }
1366
1367 #[test]
1368 fn rebuild_subtree_reclaims_freed_base_name() {
1369 let mut alloc = InodeAllocator::new(false);
1370 let mut t = VirtualTree::build_with(
1371 &[(10, "D/song.flac".into()), (20, "D/song.flac".into())],
1372 &mut alloc,
1373 );
1374 let d = t.lookup(VirtualTree::ROOT, "D").unwrap();
1375 t.remove_track(10, &mut alloc);
1376 let mut np = std::collections::HashMap::new();
1378 np.insert(20, trs("D/song.flac"));
1379 t.rebuild_subtree(d, &np, &mut alloc).unwrap();
1380 let reborn = t.lookup(d, "song.flac").unwrap();
1381 assert_eq!(t.inode_of_track(20), Some(reborn));
1382 assert!(t.lookup(d, "song (2).flac").is_none());
1383 }
1384
1385 #[test]
1386 fn rebuild_subtree_matches_build_for_dir_vs_file() {
1387 let entries = vec![
1389 (1, "P/X.flac".to_string()),
1390 (2, "P/X.flac/t.flac".to_string()),
1391 ];
1392 let reference = VirtualTree::build(&entries);
1393 let mut alloc = InodeAllocator::new(false);
1394 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1395 let p = t.lookup(VirtualTree::ROOT, "P").unwrap();
1396 let np: std::collections::HashMap<i64, TrackRenderState> =
1397 entries.iter().map(|&(id, ref p)| (id, trs(p))).collect();
1398 t.rebuild_subtree(p, &np, &mut alloc).unwrap();
1399 assert_eq!(
1400 paths_of(&t).keys().collect::<Vec<_>>(),
1401 paths_of(&reference).keys().collect::<Vec<_>>()
1402 );
1403 }
1404
1405 #[test]
1406 fn apply_changes_handles_dir_vs_file_min_id_flip() {
1407 let entries = vec![
1410 (1, "X.flac/a.flac".to_string()),
1411 (9, "X.flac/b.flac".to_string()),
1412 (5, "X.flac".to_string()), ];
1414 let mut alloc = InodeAllocator::new(false);
1415 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1416 let mut new_entries = vec![(9, "X.flac/b.flac".to_string()), (5, "X.flac".to_string())];
1425 new_entries.sort_by_key(|(id, _)| *id);
1426 let reference = VirtualTree::build(&new_entries);
1427 let new_paths: std::collections::HashMap<i64, TrackRenderState> = new_entries
1428 .iter()
1429 .map(|&(id, ref p)| (id, trs(p)))
1430 .collect();
1431 t.apply_changes(&new_paths, &[], &[], &[1], &mut alloc)
1432 .unwrap();
1433 assert_eq!(
1434 paths_of(&t).keys().collect::<Vec<_>>(),
1435 paths_of(&reference).keys().collect::<Vec<_>>(),
1436 "dir-vs-file min-id flip must match a full rebuild"
1437 );
1438 }
1439
1440 #[test]
1441 fn apply_changes_handles_add_side_min_id_flip() {
1442 let entries = vec![(2, "X.flac".to_string()), (5, "X.flac/a.flac".to_string())];
1445 let mut alloc = InodeAllocator::new(false);
1446 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1447 let mut new_entries = vec![
1450 (1, "X.flac/b.flac".to_string()),
1451 (2, "X.flac".to_string()),
1452 (5, "X.flac/a.flac".to_string()),
1453 ];
1454 new_entries.sort_by_key(|(id, _)| *id);
1455 let reference = VirtualTree::build(&new_entries);
1456 let new_paths: std::collections::HashMap<i64, TrackRenderState> = new_entries
1457 .iter()
1458 .map(|&(id, ref p)| (id, trs(p)))
1459 .collect();
1460 t.apply_changes(&new_paths, &[], &[1], &[], &mut alloc)
1461 .unwrap();
1462 assert_eq!(
1463 paths_of(&t).keys().collect::<Vec<_>>(),
1464 paths_of(&reference).keys().collect::<Vec<_>>(),
1465 "add-side dir-vs-file min-id flip must match a full rebuild"
1466 );
1467 }
1468
1469 #[test]
1470 fn apply_changes_handles_moved_track_across_dirs() {
1471 let entries = vec![
1475 (1, "Old/a.flac".to_string()),
1476 (2, "Old/b.flac".to_string()),
1477 (3, "New/c.flac".to_string()),
1478 ];
1479 let mut alloc = InodeAllocator::new(false);
1480 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1481 let mut new_entries = vec![
1483 (1, "Old/a.flac".to_string()),
1484 (2, "New/b.flac".to_string()),
1485 (3, "New/c.flac".to_string()),
1486 ];
1487 new_entries.sort_by_key(|(id, _)| *id);
1488 let reference = VirtualTree::build(&new_entries);
1489 let new_paths: std::collections::HashMap<i64, TrackRenderState> = new_entries
1490 .iter()
1491 .map(|&(id, ref p)| (id, trs(p)))
1492 .collect();
1493 t.apply_changes(&new_paths, &[2], &[], &[], &mut alloc)
1494 .unwrap();
1495 assert_eq!(
1496 paths_of(&t).keys().collect::<Vec<_>>(),
1497 paths_of(&reference).keys().collect::<Vec<_>>(),
1498 "moved track must match a full rebuild"
1499 );
1500 }
1501
1502 #[test]
1503 fn apply_changes_unchanged_path_is_noop_for_changed_id() {
1504 let entries = vec![(1, "A/x.flac".to_string()), (2, "A/y.flac".to_string())];
1507 let mut alloc = InodeAllocator::new(false);
1508 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1509 let reference = VirtualTree::build(&entries);
1510 let new_paths: std::collections::HashMap<i64, TrackRenderState> =
1511 entries.iter().map(|&(id, ref p)| (id, trs(p))).collect();
1512 let rebuilds = t
1513 .apply_changes(&new_paths, &[1], &[], &[], &mut alloc)
1514 .unwrap();
1515 assert_eq!(rebuilds, 0, "a stable-path changed id must rebuild nothing");
1516 assert_eq!(
1517 paths_of(&t).keys().collect::<Vec<_>>(),
1518 paths_of(&reference).keys().collect::<Vec<_>>(),
1519 "unchanged-path changed id must be a no-op"
1520 );
1521 }
1522
1523 fn assert_apply_matches_build(
1534 before: &[(i64, String)],
1535 after: &[(i64, String)],
1536 changed: &[i64],
1537 added: &[i64],
1538 removed: &[i64],
1539 expected_rebuilds: usize,
1540 ) {
1541 let mut alloc = InodeAllocator::new(false);
1542 let mut t = VirtualTree::build_with(before, &mut alloc);
1543 let mut after_sorted = after.to_vec();
1544 after_sorted.sort_by_key(|(id, _)| *id);
1545 let new_paths: std::collections::HashMap<i64, TrackRenderState> = after_sorted
1546 .iter()
1547 .map(|&(id, ref p)| (id, trs(p)))
1548 .collect();
1549 let rebuilds = t
1550 .apply_changes(&new_paths, changed, added, removed, &mut alloc)
1551 .unwrap();
1552 assert_eq!(rebuilds, expected_rebuilds, "subtree rebuild count");
1553 let mut ref_alloc = alloc.clone();
1554 let reference = VirtualTree::build_with(&after_sorted, &mut ref_alloc);
1555 assert!(
1556 t.equiv(&reference),
1557 "incremental tree diverged from build_with\n applied: {:?}\n reference: {:?}",
1558 paths_of(&t).keys().collect::<Vec<_>>(),
1559 paths_of(&reference).keys().collect::<Vec<_>>(),
1560 );
1561 }
1562
1563 #[test]
1564 fn apply_changes_remove_base_of_collision_group_renames_survivor() {
1565 let before = vec![(1, "A/t.flac".to_string()), (2, "A/t.flac".to_string())];
1568 let after = vec![(2, "A/t.flac".to_string())];
1569 assert_apply_matches_build(&before, &after, &[], &[], &[1], 1);
1570 }
1571
1572 #[test]
1573 fn apply_changes_remove_literal_suffix_name_frees_key_for_pushed_member() {
1574 let before = vec![
1578 (1, "A/t.flac".to_string()),
1579 (2, "A/t (2).flac".to_string()),
1580 (3, "A/t.flac".to_string()),
1581 ];
1582 let after = vec![(1, "A/t.flac".to_string()), (3, "A/t.flac".to_string())];
1583 assert_apply_matches_build(&before, &after, &[], &[], &[2], 1);
1584 }
1585
1586 #[test]
1587 fn apply_changes_add_smaller_id_into_collision_group_shifts_member() {
1588 let before = vec![(5, "A/t.flac".to_string())];
1591 let after = vec![(2, "A/t.flac".to_string()), (5, "A/t.flac".to_string())];
1592 assert_apply_matches_build(&before, &after, &[], &[2], &[], 1);
1593 }
1594
1595 #[test]
1596 fn apply_changes_dir_reclaims_base_name_when_colliding_file_removed() {
1597 let before = vec![(1, "X".to_string()), (2, "X/a.flac".to_string())];
1600 let after = vec![(2, "X/a.flac".to_string())];
1601 assert_apply_matches_build(&before, &after, &[], &[], &[1], 1);
1602 }
1603
1604 #[test]
1605 fn apply_changes_remove_min_id_without_collisions_matches_build() {
1606 let before = vec![
1609 (1, "A/B/t1.flac".to_string()),
1610 (2, "A/B/t2.flac".to_string()),
1611 (3, "C/u.flac".to_string()),
1612 ];
1613 let after = vec![(2, "A/B/t2.flac".to_string()), (3, "C/u.flac".to_string())];
1614 assert_apply_matches_build(&before, &after, &[], &[], &[1], 0);
1615 }
1616
1617 #[test]
1618 fn apply_changes_add_new_top_level_dir_with_min_id_matches_build() {
1619 let before = vec![(5, "A/t1.flac".to_string()), (6, "A/t2.flac".to_string())];
1622 let after = vec![
1623 (1, "B/u.flac".to_string()),
1624 (5, "A/t1.flac".to_string()),
1625 (6, "A/t2.flac".to_string()),
1626 ];
1627 assert_apply_matches_build(&before, &after, &[], &[1], &[], 0);
1628 }
1629
1630 #[test]
1631 fn apply_changes_moved_and_added_ids_colliding_on_fresh_key_rank_by_id() {
1632 let before = vec![
1637 (3, "A/old.flac".to_string()),
1638 (5, "A/keep.flac".to_string()),
1639 ];
1640 let after = vec![
1641 (3, "B/t.flac".to_string()),
1642 (5, "A/keep.flac".to_string()),
1643 (7, "B/t.flac".to_string()),
1644 ];
1645 assert_apply_matches_build(&before, &after, &[3], &[7], &[], 0);
1646 }
1647
1648 #[test]
1649 fn apply_changes_same_dir_move_with_colliding_dir_rebuilds_root_once() {
1650 let before = vec![
1655 (1, "D/x.flac".to_string()),
1656 (2, "D".to_string()),
1657 (3, "D/z.flac".to_string()),
1658 ];
1659 let after = vec![
1660 (1, "D/y.flac".to_string()),
1661 (2, "D".to_string()),
1662 (3, "D/z.flac".to_string()),
1663 ];
1664 assert_apply_matches_build(&before, &after, &[1], &[], &[], 1);
1665 }
1666
1667 #[test]
1668 fn apply_changes_added_min_id_under_colliding_dir_rebuilds_parent() {
1669 let before = vec![(2, "D".to_string()), (5, "D/x.flac".to_string())];
1674 let after = vec![
1675 (1, "D/y.flac".to_string()),
1676 (2, "D".to_string()),
1677 (5, "D/x.flac".to_string()),
1678 ];
1679 assert_apply_matches_build(&before, &after, &[], &[1], &[], 1);
1680 }
1681
1682 #[test]
1683 fn apply_changes_rebuilds_topmost_dirty_dir_only() {
1684 let before = vec![
1687 (1, "t.flac".to_string()),
1688 (2, "t.flac".to_string()),
1689 (10, "A/u.flac".to_string()),
1690 (11, "A/u.flac".to_string()),
1691 ];
1692 let after = vec![(2, "t.flac".to_string()), (11, "A/u.flac".to_string())];
1693 assert_apply_matches_build(&before, &after, &[], &[], &[1, 10], 1);
1694 }
1695
1696 #[test]
1697 fn rebuild_subtree_recurses_multi_level_and_prunes_intermediate() {
1698 let entries = vec![
1702 (1, "P/Sub/t.flac".to_string()),
1703 (2, "P/Sub/u.flac".to_string()),
1704 (3, "P/keep.flac".to_string()),
1705 ];
1706 let mut alloc = InodeAllocator::new(false);
1707 let mut t = VirtualTree::build_with(&entries, &mut alloc);
1708 let p = t.lookup(VirtualTree::ROOT, "P").unwrap();
1709 t.remove_track(1, &mut alloc);
1711 t.remove_track(2, &mut alloc);
1712 let new_entries = vec![(3, "P/keep.flac".to_string())];
1713 let reference = VirtualTree::build(&new_entries);
1714 let np: std::collections::HashMap<i64, TrackRenderState> = new_entries
1715 .iter()
1716 .map(|&(id, ref p)| (id, trs(p)))
1717 .collect();
1718 t.rebuild_subtree(p, &np, &mut alloc).unwrap();
1719 let p2 = t.lookup(VirtualTree::ROOT, "P").unwrap();
1720 assert!(
1721 t.lookup(p2, "Sub").is_none(),
1722 "empty intermediate dir pruned"
1723 );
1724 assert!(t.lookup(p2, "keep.flac").is_some());
1725 assert_eq!(
1726 paths_of(&t).keys().collect::<Vec<_>>(),
1727 paths_of(&reference).keys().collect::<Vec<_>>()
1728 );
1729 }
1730
1731 #[test]
1732 fn equiv_distinguishes_structurally_different_trees() {
1733 let a = VirtualTree::build(&[(10, "A/x.flac".into())]);
1736 let same = VirtualTree::build(&[(10, "A/x.flac".into())]);
1737 let different = VirtualTree::build(&[(10, "B/x.flac".into())]);
1738 assert!(a.equiv(&same), "identical builds must be equiv");
1739 assert!(
1740 !a.equiv(&different),
1741 "different structure must not be equiv (guards equiv->true)"
1742 );
1743 }
1744
1745 #[test]
1746 fn path_of_returns_full_disambiguated_path() {
1747 let t = VirtualTree::build(&[(10, "A/B/x.flac".into())]);
1748 let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1749 let b = t.lookup(a, "B").unwrap();
1750 let x = t.lookup(b, "x.flac").unwrap();
1751 assert_eq!(t.path_of(x), "A/B/x.flac");
1752 assert_eq!(t.path_of(b), "A/B");
1753 assert_eq!(t.path_of(VirtualTree::ROOT), "");
1754 }
1755
1756 #[test]
1757 fn deepest_existing_ancestor_walks_existing_rendered_dirs() {
1758 let t = VirtualTree::build(&[(10, "A/B/x.flac".into())]);
1759 let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1760 let b = t.lookup(a, "B").unwrap();
1761 assert_eq!(t.deepest_existing_ancestor("A/B/new.flac"), (b, 2));
1764 assert_eq!(t.deepest_existing_ancestor("A/Q/new.flac"), (a, 1));
1766 assert_eq!(
1768 t.deepest_existing_ancestor("Z/new.flac"),
1769 (VirtualTree::ROOT, 0)
1770 );
1771 }
1772
1773 #[test]
1774 fn ancestor_in_detects_ancestor_and_self() {
1775 let t = VirtualTree::build(&[(10, "A/B/x.flac".into())]);
1776 let a = t.lookup(VirtualTree::ROOT, "A").unwrap();
1777 let b = t.lookup(a, "B").unwrap();
1778 let mut set = std::collections::HashSet::new();
1779 set.insert(a);
1780 assert!(t.ancestor_in(b, &set), "A is an ancestor of B");
1781 assert!(
1782 t.ancestor_in(a, &set),
1783 "a node is its own ancestor for this check"
1784 );
1785 let empty = std::collections::HashSet::new();
1786 assert!(
1787 !t.ancestor_in(b, &empty),
1788 "no ancestor present (guards ancestor_in->false)"
1789 );
1790 }
1791
1792 #[test]
1793 fn over_long_leaf_truncates_to_255_keeping_extension() {
1794 let path = format!("{}.flac", "t".repeat(300));
1795 let t = VirtualTree::build(&[(10, path)]);
1796 let kids = t.children(VirtualTree::ROOT).unwrap();
1797 assert_eq!(kids.len(), 1);
1798 let name = kids.keys().next().unwrap();
1799 assert!(name.len() <= 255, "leaf is {} bytes", name.len());
1800 assert!(
1801 std::path::Path::new(name)
1802 .extension()
1803 .is_some_and(|ext| ext.eq_ignore_ascii_case("flac")),
1804 "extension preserved"
1805 );
1806 }
1807
1808 #[test]
1809 fn over_long_directory_component_truncates_to_255() {
1810 let path = format!("{}/Song.flac", "d".repeat(300));
1811 let t = VirtualTree::build(&[(10, path)]);
1812 let dir = t
1813 .children(VirtualTree::ROOT)
1814 .unwrap()
1815 .keys()
1816 .next()
1817 .unwrap()
1818 .clone();
1819 assert!(dir.len() <= 255, "dir is {} bytes", dir.len());
1820 }
1821
1822 #[test]
1823 fn over_long_component_truncates_on_utf8_boundary() {
1824 let path = format!("{}.flac", "€".repeat(100));
1825 let t = VirtualTree::build(&[(10, path)]);
1826 let name = t
1827 .children(VirtualTree::ROOT)
1828 .unwrap()
1829 .keys()
1830 .next()
1831 .unwrap()
1832 .clone();
1833 assert!(name.len() <= 255);
1834 assert!(
1835 std::path::Path::new(&name)
1836 .extension()
1837 .is_some_and(|ext| ext.eq_ignore_ascii_case("flac"))
1838 );
1839 }
1840
1841 #[test]
1842 fn colliding_over_long_leaves_stay_distinct_and_within_255() {
1843 let path = format!("{}.flac", "x".repeat(300));
1844 let entries: Vec<(i64, String)> = (0..12).map(|i| (i, path.clone())).collect();
1845 let t = VirtualTree::build(&entries);
1846 let kids = t.children(VirtualTree::ROOT).unwrap();
1847 assert_eq!(kids.len(), 12, "all collisions disambiguated distinctly");
1848 for name in kids.keys() {
1849 assert_eq!(name.len(), 255, "{name:?}");
1854 }
1855 }
1856
1857 #[test]
1858 fn over_long_leaf_with_oversize_extension_truncates_whole_name() {
1859 let path = format!("{}.{}", "s".repeat(300), "e".repeat(254));
1864 let t = VirtualTree::build(&[(10, path)]);
1865 let name = t
1866 .children(VirtualTree::ROOT)
1867 .unwrap()
1868 .keys()
1869 .next()
1870 .unwrap()
1871 .clone();
1872 assert!(name.len() <= 255, "{} bytes", name.len());
1873 assert!(
1874 !name.starts_with('.'),
1875 "no empty-stem leading dot: {name:?}"
1876 );
1877 assert!(
1878 name.starts_with('s'),
1879 "whole-name truncation keeps the stem prefix"
1880 );
1881 }
1882
1883 #[test]
1884 fn over_long_collisions_render_deterministically() {
1885 let path = format!("{}.flac", "y".repeat(300));
1886 let entries: Vec<(i64, String)> = (0..5).map(|i| (i, path.clone())).collect();
1887 let a = VirtualTree::build(&entries);
1888 let b = VirtualTree::build(&entries);
1889 let ak: Vec<_> = a
1890 .children(VirtualTree::ROOT)
1891 .unwrap()
1892 .keys()
1893 .cloned()
1894 .collect();
1895 let bk: Vec<_> = b
1896 .children(VirtualTree::ROOT)
1897 .unwrap()
1898 .keys()
1899 .cloned()
1900 .collect();
1901 assert_eq!(ak, bk);
1902 }
1903
1904 #[test]
1905 fn dot_and_dotdot_plain_components_are_dropped() {
1906 let t = VirtualTree::build(&[(10, "./Song.flac".into()), (20, "../Tune.flac".into())]);
1909 assert!(t.lookup(VirtualTree::ROOT, ".").is_none());
1910 assert!(t.lookup(VirtualTree::ROOT, "..").is_none());
1911 assert!(t.lookup(VirtualTree::ROOT, "Song.flac").is_some());
1913 assert!(t.lookup(VirtualTree::ROOT, "Tune.flac").is_some());
1914 }
1915}