Skip to main content

dodot_lib/
conflicts.rs

1//! Cross-pack conflict detection.
2//!
3//! Detects when multiple packs resolve to the same user-visible target
4//! path. Conflicts are checked **after** all intents are collected and
5//! target paths are fully resolved — this catches collisions introduced
6//! by `[symlink.targets]`, `force_home`, `_home/` prefixes, etc.
7//!
8//! Two kinds of collision are detected:
9//!
10//! 1. **Symlink target collisions**: two packs produce
11//!    `HandlerIntent::Link` with the same resolved `user_path`.
12//! 2. **PATH executable shadowing**: two packs stage directories via the
13//!    path handler that contain files with the same name — only the
14//!    first one in PATH order would be found by the shell.
15//!
16//! Shell handler Stage intents are *not* flagged because each pack's
17//! scripts are sourced independently from per-pack namespaced
18//! directories — multiple packs having `aliases.sh` is legitimate.
19
20use std::collections::HashMap;
21use std::fmt;
22use std::path::{Path, PathBuf};
23
24use crate::fs::Fs;
25use crate::handlers::HANDLER_PATH;
26use crate::operations::HandlerIntent;
27
28/// One pack's claim on a target path.
29#[derive(Debug, Clone)]
30pub struct Claimant {
31    pub pack: String,
32    pub handler: String,
33    pub source: PathBuf,
34}
35
36/// What kind of collision this conflict represents.
37///
38/// The two kinds have different display semantics: symlink conflicts
39/// have a filesystem target path, while path-executable conflicts have
40/// a bare executable name whose location is "somewhere in $PATH".
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ConflictKind {
43    /// Multiple packs resolve to the same user symlink target.
44    SymlinkTarget,
45    /// Multiple packs stage a `$PATH` directory that contains files
46    /// with the same name — only the first in PATH order would be used.
47    PathExecutable,
48}
49
50/// A cross-pack conflict: multiple packs claim the same effective target.
51#[derive(Debug, Clone)]
52pub struct Conflict {
53    /// The kind of collision.
54    pub kind: ConflictKind,
55    /// For [`ConflictKind::SymlinkTarget`]: the resolved filesystem path.
56    /// For [`ConflictKind::PathExecutable`]: a sentinel path
57    /// `<path-executable>/<name>` — read `.file_name()` for the bare name.
58    pub target: PathBuf,
59    /// Every pack that claims this target.
60    pub claimants: Vec<Claimant>,
61}
62
63impl fmt::Display for Conflict {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "  target: {}", self.target.display())?;
66        for c in &self.claimants {
67            write!(
68                f,
69                "\n    - pack '{}' ({} handler): {}",
70                c.pack,
71                c.handler,
72                c.source.display()
73            )?;
74        }
75        Ok(())
76    }
77}
78
79/// Format a list of conflicts for error display.
80pub fn format_conflicts(conflicts: &[Conflict]) -> String {
81    conflicts
82        .iter()
83        .map(|c| c.to_string())
84        .collect::<Vec<_>>()
85        .join("\n")
86}
87
88/// Detect cross-pack conflicts across all collected intents.
89///
90/// `pack_intents` is a slice of `(pack_name, intents)` pairs, one per
91/// pack. Returns a (possibly empty) list of conflicts where multiple
92/// **different** packs claim the same target.
93///
94/// `fs` is needed to list the contents of path-handler directories
95/// for executable name collision detection.
96pub fn detect_cross_pack_conflicts(
97    pack_intents: &[(String, Vec<HandlerIntent>)],
98    fs: &dyn Fs,
99) -> Vec<Conflict> {
100    let mut targets: HashMap<PathBuf, Vec<Claimant>> = HashMap::new();
101
102    let mut kinds: HashMap<PathBuf, ConflictKind> = HashMap::new();
103
104    for (pack_name, intents) in pack_intents {
105        for intent in intents {
106            // Symlink target conflicts
107            if let HandlerIntent::Link { user_path, .. } = intent {
108                kinds.insert(user_path.clone(), ConflictKind::SymlinkTarget);
109                targets
110                    .entry(user_path.clone())
111                    .or_default()
112                    .push(Claimant {
113                        pack: pack_name.clone(),
114                        handler: intent.handler().to_string(),
115                        source: intent_source(intent),
116                    });
117            }
118
119            // PATH executable shadowing: list files inside staged directories
120            if let HandlerIntent::Stage {
121                handler, source, ..
122            } = intent
123            {
124                if handler == HANDLER_PATH {
125                    if let Ok(entries) = fs.read_dir(source) {
126                        for entry in entries {
127                            if entry.is_file || entry.is_symlink {
128                                let key = Path::new("<path-executable>").join(&entry.name);
129                                kinds.insert(key.clone(), ConflictKind::PathExecutable);
130                                targets.entry(key).or_default().push(Claimant {
131                                    pack: pack_name.clone(),
132                                    handler: handler.clone(),
133                                    source: entry.path.clone(),
134                                });
135                            }
136                        }
137                    }
138                }
139            }
140        }
141    }
142
143    let mut conflicts: Vec<Conflict> = targets
144        .into_iter()
145        .filter(|(_, claimants)| {
146            // Only flag when at least two *different* packs claim the target.
147            let first = &claimants[0].pack;
148            claimants.len() > 1 && claimants.iter().any(|c| c.pack != *first)
149        })
150        .map(|(target, claimants)| {
151            let kind = kinds
152                .get(&target)
153                .copied()
154                .unwrap_or(ConflictKind::SymlinkTarget);
155            Conflict {
156                kind,
157                target,
158                claimants,
159            }
160        })
161        .collect();
162
163    // Sort for deterministic output
164    conflicts.sort_by(|a, b| a.target.cmp(&b.target));
165    conflicts
166}
167
168fn intent_source(intent: &HandlerIntent) -> PathBuf {
169    match intent {
170        HandlerIntent::Link { source, .. } => source.clone(),
171        HandlerIntent::Stage { source, .. } => source.clone(),
172        HandlerIntent::Run { executable, .. } => PathBuf::from(executable),
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::testing::TempEnvironment;
180
181    fn link(pack: &str, source: &str, user_path: &str) -> HandlerIntent {
182        HandlerIntent::Link {
183            pack: pack.into(),
184            handler: "symlink".into(),
185            source: PathBuf::from(source),
186            user_path: PathBuf::from(user_path),
187        }
188    }
189
190    fn stage(pack: &str, handler: &str, source: &str) -> HandlerIntent {
191        HandlerIntent::Stage {
192            pack: pack.into(),
193            handler: handler.into(),
194            source: PathBuf::from(source),
195        }
196    }
197
198    /// Helper: create a mock Fs for tests that don't need real filesystem.
199    fn dummy_fs() -> std::sync::Arc<crate::fs::OsFs> {
200        std::sync::Arc::new(crate::fs::OsFs::new())
201    }
202
203    // ── No conflicts ───────────────────────────────────────────
204
205    #[test]
206    fn no_conflicts_when_different_targets() {
207        let fs = dummy_fs();
208        let pack_intents = vec![
209            (
210                "vim".into(),
211                vec![link("vim", "/dot/vim/vimrc", "/home/.vimrc")],
212            ),
213            (
214                "git".into(),
215                vec![link("git", "/dot/git/gitconfig", "/home/.gitconfig")],
216            ),
217        ];
218        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
219        assert!(conflicts.is_empty());
220    }
221
222    #[test]
223    fn no_conflicts_when_single_pack() {
224        let fs = dummy_fs();
225        let pack_intents = vec![(
226            "vim".into(),
227            vec![
228                link("vim", "/dot/vim/vimrc", "/home/.vimrc"),
229                link("vim", "/dot/vim/gvimrc", "/home/.gvimrc"),
230            ],
231        )];
232        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
233        assert!(conflicts.is_empty());
234    }
235
236    #[test]
237    fn no_conflicts_when_empty() {
238        let fs = dummy_fs();
239        let conflicts = detect_cross_pack_conflicts(&[], fs.as_ref());
240        assert!(conflicts.is_empty());
241    }
242
243    #[test]
244    fn no_conflicts_for_run_intents() {
245        let fs = dummy_fs();
246        let pack_intents = vec![
247            (
248                "a".into(),
249                vec![HandlerIntent::Run {
250                    pack: "a".into(),
251                    handler: "install".into(),
252                    executable: "echo".into(),
253                    arguments: vec!["hi".into()],
254                    sentinel: "s1".into(),
255                }],
256            ),
257            (
258                "b".into(),
259                vec![HandlerIntent::Run {
260                    pack: "b".into(),
261                    handler: "install".into(),
262                    executable: "echo".into(),
263                    arguments: vec!["hi".into()],
264                    sentinel: "s1".into(),
265                }],
266            ),
267        ];
268        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
269        assert!(conflicts.is_empty());
270    }
271
272    // ── Link conflicts ─────────────────────────────────────────
273
274    #[test]
275    fn detects_link_link_conflict() {
276        let fs = dummy_fs();
277        let pack_intents = vec![
278            (
279                "pack-a".into(),
280                vec![link("pack-a", "/dot/pack-a/aliases", "/home/.aliases")],
281            ),
282            (
283                "pack-b".into(),
284                vec![link("pack-b", "/dot/pack-b/aliases", "/home/.aliases")],
285            ),
286        ];
287        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
288        assert_eq!(conflicts.len(), 1);
289        assert_eq!(conflicts[0].target, PathBuf::from("/home/.aliases"));
290        assert_eq!(conflicts[0].claimants.len(), 2);
291
292        let packs: Vec<&str> = conflicts[0]
293            .claimants
294            .iter()
295            .map(|c| c.pack.as_str())
296            .collect();
297        assert!(packs.contains(&"pack-a"));
298        assert!(packs.contains(&"pack-b"));
299    }
300
301    #[test]
302    fn detects_multiple_conflicts() {
303        let fs = dummy_fs();
304        let pack_intents = vec![
305            (
306                "a".into(),
307                vec![
308                    link("a", "/dot/a/f1", "/home/.f1"),
309                    link("a", "/dot/a/f2", "/home/.f2"),
310                ],
311            ),
312            (
313                "b".into(),
314                vec![
315                    link("b", "/dot/b/f1", "/home/.f1"),
316                    link("b", "/dot/b/f2", "/home/.f2"),
317                ],
318            ),
319        ];
320        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
321        assert_eq!(conflicts.len(), 2);
322    }
323
324    #[test]
325    fn three_packs_one_conflict() {
326        let fs = dummy_fs();
327        let pack_intents = vec![
328            (
329                "a".into(),
330                vec![link("a", "/dot/a/conf", "/home/.config/app/conf")],
331            ),
332            (
333                "b".into(),
334                vec![link("b", "/dot/b/conf", "/home/.config/app/conf")],
335            ),
336            (
337                "c".into(),
338                vec![link("c", "/dot/c/conf", "/home/.config/app/conf")],
339            ),
340        ];
341        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
342        assert_eq!(conflicts.len(), 1);
343        assert_eq!(conflicts[0].claimants.len(), 3);
344    }
345
346    // ── Stage intents ──────────────────────────────────────────
347
348    #[test]
349    fn same_name_shell_scripts_are_not_conflicts() {
350        let fs = dummy_fs();
351        let pack_intents = vec![
352            (
353                "vim".into(),
354                vec![stage("vim", "shell", "/dot/vim/aliases.sh")],
355            ),
356            (
357                "git".into(),
358                vec![stage("git", "shell", "/dot/git/aliases.sh")],
359            ),
360        ];
361        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
362        assert!(
363            conflicts.is_empty(),
364            "same-name shell scripts in different packs are legitimate"
365        );
366    }
367
368    #[test]
369    fn stage_intents_do_not_conflict_with_link_intents() {
370        let fs = dummy_fs();
371        let pack_intents = vec![
372            ("a".into(), vec![link("a", "/dot/a/tool", "/home/bin/tool")]),
373            ("b".into(), vec![stage("b", "path", "/nonexistent/dir")]),
374        ];
375        let conflicts = detect_cross_pack_conflicts(&pack_intents, fs.as_ref());
376        assert!(conflicts.is_empty());
377    }
378
379    // ── PATH executable shadowing ──────────────────────────────
380
381    #[test]
382    fn detects_path_executable_shadowing() {
383        // Two packs both have bin/ directories containing a file named `tool`
384        let env = TempEnvironment::builder()
385            .pack("tools-a")
386            .file("bin/tool", "#!/bin/sh\necho a")
387            .done()
388            .pack("tools-b")
389            .file("bin/tool", "#!/bin/sh\necho b")
390            .done()
391            .build();
392
393        let pack_intents = vec![
394            (
395                "tools-a".into(),
396                vec![stage(
397                    "tools-a",
398                    "path",
399                    &env.dotfiles_root.join("tools-a/bin").to_string_lossy(),
400                )],
401            ),
402            (
403                "tools-b".into(),
404                vec![stage(
405                    "tools-b",
406                    "path",
407                    &env.dotfiles_root.join("tools-b/bin").to_string_lossy(),
408                )],
409            ),
410        ];
411        let conflicts = detect_cross_pack_conflicts(&pack_intents, env.fs.as_ref());
412        assert_eq!(conflicts.len(), 1, "should detect shadowed executable");
413
414        let c = &conflicts[0];
415        assert!(
416            c.target.to_string_lossy().contains("tool"),
417            "target should mention the executable name: {}",
418            c.target.display()
419        );
420        assert_eq!(c.claimants.len(), 2);
421
422        let packs: Vec<&str> = c.claimants.iter().map(|cl| cl.pack.as_str()).collect();
423        assert!(packs.contains(&"tools-a"));
424        assert!(packs.contains(&"tools-b"));
425    }
426
427    #[test]
428    fn no_path_conflict_when_different_executables() {
429        // Two packs with bin/ directories but different file names — no conflict
430        let env = TempEnvironment::builder()
431            .pack("tools-a")
432            .file("bin/tool-a", "#!/bin/sh")
433            .done()
434            .pack("tools-b")
435            .file("bin/tool-b", "#!/bin/sh")
436            .done()
437            .build();
438
439        let pack_intents = vec![
440            (
441                "tools-a".into(),
442                vec![stage(
443                    "tools-a",
444                    "path",
445                    &env.dotfiles_root.join("tools-a/bin").to_string_lossy(),
446                )],
447            ),
448            (
449                "tools-b".into(),
450                vec![stage(
451                    "tools-b",
452                    "path",
453                    &env.dotfiles_root.join("tools-b/bin").to_string_lossy(),
454                )],
455            ),
456        ];
457        let conflicts = detect_cross_pack_conflicts(&pack_intents, env.fs.as_ref());
458        assert!(conflicts.is_empty());
459    }
460
461    #[test]
462    fn path_executable_conflict_shows_source_files() {
463        let env = TempEnvironment::builder()
464            .pack("a")
465            .file("bin/deploy", "#!/bin/sh\necho a")
466            .done()
467            .pack("b")
468            .file("bin/deploy", "#!/bin/sh\necho b")
469            .done()
470            .build();
471
472        let pack_intents = vec![
473            (
474                "a".into(),
475                vec![stage(
476                    "a",
477                    "path",
478                    &env.dotfiles_root.join("a/bin").to_string_lossy(),
479                )],
480            ),
481            (
482                "b".into(),
483                vec![stage(
484                    "b",
485                    "path",
486                    &env.dotfiles_root.join("b/bin").to_string_lossy(),
487                )],
488            ),
489        ];
490        let conflicts = detect_cross_pack_conflicts(&pack_intents, env.fs.as_ref());
491        assert_eq!(conflicts.len(), 1);
492
493        // Claimant sources should point to the actual files, not the directories
494        for claimant in &conflicts[0].claimants {
495            assert!(
496                claimant.source.to_string_lossy().contains("deploy"),
497                "source should be the file, not the directory: {}",
498                claimant.source.display()
499            );
500        }
501    }
502
503    #[test]
504    fn same_pack_path_executables_are_not_conflicts() {
505        // A single pack can't conflict with itself
506        let env = TempEnvironment::builder()
507            .pack("tools")
508            .file("bin/tool", "#!/bin/sh")
509            .done()
510            .build();
511
512        let pack_intents = vec![(
513            "tools".into(),
514            vec![stage(
515                "tools",
516                "path",
517                &env.dotfiles_root.join("tools/bin").to_string_lossy(),
518            )],
519        )];
520        let conflicts = detect_cross_pack_conflicts(&pack_intents, env.fs.as_ref());
521        assert!(conflicts.is_empty());
522    }
523
524    #[test]
525    fn detects_path_shadowing_via_symlinks() {
526        // bin/ entries that are symlinks (e.g. bin/tool -> ../libexec/tool) must
527        // also be detected as potential shadowing conflicts.
528        let env = TempEnvironment::builder()
529            .pack("tools-a")
530            .file("libexec/tool", "#!/bin/sh\necho a")
531            .done()
532            .pack("tools-b")
533            .file("libexec/tool", "#!/bin/sh\necho b")
534            .done()
535            .build();
536
537        // Create bin/ directories and symlinks inside them
538        let bin_a = env.dotfiles_root.join("tools-a/bin");
539        let bin_b = env.dotfiles_root.join("tools-b/bin");
540        env.fs.mkdir_all(&bin_a).unwrap();
541        env.fs.mkdir_all(&bin_b).unwrap();
542        env.fs
543            .symlink(
544                &env.dotfiles_root.join("tools-a/libexec/tool"),
545                &bin_a.join("tool"),
546            )
547            .unwrap();
548        env.fs
549            .symlink(
550                &env.dotfiles_root.join("tools-b/libexec/tool"),
551                &bin_b.join("tool"),
552            )
553            .unwrap();
554
555        let pack_intents = vec![
556            (
557                "tools-a".into(),
558                vec![stage("tools-a", "path", &bin_a.to_string_lossy())],
559            ),
560            (
561                "tools-b".into(),
562                vec![stage("tools-b", "path", &bin_b.to_string_lossy())],
563            ),
564        ];
565        let conflicts = detect_cross_pack_conflicts(&pack_intents, env.fs.as_ref());
566        assert_eq!(
567            conflicts.len(),
568            1,
569            "symlink executables with the same name should be detected as shadowing"
570        );
571        let packs: Vec<&str> = conflicts[0]
572            .claimants
573            .iter()
574            .map(|c| c.pack.as_str())
575            .collect();
576        assert!(packs.contains(&"tools-a"));
577        assert!(packs.contains(&"tools-b"));
578    }
579
580    // ── Display ────────────────────────────────────────────────
581
582    #[test]
583    fn conflict_display_includes_all_info() {
584        let conflict = Conflict {
585            kind: ConflictKind::SymlinkTarget,
586            target: PathBuf::from("/home/.aliases"),
587            claimants: vec![
588                Claimant {
589                    pack: "pack-a".into(),
590                    handler: "symlink".into(),
591                    source: PathBuf::from("/dot/pack-a/aliases"),
592                },
593                Claimant {
594                    pack: "pack-b".into(),
595                    handler: "symlink".into(),
596                    source: PathBuf::from("/dot/pack-b/aliases"),
597                },
598            ],
599        };
600        let display = conflict.to_string();
601        assert!(display.contains("/home/.aliases"));
602        assert!(display.contains("pack-a"));
603        assert!(display.contains("pack-b"));
604        assert!(display.contains("symlink"));
605    }
606
607    #[test]
608    fn format_conflicts_combines_multiple() {
609        let conflicts = vec![
610            Conflict {
611                kind: ConflictKind::SymlinkTarget,
612                target: PathBuf::from("/home/.a"),
613                claimants: vec![
614                    Claimant {
615                        pack: "x".into(),
616                        handler: "symlink".into(),
617                        source: PathBuf::from("/dot/x/a"),
618                    },
619                    Claimant {
620                        pack: "y".into(),
621                        handler: "symlink".into(),
622                        source: PathBuf::from("/dot/y/a"),
623                    },
624                ],
625            },
626            Conflict {
627                kind: ConflictKind::SymlinkTarget,
628                target: PathBuf::from("/home/.b"),
629                claimants: vec![
630                    Claimant {
631                        pack: "x".into(),
632                        handler: "symlink".into(),
633                        source: PathBuf::from("/dot/x/b"),
634                    },
635                    Claimant {
636                        pack: "y".into(),
637                        handler: "symlink".into(),
638                        source: PathBuf::from("/dot/y/b"),
639                    },
640                ],
641            },
642        ];
643        let formatted = format_conflicts(&conflicts);
644        assert!(formatted.contains("/home/.a"));
645        assert!(formatted.contains("/home/.b"));
646    }
647}