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)]
38pub struct Conflict {
39 pub target: PathBuf,
42 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
62pub fn format_conflicts(conflicts: &[Conflict]) -> String {
64 conflicts
65 .iter()
66 .map(|c| c.to_string())
67 .collect::<Vec<_>>()
68 .join("\n")
69}
70
71pub 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 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 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 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 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 fn dummy_fs() -> std::sync::Arc<crate::fs::OsFs> {
169 std::sync::Arc::new(crate::fs::OsFs::new())
170 }
171
172 #[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 #[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 #[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 #[test]
351 fn detects_path_executable_shadowing() {
352 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 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 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 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 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 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 #[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}