Skip to main content

path_resolver/
lib.rs

1#![deny(missing_docs)]
2//! Provides a trie-based data structure to track and resolve relative path ownership across multiple packages.
3//!
4//! For details see methods of `PathResolver`.
5use std::{
6    collections::BTreeSet,
7    ffi::OsString,
8    io,
9    path::{Component, Path, PathBuf},
10    sync::Arc,
11};
12
13use fs_err as fs;
14use indexmap::IndexSet;
15use itertools::Itertools;
16
17/// Type to represent path owner. Using `Arc<str>` to avoid cloning
18/// overhead while maintaining ownership semantics.
19#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct PackageName(Arc<str>);
21
22impl PackageName {
23    /// Create a new `PackageName` from a string-like value.
24    /// Accepts `&str`, `String`, `Box<str>`, or an existing `Arc<str>`.
25    pub fn new(s: impl Into<Arc<str>>) -> Self {
26        Self(s.into())
27    }
28
29    /// Get the package name as a string slice.
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35impl From<&str> for PackageName {
36    fn from(s: &str) -> Self {
37        Self::new(s)
38    }
39}
40
41impl From<String> for PackageName {
42    fn from(s: String) -> Self {
43        Self::new(s)
44    }
45}
46
47impl From<&String> for PackageName {
48    fn from(s: &String) -> Self {
49        Self::new(s.clone())
50    }
51}
52
53impl AsRef<str> for PackageName {
54    fn as_ref(&self) -> &str {
55        &self.0
56    }
57}
58
59impl AsRef<Path> for PackageName {
60    fn as_ref(&self) -> &Path {
61        let s: &str = self.as_ref();
62        Path::new(s)
63    }
64}
65
66impl std::fmt::Display for PackageName {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(f, "{}", self.0)
69    }
70}
71
72impl std::ops::Deref for PackageName {
73    type Target = str;
74
75    fn deref(&self) -> &Self::Target {
76        &self.0
77    }
78}
79
80/// Vector of files that we want to move to clobbers directory.
81pub type ToClobbers = Vec<(PathBuf, PackageName)>;
82/// Vector of files that we want to move from clobbers directory.
83pub type FromClobbers = Vec<(PathBuf, PackageName)>;
84/// Changes that we have to do to keep on-disk state in tact with what we have in-memory.
85pub type Changes = (ToClobbers, FromClobbers);
86
87/// Struct to represent a path clobbered by multiple packages.
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89pub struct ClobberedPath {
90    /// The name of the package that ultimately owns the file.
91    pub winner: PackageName,
92
93    /// Other packages whose files were overridden at this path.
94    pub losers: Vec<PackageName>,
95}
96
97/// One node in the path-component trie.
98#[derive(Default, Debug, Clone, PartialEq, Eq)]
99struct PathTrieNode {
100    /// All tags that touch this prefix *or* any descendant.
101    prefixes: ahash::HashSet<PackageName>,
102    /// Tags that have a file exactly at this node.
103    terminals: ahash::HashSet<PackageName>,
104    /// Child components.
105    children: ahash::HashMap<OsString, PathTrieNode>,
106}
107
108/// A trie of relative file-paths, tagged by package name (in insertion order).
109#[derive(Default, Debug, Clone, PartialEq, Eq)]
110pub struct PathResolver {
111    root: PathTrieNode,
112    packages: IndexSet<PackageName>,
113}
114
115impl PathResolver {
116    /// Create an empty trie.
117    pub fn new() -> Self {
118        Self {
119            root: PathTrieNode::default(),
120            packages: IndexSet::new(),
121        }
122    }
123
124    /// Return slice to packages. Note that it is in the order of decreasing priority.
125    pub fn packages(&self) -> &IndexSet<PackageName> {
126        &self.packages
127    }
128
129    /// Insert a file path under `package_name`.
130    fn insert_file_owned<P: AsRef<Path>>(&mut self, path: P, package: PackageName) {
131        let path = path.as_ref();
132        assert!(
133            path.is_relative(),
134            "All inserted paths must be relative; got {path:?}"
135        );
136
137        let mut node = &mut self.root;
138        for comp in path.components().map(|c| c.as_os_str().to_os_string()) {
139            node.prefixes.insert(package.clone());
140            node = node.children.entry(comp).or_default();
141        }
142        node.prefixes.insert(package.clone());
143        node.terminals.insert(package);
144    }
145
146    /// Get a mutable reference to the node at `path`, if it exists.
147    fn get_node_mut<'a>(&'a mut self, path: &Path) -> Option<&'a mut PathTrieNode> {
148        let mut cur = &mut self.root;
149        for comp in path.components().map(Component::as_os_str) {
150            cur = cur.children.get_mut(comp)?;
151        }
152        Some(cur)
153    }
154
155    /// Propagate a `package_name` into every descendant's `prefixes` set.
156    fn propagate_prefix(node: &mut PathTrieNode, package_name: PackageName) {
157        node.prefixes.insert(package_name.clone());
158        for child in node.children.values_mut() {
159            Self::propagate_prefix(child, package_name.clone());
160        }
161    }
162
163    /// Insert a package files; return the new paths that conflict
164    /// with what was already in the trie before this call.
165    ///
166    /// 1. **File -> File** at `p`: return `p`.
167    /// 2. **Directory -> File** at `p`: return just `p`.
168    /// 3. **File -> Directory** under some existing file `f`: return the new file’s `p`.
169    /// 4. **Directory -> Directory**: no conflict.
170    pub fn insert_package<P: AsRef<Path>>(
171        &mut self,
172        package: PackageName,
173        paths: &[P],
174    ) -> Vec<PathBuf> {
175        // Record insertion order for future reprioritize.
176        self.packages.insert(package.clone());
177
178        let mut conflicts = BTreeSet::default();
179        // Which of these paths were *directories* on the old trie?
180        let mut dir_inserts = Vec::new();
181
182        // 1) detect conflicts against the existing trie
183        //
184        // For each path we want to insert (e.g., "foo/bar/baz.txt"):
185        //
186        // 1. Start at the trie root and break path into components ["foo", "bar", "baz.txt"]
187        //
188        // 2. Traverse the trie component by component:
189        //   - For each component, check if it exists in current node's children
190        //   - Use peekable iterator to know if we're at the last component
191        //
192        // 3. During traversal, detect "File blocks Directory" conflict:
193        //   - If we're not at the last component (still have subdirectories to traverse)
194        //   - BUT current node has terminals (meaning a file exists here)
195        //   - Then we have a conflict: existing file blocks our directory path
196        //   - Example: If "foo/bar" is a file, we can't insert "foo/bar/baz.txt"
197        //
198        // 4. If we reach the final component without prefix conflicts:
199        //   - Check "File vs File" conflict:
200        //     - If node has terminals, another file already exists at this exact path
201        //   - Check "Directory vs File" conflict:
202        //     - If node has children, we're trying to replace a directory with a file
203        //     - Example: If "foo/bar/" is a directory, we can't insert "foo/bar" as a file
204        for p in paths {
205            let p = p.as_ref();
206
207            let mut current_node = &self.root;
208            let mut found_node = None;
209            let mut has_conflict = false;
210
211            let mut components = p.components().peekable();
212
213            while let Some(component) = components.next() {
214                let comp = component.as_os_str();
215                let is_last = components.peek().is_none();
216
217                match current_node.children.get(comp) {
218                    Some(node) => {
219                        // Check for File -> Directory conflict (file exists at prefix)
220                        if !is_last && !node.terminals.is_empty() {
221                            conflicts.insert(p.to_path_buf());
222                            has_conflict = true;
223                            break;
224                        }
225
226                        current_node = node;
227
228                        if is_last {
229                            found_node = Some(node);
230                        }
231                    }
232                    None => break, // Path doesn't exist in trie
233                }
234            }
235
236            // Check conflicts at the target node (if we found it and no prefix conflict)
237            if !has_conflict {
238                if let Some(n) = found_node {
239                    // File -> File conflict
240                    if !n.terminals.is_empty() {
241                        conflicts.insert(p.to_path_buf());
242                        continue;
243                    }
244                    // Directory -> File conflict
245                    if !n.children.is_empty() {
246                        let pbuf = p.to_path_buf();
247                        conflicts.insert(pbuf.clone());
248                        dir_inserts.push(pbuf);
249                    }
250                }
251            }
252        }
253
254        // 2) actually insert all files
255        for p in paths {
256            self.insert_file_owned(p, package.clone());
257        }
258
259        // 3) propagate directory inserts into descendants
260        for pbuf in dir_inserts {
261            if let Some(n) = self.get_node_mut(&pbuf) {
262                Self::propagate_prefix(n, package.clone());
263            }
264        }
265
266        conflicts.into_iter().collect()
267    }
268
269    /// Unregister all paths belonging to `package`, then prune empty
270    /// branches.
271    ///
272    /// Returns a change vectors.
273    pub fn unregister_package<N: Into<PackageName>>(&mut self, package: N) -> Changes {
274        fn collect_next_candidate_paths(
275            package: PackageName,
276            candidates: &indexmap::set::Slice<PackageName>,
277            n: &PathTrieNode,
278            to_add: &mut Vec<(PathBuf, PackageName)>,
279            path: &mut PathBuf,
280            under_removed: bool,
281        ) {
282            // Determine if this node is part of the removed package's coverage.
283            let removed_here = n.terminals.contains(&package);
284            let prefix_here = n.prefixes.contains(&package);
285            // Active if we're under a removed file or at a prefix of a removed package.
286            let active = under_removed || removed_here || prefix_here;
287            if !active {
288                return;
289            }
290
291            // If any candidate has a file here, take the highest-priority one.
292            for candidate in candidates {
293                if n.terminals.contains(candidate) {
294                    to_add.push((path.clone(), candidate.clone()));
295                    break;
296                }
297            }
298
299            // Prepare flag for deeper recursion: once under a removed file, stay under.
300            let next_under = under_removed || removed_here;
301            // Recurse into all child nodes under this covered subtree.
302            for (comp, child) in &n.children {
303                path.push(comp);
304                collect_next_candidate_paths(
305                    package.clone(),
306                    candidates,
307                    child,
308                    to_add,
309                    path,
310                    next_under,
311                );
312                path.pop();
313            }
314        }
315
316        fn prune(n: &mut PathTrieNode) -> bool {
317            n.children.retain(|_, c| !prune(c));
318            n.prefixes.is_empty() && n.terminals.is_empty() && n.children.is_empty()
319        }
320
321        fn rm(n: &mut PathTrieNode, pkg: PackageName) {
322            n.prefixes.remove(&pkg);
323            n.terminals.remove(&pkg);
324            for child in n.children.values_mut() {
325                rm(child, pkg.clone());
326            }
327        }
328
329        let package = package.into();
330
331        if !self.packages.contains(&package) {
332            return Default::default();
333        }
334
335        let mut from_clobbers = vec![];
336        let to_clobbers = vec![];
337
338        if let Some(candidates) = self
339            .packages
340            .get_index_of(&package)
341            .and_then(|idx| self.packages.get_range(idx + 1..))
342        {
343            collect_next_candidate_paths(
344                package.clone(),
345                candidates,
346                &self.root,
347                &mut from_clobbers,
348                &mut PathBuf::new(),
349                false,
350            );
351        }
352
353        self.packages.shift_remove(&package);
354
355        rm(&mut self.root, package);
356
357        self.root.children.retain(|_, c| !prune(c));
358
359        (to_clobbers, from_clobbers)
360    }
361
362    /// Recompute active files based on old (insertion) vs new priority.
363    pub fn reprioritize_packages(&mut self, new_order: Vec<PackageName>) -> Changes {
364        fn rank<'a>(
365            order: impl IntoIterator<Item = &'a PackageName>,
366        ) -> ahash::HashMap<&'a PackageName, usize> {
367            order.into_iter().enumerate().map(|(i, v)| (v, i)).collect()
368        }
369
370        fn collect_removed_subtree(
371            n: &PathTrieNode,
372            cur: &mut PathBuf,
373            old_rank: &ahash::HashMap<&PackageName, usize>,
374            to_clobbers: &mut ToClobbers,
375        ) {
376            let old_winner = n
377                .prefixes
378                .iter()
379                .max_by_key(|p| old_rank.get(p).copied().unwrap_or(usize::MAX));
380            if let Some(p) = old_winner {
381                if n.terminals.contains(p) {
382                    to_clobbers.push((cur.clone(), p.clone()));
383                }
384            }
385            for (comp, child) in &n.children {
386                cur.push(comp);
387                collect_removed_subtree(child, cur, old_rank, to_clobbers);
388                cur.pop();
389            }
390        }
391
392        fn collect_new_winners(
393            n: &PathTrieNode,
394            cur: &mut PathBuf,
395            new_rank: &ahash::HashMap<&PackageName, usize>,
396            from_clobbers: &mut FromClobbers,
397        ) {
398            let new_winner = n
399                .prefixes
400                .iter()
401                .max_by_key(|p| new_rank.get(p).copied().unwrap_or(usize::MAX));
402            if let Some(p) = new_winner {
403                if n.terminals.contains(p) {
404                    from_clobbers.push((cur.clone(), p.clone()));
405                    return; // don't descend, file shadows
406                }
407                for (comp, child) in &n.children {
408                    cur.push(comp);
409                    collect_new_winners(child, cur, new_rank, from_clobbers);
410                    cur.pop();
411                }
412            }
413        }
414
415        fn dfs(
416            n: &PathTrieNode,
417            cur: &mut PathBuf,
418            old_rank: &ahash::HashMap<&PackageName, usize>,
419            new_rank: &ahash::HashMap<&PackageName, usize>,
420            to_clobbers: &mut ToClobbers,
421            from_clobbers: &mut FromClobbers,
422        ) {
423            let old_winner = n
424                .prefixes
425                .iter()
426                .max_by_key(|p| old_rank.get(p).copied().unwrap_or(usize::MAX));
427            let new_winner = n
428                .prefixes
429                .iter()
430                .max_by_key(|p| new_rank.get(p).copied().unwrap_or(usize::MAX));
431
432            let old_is_file = old_winner.is_some_and(|p| n.terminals.contains(p));
433            let new_is_file = new_winner.is_some_and(|p| n.terminals.contains(p));
434
435            match (old_is_file, new_is_file) {
436                (true, true) => {
437                    let old = old_winner.unwrap();
438                    let new = new_winner.unwrap();
439                    if old != new {
440                        to_clobbers.push((cur.clone(), old.clone()));
441                        from_clobbers.push((cur.clone(), new.clone()));
442                    }
443                    // don't descend
444                }
445                (false, true) => {
446                    let new = new_winner.unwrap();
447                    from_clobbers.push((cur.clone(), new.clone()));
448                    collect_removed_subtree(n, cur, old_rank, to_clobbers);
449                    // don't descend
450                }
451                (true, false) => {
452                    let old = old_winner.unwrap();
453                    to_clobbers.push((cur.clone(), old.clone()));
454                    collect_new_winners(n, cur, new_rank, from_clobbers);
455                    // don't descend further: new_winners handles it
456                }
457                (false, false) => {
458                    // Recurse normally
459                    for (comp, child) in &n.children {
460                        cur.push(comp);
461                        dfs(child, cur, old_rank, new_rank, to_clobbers, from_clobbers);
462                        cur.pop();
463                    }
464                }
465            }
466        }
467
468        {
469            let is_reordering = 'reorder: {
470                if self.packages.len() != new_order.len() {
471                    break 'reorder false;
472                }
473                let self_pkg_set: ahash::HashSet<&PackageName> = self.packages.iter().collect();
474                let new_pkg_set: ahash::HashSet<&PackageName> = new_order.iter().collect();
475                self_pkg_set == new_pkg_set
476            };
477
478            assert!(
479                is_reordering,
480                "Expected just reordering, got something else.
481Old:
482{:#?}
483New:
484{:#?}
485",
486                self.packages
487                    .iter()
488                    .cloned()
489                    .sorted()
490                    .collect::<Vec<PackageName>>(),
491                new_order
492                    .iter()
493                    .cloned()
494                    .sorted()
495                    .collect::<Vec<PackageName>>()
496            );
497        }
498
499        // Package with highest priority will have biggest number.
500        let old_rank = rank(self.packages.iter().rev());
501        let new_rank = rank(new_order.iter());
502
503        let mut to_clobbers = Vec::new();
504        let mut from_clobbers = Vec::new();
505
506        let mut buf = PathBuf::new();
507        dfs(
508            &self.root,
509            &mut buf,
510            &old_rank,
511            &new_rank,
512            &mut to_clobbers,
513            &mut from_clobbers,
514        );
515
516        self.packages.clear();
517        self.packages.extend(new_order.into_iter().rev());
518
519        (to_clobbers, from_clobbers)
520    }
521
522    /// Move files on-disk:
523    ///
524    /// - For each `(p,pkg)` in `to_clobbers`:  `base/p` → `clobbers/pkg/p` if `base/p` exists and dest doesn’t.
525    /// - For each `(p,pkg)` in `from_clobbers`: `clobbers/pkg/p` → `base/p` if source exists and dest doesn’t.
526    pub fn sync_clobbers(
527        target_prefix: &Path,
528        clobbers_dir: &Path,
529        to_clobbers: &[(PathBuf, PackageName)],
530        from_clobbers: &[(PathBuf, PackageName)],
531    ) -> io::Result<()> {
532        fn mv(src: PathBuf, dst: PathBuf) -> io::Result<()> {
533            tracing::trace!("Moving from {} to {}", src.display(), dst.display());
534            if let Some(p) = dst.parent() {
535                fs::create_dir_all(p)?;
536            }
537            fs::rename(src, dst)
538        }
539
540        fn check_file_exists(path: &Path) -> bool {
541            path.symlink_metadata().is_ok()
542        }
543
544        for (p, pkg) in to_clobbers {
545            let src = target_prefix.join(p);
546            let dst = clobbers_dir.join::<&Path>(pkg.as_ref()).join(p);
547            if check_file_exists(&src) && !check_file_exists(&dst) {
548                mv(src, dst)?;
549            }
550        }
551
552        for (p, pkg) in from_clobbers {
553            let src = clobbers_dir.join::<&Path>(pkg.as_ref()).join(p);
554            let dst = target_prefix.join(p);
555            if check_file_exists(&src) && !check_file_exists(&dst) {
556                mv(src, dst)?;
557            }
558        }
559
560        Ok(())
561    }
562
563    /// Which packages own this prefix?
564    pub fn packages_for_prefix<P: AsRef<Path>>(
565        &self,
566        path: P,
567    ) -> Option<&ahash::HashSet<PackageName>> {
568        let mut cur = &self.root;
569
570        for comp in path.as_ref().components().map(Component::as_os_str) {
571            cur = cur.children.get(comp)?;
572        }
573
574        Some(&cur.prefixes)
575    }
576
577    /// Who owns exactly this file?
578    pub fn packages_for_exact<P: AsRef<Path>>(
579        &self,
580        path: P,
581    ) -> Option<&ahash::HashSet<PackageName>> {
582        Self::get_node(&self.root, path.as_ref()).map(|n| &n.terminals)
583    }
584
585    /// List global file-vs-file conflicts (>1 owners).
586    pub fn find_conflicts(&self) -> Vec<(PathBuf, Vec<PackageName>)> {
587        fn dfs(n: &PathTrieNode, cur: &mut PathBuf, out: &mut Vec<(PathBuf, Vec<PackageName>)>) {
588            if n.terminals.len() > 1 {
589                out.push((cur.clone(), n.terminals.iter().cloned().collect()));
590            }
591            for (c, child) in &n.children {
592                cur.push(c);
593                dfs(child, cur, out);
594                cur.pop();
595            }
596        }
597
598        let mut out = Vec::new();
599        let mut buf = PathBuf::new();
600        dfs(&self.root, &mut buf, &mut out);
601        out
602    }
603
604    /// Internal: get an immutable node for exact path.
605    fn get_node<'a>(root: &'a PathTrieNode, path: &Path) -> Option<&'a PathTrieNode> {
606        let mut cur = root;
607        for comp in path.components().map(Component::as_os_str) {
608            cur = cur.children.get(comp)?;
609        }
610        Some(cur)
611    }
612
613    /// Collect all paths where multiple packages wrote the same file,
614    /// returning a map from each path to its final owner and overridden packages.
615    pub fn collect_clobbered_paths(&self) -> ahash::HashMap<PathBuf, ClobberedPath> {
616        fn dfs(
617            node: &PathTrieNode,
618            path: &mut PathBuf,
619            packages: &IndexSet<PackageName>,
620            results: &mut ahash::HashMap<PathBuf, ClobberedPath>,
621        ) {
622            if !node.terminals.is_empty() {
623                // Determine the winning package by insertion priority
624                if let Some(winner) = packages.iter().find(|pkg| node.terminals.contains(pkg)) {
625                    // Collect all other packages that wrote to this path
626                    let others: Vec<PackageName> = node
627                        .terminals
628                        .iter()
629                        .filter(|&p| (p.as_ref() as &Path) != (winner.as_ref() as &Path))
630                        .cloned()
631                        .collect();
632                    if !others.is_empty() {
633                        results.insert(
634                            path.clone(),
635                            ClobberedPath {
636                                winner: winner.clone(),
637                                losers: others,
638                            },
639                        );
640                    }
641                }
642            }
643            for (comp, child) in &node.children {
644                path.push(comp);
645                dfs(child, path, packages, results);
646                path.pop();
647            }
648        }
649
650        let mut results = ahash::HashMap::default();
651        dfs(
652            &self.root,
653            &mut PathBuf::new(),
654            &self.packages,
655            &mut results,
656        );
657        results
658    }
659}
660
661// TODO: Write property based tests.
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use std::{
666        fs,
667        fs::File,
668        io::{Read, Write},
669        path::PathBuf,
670    };
671    use tempfile::TempDir;
672
673    #[test]
674    fn test_insert_file_vs_file_conflict() {
675        let mut resolver = PathResolver::new();
676        assert!(resolver
677            .insert_package("pkg1".into(), &["foo.txt"])
678            .is_empty());
679        let conflicts = resolver.insert_package("pkg2".into(), &["foo.txt"]);
680        assert_eq!(conflicts, vec![PathBuf::from("foo.txt")]);
681    }
682
683    #[test]
684    fn test_insert_nested_file_vs_file_conflict() {
685        let mut resolver = PathResolver::new();
686        assert!(resolver
687            .insert_package("pkg1".into(), &["foo/bar.txt"])
688            .is_empty());
689        let conflicts = resolver.insert_package("pkg2".into(), &["foo/bar.txt"]);
690        assert_eq!(conflicts, vec![PathBuf::from("foo/bar.txt")]);
691    }
692
693    #[test]
694    fn test_insert_dir_vs_file_conflict() {
695        let mut resolver = PathResolver::new();
696        assert!(resolver
697            .insert_package("pkg1".into(), &["foo/bar.txt", "foo/baz.txt"])
698            .is_empty());
699        let mut conflicts = resolver.insert_package("pkg2".into(), &["foo"]);
700        conflicts.sort();
701        assert_eq!(conflicts, vec![PathBuf::from("foo")]);
702    }
703
704    #[test]
705    fn test_insert_file_vs_dir_conflict() {
706        let mut resolver = PathResolver::new();
707        assert!(resolver.insert_package("pkg1".into(), &["foo"]).is_empty());
708        let conflicts = resolver.insert_package("pkg2".into(), &["foo/bar.txt"]);
709        assert_eq!(conflicts, vec![PathBuf::from("foo/bar.txt")]);
710    }
711
712    #[test]
713    fn test_no_conflict_on_sibling() {
714        let mut resolver = PathResolver::new();
715        assert!(resolver.insert_package("a".into(), &["a/x"]).is_empty());
716        assert!(resolver.insert_package("b".into(), &["b/y"]).is_empty());
717    }
718
719    #[test]
720    fn test_no_conflict_on_dir_sibling() {
721        let mut resolver = PathResolver::new();
722        assert!(resolver.insert_package("a".into(), &["a/x"]).is_empty());
723        assert!(resolver.insert_package("b".into(), &["a/y"]).is_empty());
724    }
725
726    #[test]
727    fn test_unregister_package() {
728        let mut resolver = PathResolver::new();
729        let paths = ["foo.txt", "foo/bar.txt"];
730        assert!(resolver.insert_package("pkg".into(), &paths).is_empty());
731        resolver.unregister_package("pkg");
732        assert!(resolver
733            .packages_for_exact("foo.txt")
734            .is_none_or(ahash::HashSet::is_empty));
735        assert!(resolver.packages_for_prefix("foo").is_none());
736    }
737
738    #[test]
739    fn test_reprioritize_noop() {
740        let mut resolver = PathResolver::new();
741        resolver.insert_package("pkg1".into(), &["a.txt"]);
742        resolver.insert_package("pkg2".into(), &["b.txt"]);
743        let (removed, added) = resolver.reprioritize_packages(vec!["pkg1".into(), "pkg2".into()]);
744        assert!(removed.is_empty());
745        assert!(added.is_empty());
746    }
747
748    #[test]
749    fn test_reprioritize_file_vs_file() {
750        let mut resolver = PathResolver::new();
751        resolver.insert_package("pkg1".into(), &["foo.txt"]);
752        resolver.insert_package("pkg2".into(), &["foo.txt"]);
753        let (removed, added) = resolver.reprioritize_packages(vec!["pkg1".into(), "pkg2".into()]);
754        assert_eq!(removed, vec![(PathBuf::from("foo.txt"), "pkg1".into())]);
755        assert_eq!(added, vec![(PathBuf::from("foo.txt"), "pkg2".into())]);
756    }
757
758    #[test]
759    fn test_reprioritize_file_vs_dir() {
760        let mut resolver = PathResolver::new();
761        resolver.insert_package("pkg1".into(), &["foo"]);
762        resolver.insert_package("pkg2".into(), &["foo/bar.txt"]);
763        let (removed, added) = resolver.reprioritize_packages(vec!["pkg1".into(), "pkg2".into()]);
764        assert_eq!(removed, vec![(PathBuf::from("foo"), "pkg1".into())]);
765        assert_eq!(added, vec![(PathBuf::from("foo/bar.txt"), "pkg2".into())]);
766    }
767
768    #[test]
769    fn test_reprioritize_dir_vs_file() {
770        let mut resolver = PathResolver::new();
771        resolver.insert_package("pkg1".into(), &["foo/bar.txt"]);
772        resolver.insert_package("pkg2".into(), &["foo"]);
773        let (removed, added) = resolver.reprioritize_packages(vec!["pkg1".into(), "pkg2".into()]);
774        assert_eq!(removed, vec![(PathBuf::from("foo/bar.txt"), "pkg1".into())]);
775        assert_eq!(added, vec![(PathBuf::from("foo"), "pkg2".into())]);
776    }
777
778    #[test]
779    fn test_reprioritize_dir_vs_dir() {
780        let mut resolver = PathResolver::new();
781        resolver.insert_package("pkg1".into(), &["foo/bar1.txt", "foo/bar2.txt"]);
782        let mut conflict = resolver.insert_package("pkg2".into(), &["foo/bar2.txt"]);
783        conflict.sort();
784        assert_eq!(conflict, vec![PathBuf::from("foo/bar2.txt")]);
785        let (removed, added) = resolver.reprioritize_packages(vec!["pkg1".into(), "pkg2".into()]);
786        // pkg1 was winner and now pkg2 is a winner
787        assert_eq!(
788            removed,
789            vec![(PathBuf::from("foo/bar2.txt"), "pkg1".into())],
790        );
791        assert_eq!(added, vec![(PathBuf::from("foo/bar2.txt"), "pkg2".into())]);
792    }
793
794    #[test]
795    fn test_reprioritize_file_vs_dir_vs_dir_with_permuted_insertion_order() {
796        let priority_order = vec!["pkg1".into(), "pkg2".into(), "pkg3".into()];
797
798        // 1
799        let pkgs: &[(PackageName, &[&str])] = &[
800            ("pkg1".into(), &["foo"]),
801            ("pkg2".into(), &["foo/bar1.txt", "foo/bar2.txt"]),
802            ("pkg3".into(), &["foo/bar2.txt"]),
803        ];
804
805        let mut resolver = PathResolver::new();
806        for (pkg_name, paths) in pkgs {
807            resolver.insert_package(pkg_name.clone(), paths);
808        }
809
810        let (removed, mut added) = resolver.reprioritize_packages(priority_order.clone());
811        assert_eq!(removed, vec![(PathBuf::from("foo"), "pkg1".into())],);
812        added.sort();
813        assert_eq!(
814            added,
815            vec![
816                (PathBuf::from("foo/bar1.txt"), "pkg2".into()),
817                (PathBuf::from("foo/bar2.txt"), "pkg3".into())
818            ]
819        );
820
821        // 2
822        let pkgs: &[(String, &[&str])] = &[
823            ("pkg1".into(), &["foo"]),
824            ("pkg3".into(), &["foo/bar2.txt"]),
825            ("pkg2".into(), &["foo/bar1.txt", "foo/bar2.txt"]),
826        ];
827
828        let mut resolver = PathResolver::new();
829        for (pkg_name, paths) in pkgs {
830            resolver.insert_package(pkg_name.into(), paths);
831        }
832
833        let (removed, mut added) = resolver.reprioritize_packages(priority_order.clone());
834        assert_eq!(removed, vec![(PathBuf::from("foo"), "pkg1".into())],);
835        added.sort();
836        assert_eq!(
837            added,
838            vec![
839                (PathBuf::from("foo/bar1.txt"), "pkg2".into()),
840                (PathBuf::from("foo/bar2.txt"), "pkg3".into())
841            ]
842        );
843
844        // 3
845        let pkgs: &[(String, &[&str])] = &[
846            ("pkg2".into(), &["foo/bar1.txt", "foo/bar2.txt"]),
847            ("pkg1".into(), &["foo"]),
848            ("pkg3".into(), &["foo/bar2.txt"]),
849        ];
850
851        let mut resolver = PathResolver::new();
852        for (pkg_name, paths) in pkgs {
853            resolver.insert_package(pkg_name.into(), paths);
854        }
855
856        let (removed, added) = resolver.reprioritize_packages(priority_order.clone());
857        assert_eq!(
858            removed,
859            vec![(PathBuf::from("foo/bar2.txt"), "pkg2".into())],
860        );
861        assert_eq!(added, vec![(PathBuf::from("foo/bar2.txt"), "pkg3".into())]);
862
863        // 4
864        let pkgs: &[(String, &[&str])] = &[
865            ("pkg2".into(), &["foo/bar1.txt", "foo/bar2.txt"]),
866            ("pkg3".into(), &["foo/bar2.txt"]),
867            ("pkg1".into(), &["foo"]),
868        ];
869
870        let mut resolver = PathResolver::new();
871        for (pkg_name, paths) in pkgs {
872            resolver.insert_package(pkg_name.into(), paths);
873        }
874
875        let (removed, added) = resolver.reprioritize_packages(priority_order.clone());
876        assert_eq!(
877            removed,
878            vec![(PathBuf::from("foo/bar2.txt"), "pkg2".into())],
879        );
880        assert_eq!(added, vec![(PathBuf::from("foo/bar2.txt"), "pkg3".into())]);
881
882        // 5
883        let pkgs: &[(String, &[&str])] = &[
884            ("pkg3".into(), &["foo/bar2.txt"]),
885            ("pkg1".into(), &["foo"]),
886            ("pkg2".into(), &["foo/bar1.txt", "foo/bar2.txt"]),
887        ];
888
889        let mut resolver = PathResolver::new();
890        for (pkg_name, paths) in pkgs {
891            resolver.insert_package(pkg_name.into(), paths);
892        }
893
894        let (removed, added) = resolver.reprioritize_packages(priority_order.clone());
895        assert!(removed.is_empty());
896        assert!(added.is_empty());
897
898        // 6
899        let pkgs: &[(String, &[&str])] = &[
900            ("pkg3".into(), &["foo/bar2.txt"]),
901            ("pkg2".into(), &["foo/bar1.txt", "foo/bar2.txt"]),
902            ("pkg1".into(), &["foo"]),
903        ];
904
905        let mut resolver = PathResolver::new();
906        for (pkg_name, paths) in pkgs {
907            resolver.insert_package(pkg_name.into(), paths);
908        }
909
910        let (removed, added) = resolver.reprioritize_packages(priority_order.clone());
911        assert!(removed.is_empty());
912        assert!(added.is_empty());
913    }
914
915    #[test]
916    fn test_tags_queries() {
917        let mut resolver = PathResolver::new();
918        resolver.insert_package("pkg".into(), &["d1/f1.txt", "d1/f2.txt", "d2/f3.txt"]);
919        let p1 = resolver.packages_for_prefix("d1").unwrap();
920        assert_eq!(p1.len(), 1);
921        assert!(p1.contains(&"pkg".into()));
922        let e = resolver.packages_for_exact("d1/f2.txt").unwrap();
923        assert_eq!(e.len(), 1);
924        assert!(e.contains(&"pkg".into()));
925    }
926
927    #[test]
928    fn test_sync_clobbers_file_vs_file() {
929        let tmp = TempDir::new().unwrap();
930        let target_prefix = tmp.path();
931        let clobbers = tmp.path().join("__clobbers__");
932        fs::create_dir_all(target_prefix).unwrap();
933        fs::create_dir_all(clobbers.join("pkg2")).unwrap();
934
935        File::create(target_prefix.join("foo.txt"))
936            .unwrap()
937            .write_all(b"pkg1")
938            .unwrap();
939        File::create(clobbers.join("pkg2").join("foo.txt"))
940            .unwrap()
941            .write_all(b"pkg2")
942            .unwrap();
943
944        let mut resolver = PathResolver::new();
945        resolver.insert_package("pkg1".into(), &["foo.txt"]);
946        resolver.insert_package("pkg2".into(), &["foo.txt"]);
947        let (to_clobbers, from_clobbers) =
948            resolver.reprioritize_packages(vec!["pkg1".into(), "pkg2".into()]);
949
950        PathResolver::sync_clobbers(target_prefix, &clobbers, &to_clobbers, &from_clobbers)
951            .unwrap();
952
953        let mut buf = String::new();
954        File::open(target_prefix.join("foo.txt"))
955            .unwrap()
956            .read_to_string(&mut buf)
957            .unwrap();
958        assert_eq!(buf, "pkg2");
959
960        let mut buf = String::new();
961        File::open(clobbers.join("pkg1").join("foo.txt"))
962            .unwrap()
963            .read_to_string(&mut buf)
964            .unwrap();
965        assert_eq!(buf, "pkg1");
966    }
967
968    // TODO: Write more tests for unregister.
969    #[test]
970    fn test_insert_file_vs_dir_conflict_unregister() {
971        let mut resolver = PathResolver::new();
972        assert!(resolver.insert_package("pkg1".into(), &["foo"]).is_empty());
973        resolver.insert_package("pkg2".into(), &["foo/bar.txt"]);
974        let moves = resolver.unregister_package("pkg1");
975        assert_eq!(
976            moves,
977            (vec![], vec![(PathBuf::from("foo/bar.txt"), "pkg2".into())])
978        );
979    }
980
981    #[test]
982    fn test_collect_clobbered_no_conflict() {
983        let mut resolver = PathResolver::new();
984        assert!(resolver
985            .insert_package("pkg1".into(), &["a.txt"])
986            .is_empty());
987
988        let clobbered = resolver.collect_clobbered_paths();
989        assert!(clobbered.is_empty());
990    }
991
992    #[test]
993    fn test_collect_clobbered_simple_conflict() {
994        let mut resolver = PathResolver::new();
995        assert!(resolver
996            .insert_package("pkg1".into(), &["file.txt"])
997            .is_empty());
998        let conflicts = resolver.insert_package("pkg2".into(), &["file.txt"]);
999        assert_eq!(conflicts, vec![PathBuf::from("file.txt")]);
1000
1001        let clobbered = resolver.collect_clobbered_paths();
1002        assert_eq!(clobbered.len(), 1);
1003        let path = PathBuf::from("file.txt");
1004        let entry = clobbered.get(&path).expect("file.txt should be present");
1005        assert_eq!(entry.winner, "pkg1".into());
1006        assert_eq!(entry.losers, vec!["pkg2".into()]);
1007    }
1008
1009    #[test]
1010    fn test_collect_clobbered_multiple_conflicts() {
1011        let mut resolver = PathResolver::new();
1012        assert!(resolver
1013            .insert_package("pkg1".into(), &["dup.txt"])
1014            .is_empty());
1015        let conflicts = resolver.insert_package("pkg2".into(), &["dup.txt"]);
1016        assert_eq!(conflicts, vec![PathBuf::from("dup.txt")]);
1017        let conflicts = resolver.insert_package("pkg3".into(), &["dup.txt"]);
1018        assert_eq!(conflicts, vec![PathBuf::from("dup.txt")]);
1019
1020        let clobbered = resolver.collect_clobbered_paths();
1021        assert_eq!(clobbered.len(), 1);
1022        let path = PathBuf::from("dup.txt");
1023        let entry = clobbered.get(&path).expect("dup.txt should be present");
1024        assert_eq!(entry.winner, "pkg1".into());
1025        let mut others = entry.losers.clone();
1026        others.sort();
1027        assert_eq!(others, vec!["pkg2".into(), "pkg3".into()]);
1028    }
1029
1030    #[test]
1031    fn test_collect_clobbered_multiple_files() {
1032        let mut resolver = PathResolver::new();
1033        assert!(resolver
1034            .insert_package("pkg1".into(), &["a.txt", "b.txt"])
1035            .is_empty());
1036        let conflicts = resolver.insert_package("pkg2".into(), &["a.txt"]);
1037        assert_eq!(conflicts, vec![PathBuf::from("a.txt")]);
1038
1039        let clobbered = resolver.collect_clobbered_paths();
1040        assert_eq!(clobbered.len(), 1);
1041        let path = PathBuf::from("a.txt");
1042        let entry = clobbered.get(&path).expect("a.txt should be clobbered");
1043        assert_eq!(
1044            entry,
1045            &ClobberedPath {
1046                winner: "pkg1".into(),
1047                losers: vec!["pkg2".into()]
1048            }
1049        );
1050    }
1051
1052    #[test]
1053    fn test_collect_clobbered_directory_conflict() {
1054        let mut resolver = PathResolver::new();
1055        assert!(resolver.insert_package("pkg1".into(), &["dir"]).is_empty());
1056        let conflicts = resolver.insert_package("pkg2".into(), &["dir"]);
1057        assert_eq!(conflicts, vec![PathBuf::from("dir")]);
1058
1059        let clobbered = resolver.collect_clobbered_paths();
1060        assert_eq!(clobbered.len(), 1);
1061        let path = PathBuf::from("dir");
1062        let entry = clobbered.get(&path).expect("dir should be clobbered");
1063        assert_eq!(
1064            entry,
1065            &ClobberedPath {
1066                winner: "pkg1".into(),
1067                losers: vec!["pkg2".into()]
1068            }
1069        );
1070    }
1071}
1072
1073#[cfg(test)]
1074mod props {
1075    use super::*;
1076
1077    use std::collections::BTreeMap;
1078    use std::path::PathBuf;
1079
1080    use proptest::prelude::*;
1081    use proptest::sample::subsequence;
1082    use proptest::string::string_regex;
1083
1084    /// Filesystem path trie.
1085    #[derive(Clone, Debug)]
1086    enum Node {
1087        File,
1088        Dir(BTreeMap<String, Node>),
1089    }
1090
1091    fn collect_paths(node: &Node, cur: &Path, out: &mut Vec<PathBuf>) {
1092        match node {
1093            Node::File => out.push(cur.to_path_buf()),
1094            Node::Dir(children) => {
1095                for (seg, child) in children {
1096                    let mut next = cur.to_path_buf();
1097                    next.push(seg);
1098                    collect_paths(child, &next, out);
1099                }
1100            }
1101        }
1102    }
1103
1104    // TODO: Add trie with non-empty paths for more property-based tests.
1105    /// Strategy to build random path trie.
1106    fn path_trie() -> impl Strategy<Value = Node> {
1107        // atomic leaf
1108        let leaf = Just(Node::File).boxed();
1109        // directory nodes built from smaller tries
1110        let dir = |inner: BoxedStrategy<Node>| {
1111            prop::collection::btree_map(
1112                // unique segment names
1113                string_regex("[a-z]{1,1}").unwrap(),
1114                inner,
1115                1..=5,
1116            )
1117            .prop_map(Node::Dir)
1118            .boxed()
1119        };
1120
1121        leaf.prop_recursive(5, 64, 5, dir)
1122    }
1123
1124    /// Strategy yielding a vector of `(PackageName, Vec<PathBuf>)`,
1125    fn arb_package_set() -> impl Strategy<Value = Vec<(String, Vec<PathBuf>)>> {
1126        // 1) pick 1–5 distinct package names
1127        let names = subsequence(&["pkg1", "pkg2", "pkg3", "pkg4", "pkg5", "pkg6"], 1..=5)
1128            .prop_map(|v| v.into_iter().map(str::to_string).collect::<Vec<_>>());
1129
1130        names.prop_flat_map(move |pkgs| {
1131            // draw one independent non_empty_trie per package
1132            let tries = prop::collection::vec(path_trie(), pkgs.len());
1133
1134            (Just(pkgs.clone()), tries).prop_map(move |(pkgs, trees)| {
1135                pkgs.into_iter()
1136                    .zip(trees)
1137                    .map(|(pkg, tree)| {
1138                        let mut paths = Vec::new();
1139                        collect_paths(&tree, &PathBuf::new(), &mut paths);
1140                        (pkg, paths)
1141                    })
1142                    .collect()
1143            })
1144        })
1145    }
1146
1147    /// Strategy yielding a `PathResolver` with some packages already inserted,
1148    /// together with the `Vec<PackageName>` in the order they were inserted.
1149    fn arb_resolver() -> impl Strategy<Value = (PathResolver, Vec<PackageName>)> {
1150        arb_package_set().prop_map(|pkg_set| {
1151            let mut resolver = PathResolver::new();
1152            // keep track of the order in which we insert packages
1153            let mut initial_order = Vec::with_capacity(pkg_set.len());
1154
1155            for (package, paths) in pkg_set {
1156                // Insert each package (ignoring any spurious conflicts)
1157                let pkg: PackageName = package.into();
1158                let _ = resolver.insert_package(pkg.clone(), &paths);
1159                initial_order.push(pkg);
1160            }
1161
1162            (resolver, initial_order)
1163        })
1164    }
1165
1166    proptest! {
1167        #[test]
1168        fn identity_no_changes((mut resolver, packages) in arb_resolver()) {
1169            let (removed, added) = resolver.reprioritize_packages(packages.into_iter().rev().collect());
1170            prop_assert!(removed.is_empty());
1171            prop_assert!(added.is_empty());
1172        }
1173
1174        #[test]
1175        fn reprioritize_updates_order((mut resolver, packages) in arb_resolver()) {
1176            let new_order: Vec<_> = packages.iter().rev().cloned().collect();
1177            let (_removed, _added) = resolver.reprioritize_packages(new_order.clone());
1178            let current_order: Vec<_> = resolver.packages.iter().rev().cloned().collect();
1179            prop_assert_eq!(current_order, new_order);
1180        }
1181
1182        #[test]
1183        fn idempotent_after_reprioritize((mut resolver, packages) in arb_resolver()) {
1184            let new_order: Vec<_> = packages.clone();
1185            let _first = resolver.reprioritize_packages(new_order.clone());
1186            let (removed2, added2) = resolver.reprioritize_packages(new_order);
1187            prop_assert!(removed2.is_empty());
1188            prop_assert!(added2.is_empty());
1189        }
1190    }
1191}