1use 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#[derive(Debug, Clone)]
30pub struct Claimant {
31 pub pack: String,
32 pub handler: String,
33 pub source: PathBuf,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ConflictKind {
43 SymlinkTarget,
45 PathExecutable,
48}
49
50#[derive(Debug, Clone)]
52pub struct Conflict {
53 pub kind: ConflictKind,
55 pub target: PathBuf,
59 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
79pub fn format_conflicts(conflicts: &[Conflict]) -> String {
81 conflicts
82 .iter()
83 .map(|c| c.to_string())
84 .collect::<Vec<_>>()
85 .join("\n")
86}
87
88pub 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 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 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 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 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 fn dummy_fs() -> std::sync::Arc<crate::fs::OsFs> {
200 std::sync::Arc::new(crate::fs::OsFs::new())
201 }
202
203 #[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 #[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 #[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 #[test]
382 fn detects_path_executable_shadowing() {
383 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 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 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 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 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 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 #[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}