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