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