1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::fs::Fs;
14use crate::packs::Pack;
15use crate::Result;
16
17#[derive(Debug, Clone, Serialize)]
21pub struct Rule {
22 pub pattern: String,
25
26 pub handler: String,
28
29 pub priority: i32,
31
32 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34 pub options: HashMap<String, String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct PackEntry {
40 pub relative_path: PathBuf,
42 pub absolute_path: PathBuf,
44 pub is_dir: bool,
46}
47
48#[derive(Debug, Clone, Serialize)]
50pub struct RuleMatch {
51 pub relative_path: PathBuf,
53
54 pub absolute_path: PathBuf,
56
57 pub pack: String,
59
60 pub handler: String,
62
63 pub is_dir: bool,
65
66 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
68 pub options: HashMap<String, String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub preprocessor_source: Option<PathBuf>,
74}
75
76pub fn group_by_handler(matches: &[RuleMatch]) -> HashMap<String, Vec<RuleMatch>> {
80 let mut groups: HashMap<String, Vec<RuleMatch>> = HashMap::new();
81 for m in matches {
82 groups.entry(m.handler.clone()).or_default().push(m.clone());
83 }
84 groups
85}
86
87pub fn handler_execution_order(
100 groups: &HashMap<String, Vec<RuleMatch>>,
101 registry: &HashMap<String, Box<dyn crate::handlers::Handler + '_>>,
102) -> Vec<String> {
103 let mut names: Vec<String> = groups.keys().cloned().collect();
104 names.sort_by(|a, b| {
105 let pa = registry.get(a).map(|h| h.phase());
106 let pb = registry.get(b).map(|h| h.phase());
107 match (pa, pb) {
108 (Some(x), Some(y)) => x.cmp(&y),
109 (Some(_), None) => std::cmp::Ordering::Less,
110 (None, Some(_)) => std::cmp::Ordering::Greater,
111 (None, None) => a.cmp(b),
112 }
113 });
114 names
115}
116
117#[derive(Debug)]
121enum CompiledPattern {
122 Exact(String),
124 Glob(glob::Pattern),
126 Directory(String),
129}
130
131#[derive(Debug)]
133struct CompiledRule {
134 pattern: CompiledPattern,
135 is_exclusion: bool,
136 handler: String,
137 priority: i32,
138 options: HashMap<String, String>,
139}
140
141fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
142 rules
143 .iter()
144 .map(|rule| {
145 let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
146 (rest.to_string(), true)
147 } else {
148 (rule.pattern.clone(), false)
149 };
150
151 let pattern = if raw_pattern.ends_with('/') {
152 let dir_name = raw_pattern.trim_end_matches('/').to_string();
154 CompiledPattern::Directory(dir_name)
155 } else if raw_pattern.contains('*')
156 || raw_pattern.contains('?')
157 || raw_pattern.contains('[')
158 {
159 match glob::Pattern::new(&raw_pattern) {
161 Ok(p) => CompiledPattern::Glob(p),
162 Err(_) => CompiledPattern::Exact(raw_pattern),
163 }
164 } else {
165 CompiledPattern::Exact(raw_pattern)
166 };
167
168 CompiledRule {
169 pattern,
170 is_exclusion,
171 handler: rule.handler.clone(),
172 priority: rule.priority,
173 options: rule.options.clone(),
174 }
175 })
176 .collect()
177}
178
179fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
180 match pattern {
181 CompiledPattern::Exact(name) => filename == name,
182 CompiledPattern::Glob(glob) => glob.matches(filename),
183 CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
184 }
185}
186
187pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
191
192pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
200 SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
201}
202
203pub struct Scanner<'a> {
205 fs: &'a dyn Fs,
206}
207
208impl<'a> Scanner<'a> {
209 pub fn new(fs: &'a dyn Fs) -> Self {
210 Self { fs }
211 }
212
213 pub fn scan_pack(
223 &self,
224 pack: &Pack,
225 rules: &[Rule],
226 pack_ignore: &[String],
227 ) -> Result<Vec<RuleMatch>> {
228 let entries = self.walk_pack(&pack.path, pack_ignore)?;
229 Ok(self.match_entries(&entries, rules, &pack.name))
230 }
231
232 pub fn walk_pack(
249 &self,
250 pack_path: &Path,
251 ignore_patterns: &[String],
252 ) -> Result<Vec<PackEntry>> {
253 let mut results = Vec::new();
254 self.list_top_level(pack_path, ignore_patterns, &mut results)?;
255 Ok(results)
256 }
257
258 pub fn walk_pack_recursive(
260 &self,
261 pack_path: &Path,
262 ignore_patterns: &[String],
263 ) -> Result<Vec<PackEntry>> {
264 let mut results = Vec::new();
265 self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
266 Ok(results)
267 }
268
269 pub fn match_entries(
275 &self,
276 entries: &[PackEntry],
277 rules: &[Rule],
278 pack_name: &str,
279 ) -> Vec<RuleMatch> {
280 let compiled = compile_rules(rules);
281 let mut matches = Vec::new();
282
283 for entry in entries {
284 let filename = entry
285 .relative_path
286 .file_name()
287 .map(|n| n.to_string_lossy().to_string())
288 .unwrap_or_default();
289
290 if let Some(rule_match) = match_file(
291 &compiled,
292 &filename,
293 entry.is_dir,
294 &entry.relative_path,
295 &entry.absolute_path,
296 pack_name,
297 ) {
298 matches.push(rule_match);
299 }
300 }
301
302 matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
303 matches
304 }
305
306 fn list_top_level(
309 &self,
310 pack_path: &Path,
311 ignore_patterns: &[String],
312 results: &mut Vec<PackEntry>,
313 ) -> Result<()> {
314 let entries = self.fs.read_dir(pack_path)?;
315
316 for entry in entries {
317 let name = &entry.name;
318
319 if name.starts_with('.') && name != ".config" {
320 continue;
321 }
322 if SPECIAL_FILES.contains(&name.as_str()) {
323 continue;
324 }
325 if is_ignored(name, ignore_patterns) {
326 continue;
327 }
328
329 let rel_path = entry
330 .path
331 .strip_prefix(pack_path)
332 .unwrap_or(&entry.path)
333 .to_path_buf();
334
335 results.push(PackEntry {
336 relative_path: rel_path,
337 absolute_path: entry.path.clone(),
338 is_dir: entry.is_dir,
339 });
340 }
341
342 Ok(())
343 }
344
345 fn walk_dir(
346 &self,
347 base: &Path,
348 dir: &Path,
349 ignore_patterns: &[String],
350 results: &mut Vec<PackEntry>,
351 ) -> Result<()> {
352 let entries = self.fs.read_dir(dir)?;
353
354 for entry in entries {
355 let name = &entry.name;
356
357 if name.starts_with('.') && name != ".config" {
359 continue;
360 }
361
362 if SPECIAL_FILES.contains(&name.as_str()) {
364 continue;
365 }
366
367 if is_ignored(name, ignore_patterns) {
369 continue;
370 }
371
372 let rel_path = entry
373 .path
374 .strip_prefix(base)
375 .unwrap_or(&entry.path)
376 .to_path_buf();
377
378 if entry.is_dir {
379 results.push(PackEntry {
381 relative_path: rel_path.clone(),
382 absolute_path: entry.path.clone(),
383 is_dir: true,
384 });
385 self.walk_dir(base, &entry.path, ignore_patterns, results)?;
387 } else {
388 results.push(PackEntry {
389 relative_path: rel_path,
390 absolute_path: entry.path.clone(),
391 is_dir: false,
392 });
393 }
394 }
395
396 Ok(())
397 }
398}
399
400fn match_file(
405 compiled: &[CompiledRule],
406 filename: &str,
407 is_dir: bool,
408 rel_path: &Path,
409 abs_path: &Path,
410 pack: &str,
411) -> Option<RuleMatch> {
412 for rule in compiled {
414 if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
415 return None;
416 }
417 }
418
419 let mut inclusion_rules: Vec<&CompiledRule> =
422 compiled.iter().filter(|r| !r.is_exclusion).collect();
423 inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
424
425 for rule in inclusion_rules {
426 if matches_entry(&rule.pattern, filename, is_dir) {
427 return Some(RuleMatch {
428 relative_path: rel_path.to_path_buf(),
429 absolute_path: abs_path.to_path_buf(),
430 pack: pack.to_string(),
431 handler: rule.handler.clone(),
432 is_dir,
433 options: rule.options.clone(),
434 preprocessor_source: None,
435 });
436 }
437 }
438
439 None
440}
441
442fn is_ignored(name: &str, patterns: &[String]) -> bool {
443 for pattern in patterns {
444 if let Ok(glob) = glob::Pattern::new(pattern) {
445 if glob.matches(name) {
446 return true;
447 }
448 }
449 if name == pattern {
451 return true;
452 }
453 }
454 false
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::handlers::HandlerConfig;
461 use crate::testing::TempEnvironment;
462
463 fn make_pack(name: &str, path: PathBuf) -> Pack {
464 Pack::new(name.into(), path, HandlerConfig::default())
465 }
466
467 fn default_rules() -> Vec<Rule> {
468 vec![
469 Rule {
470 pattern: "bin/".into(),
471 handler: "path".into(),
472 priority: 10,
473 options: HashMap::new(),
474 },
475 Rule {
476 pattern: "install.sh".into(),
477 handler: "install".into(),
478 priority: 10,
479 options: HashMap::new(),
480 },
481 Rule {
482 pattern: "aliases.sh".into(),
483 handler: "shell".into(),
484 priority: 10,
485 options: HashMap::new(),
486 },
487 Rule {
488 pattern: "profile.sh".into(),
489 handler: "shell".into(),
490 priority: 10,
491 options: HashMap::new(),
492 },
493 Rule {
494 pattern: "Brewfile".into(),
495 handler: "homebrew".into(),
496 priority: 10,
497 options: HashMap::new(),
498 },
499 Rule {
500 pattern: "*".into(),
501 handler: "symlink".into(),
502 priority: 0,
503 options: HashMap::new(),
504 },
505 ]
506 }
507
508 #[test]
511 fn exact_match() {
512 let compiled = compile_rules(&[Rule {
513 pattern: "install.sh".into(),
514 handler: "install".into(),
515 priority: 0,
516 options: HashMap::new(),
517 }]);
518 assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
519 assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
520 }
521
522 #[test]
523 fn glob_match() {
524 let compiled = compile_rules(&[Rule {
525 pattern: "*.sh".into(),
526 handler: "shell".into(),
527 priority: 0,
528 options: HashMap::new(),
529 }]);
530 assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
531 assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
532 assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
533 }
534
535 #[test]
536 fn directory_match() {
537 let compiled = compile_rules(&[Rule {
538 pattern: "bin/".into(),
539 handler: "path".into(),
540 priority: 0,
541 options: HashMap::new(),
542 }]);
543 assert!(matches_entry(&compiled[0].pattern, "bin", true));
544 assert!(!matches_entry(&compiled[0].pattern, "bin", false));
545 assert!(!matches_entry(&compiled[0].pattern, "lib", true));
546 }
547
548 #[test]
549 fn exclusion_prefix() {
550 let compiled = compile_rules(&[Rule {
551 pattern: "!*.tmp".into(),
552 handler: "exclude".into(),
553 priority: 100,
554 options: HashMap::new(),
555 }]);
556 assert!(compiled[0].is_exclusion);
557 assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
558 }
559
560 #[test]
561 fn catchall_matches_everything() {
562 let compiled = compile_rules(&[Rule {
563 pattern: "*".into(),
564 handler: "symlink".into(),
565 priority: 0,
566 options: HashMap::new(),
567 }]);
568 assert!(matches_entry(&compiled[0].pattern, "anything", false));
569 assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
570 }
571
572 #[test]
575 fn scan_pack_basic() {
576 let env = TempEnvironment::builder()
577 .pack("vim")
578 .file("vimrc", "set nocompatible")
579 .file("gvimrc", "set guifont=Mono")
580 .file("aliases.sh", "alias vi=vim")
581 .file("install.sh", "#!/bin/sh\necho setup")
582 .done()
583 .build();
584
585 let scanner = Scanner::new(env.fs.as_ref());
586 let pack = make_pack("vim", env.dotfiles_root.join("vim"));
587 let rules = default_rules();
588
589 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
590
591 let handler_map: HashMap<String, Vec<String>> = {
592 let mut m: HashMap<String, Vec<String>> = HashMap::new();
593 for rm in &matches {
594 m.entry(rm.handler.clone())
595 .or_default()
596 .push(rm.relative_path.to_string_lossy().to_string());
597 }
598 m
599 };
600
601 assert_eq!(handler_map["install"], vec!["install.sh"]);
602 assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
603 assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
604 assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
605 }
606
607 #[test]
608 fn scan_pack_skips_hidden_files() {
609 let env = TempEnvironment::builder()
610 .pack("test")
611 .file("visible", "yes")
612 .file(".hidden", "no")
613 .done()
614 .build();
615
616 let scanner = Scanner::new(env.fs.as_ref());
617 let pack = make_pack("test", env.dotfiles_root.join("test"));
618 let rules = default_rules();
619
620 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
621 let names: Vec<String> = matches
622 .iter()
623 .map(|m| m.relative_path.to_string_lossy().to_string())
624 .collect();
625
626 assert!(names.contains(&"visible".to_string()));
627 assert!(!names.contains(&".hidden".to_string()));
628 }
629
630 #[test]
631 fn scan_pack_skips_special_files() {
632 let env = TempEnvironment::builder()
633 .pack("test")
634 .file("normal", "yes")
635 .config("[pack]\nignore = []")
636 .done()
637 .build();
638
639 let pack_dir = env.dotfiles_root.join("test");
641 env.fs
642 .write_file(&pack_dir.join(".dodotignore"), b"")
643 .unwrap();
644
645 let scanner = Scanner::new(env.fs.as_ref());
646 let pack = make_pack("test", pack_dir);
647 let rules = default_rules();
648
649 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
650 let names: Vec<String> = matches
651 .iter()
652 .map(|m| m.relative_path.to_string_lossy().to_string())
653 .collect();
654
655 assert!(names.contains(&"normal".to_string()));
656 assert!(!names.contains(&".dodot.toml".to_string()));
657 assert!(!names.contains(&".dodotignore".to_string()));
658 }
659
660 #[test]
661 fn scan_pack_with_ignore_patterns() {
662 let env = TempEnvironment::builder()
663 .pack("test")
664 .file("keep.txt", "yes")
665 .file("skip.bak", "no")
666 .file("other.bak", "no")
667 .done()
668 .build();
669
670 let scanner = Scanner::new(env.fs.as_ref());
671 let pack = make_pack("test", env.dotfiles_root.join("test"));
672 let rules = default_rules();
673
674 let matches = scanner
675 .scan_pack(&pack, &rules, &["*.bak".to_string()])
676 .unwrap();
677 let names: Vec<String> = matches
678 .iter()
679 .map(|m| m.relative_path.to_string_lossy().to_string())
680 .collect();
681
682 assert!(names.contains(&"keep.txt".to_string()));
683 assert!(!names.contains(&"skip.bak".to_string()));
684 assert!(!names.contains(&"other.bak".to_string()));
685 }
686
687 #[test]
688 fn scan_pack_exclusion_rules_override_catchall() {
689 let env = TempEnvironment::builder()
690 .pack("test")
691 .file("good.txt", "yes")
692 .file("bad.tmp", "no")
693 .done()
694 .build();
695
696 let scanner = Scanner::new(env.fs.as_ref());
697 let pack = make_pack("test", env.dotfiles_root.join("test"));
698
699 let rules = vec![
700 Rule {
701 pattern: "!*.tmp".into(),
702 handler: "exclude".into(),
703 priority: 100,
704 options: HashMap::new(),
705 },
706 Rule {
707 pattern: "*".into(),
708 handler: "symlink".into(),
709 priority: 0,
710 options: HashMap::new(),
711 },
712 ];
713
714 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
715 let names: Vec<String> = matches
716 .iter()
717 .map(|m| m.relative_path.to_string_lossy().to_string())
718 .collect();
719
720 assert!(names.contains(&"good.txt".to_string()));
721 assert!(!names.contains(&"bad.tmp".to_string()));
722 }
723
724 #[test]
725 fn scan_pack_priority_ordering() {
726 let env = TempEnvironment::builder()
727 .pack("test")
728 .file("aliases.sh", "# shell")
729 .done()
730 .build();
731
732 let scanner = Scanner::new(env.fs.as_ref());
733 let pack = make_pack("test", env.dotfiles_root.join("test"));
734
735 let rules = vec![
737 Rule {
738 pattern: "*.sh".into(),
739 handler: "generic-shell".into(),
740 priority: 5,
741 options: HashMap::new(),
742 },
743 Rule {
744 pattern: "aliases.sh".into(),
745 handler: "specific-shell".into(),
746 priority: 10,
747 options: HashMap::new(),
748 },
749 Rule {
750 pattern: "*".into(),
751 handler: "symlink".into(),
752 priority: 0,
753 options: HashMap::new(),
754 },
755 ];
756
757 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
758 assert_eq!(matches.len(), 1);
759 assert_eq!(matches[0].handler, "specific-shell");
760 }
761
762 #[test]
763 fn scan_pack_directory_entry() {
764 let env = TempEnvironment::builder()
765 .pack("test")
766 .file("bin/my-script", "#!/bin/sh")
767 .file("normal", "x")
768 .done()
769 .build();
770
771 let scanner = Scanner::new(env.fs.as_ref());
772 let pack = make_pack("test", env.dotfiles_root.join("test"));
773 let rules = default_rules();
774
775 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
776
777 let bin_match = matches
778 .iter()
779 .find(|m| m.relative_path.to_string_lossy() == "bin");
780 assert!(bin_match.is_some(), "bin directory should match");
781 assert_eq!(bin_match.unwrap().handler, "path");
782 assert!(bin_match.unwrap().is_dir);
783 }
784
785 #[test]
786 fn nested_install_sh_is_not_matched_by_install_rule() {
787 let env = TempEnvironment::builder()
791 .pack("sneaky")
792 .file("config/install.sh", "echo boom")
793 .done()
794 .build();
795
796 let scanner = Scanner::new(env.fs.as_ref());
797 let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
798 let rules = default_rules();
799
800 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
801
802 assert!(
803 !matches.iter().any(|m| m.handler == "install"),
804 "nested install.sh should not route to install handler: {matches:?}"
805 );
806 }
807
808 #[test]
809 fn scan_pack_returns_only_top_level_entries() {
810 let env = TempEnvironment::builder()
814 .pack("nvim")
815 .file("nvim/init.lua", "require('config')")
816 .file("nvim/lua/plugins.lua", "return {}")
817 .done()
818 .build();
819
820 let scanner = Scanner::new(env.fs.as_ref());
821 let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
822 let rules = default_rules();
823
824 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
825
826 let relpaths: Vec<String> = matches
827 .iter()
828 .map(|m| m.relative_path.to_string_lossy().to_string())
829 .collect();
830
831 assert!(
832 relpaths.iter().any(|p| p == "nvim"),
833 "top-level nvim dir should match: {relpaths:?}"
834 );
835 assert!(
836 !relpaths.iter().any(|p| p.contains('/')),
837 "no nested paths expected: {relpaths:?}"
838 );
839 }
840
841 #[test]
844 fn group_by_handler_groups_correctly() {
845 let matches = vec![
846 RuleMatch {
847 relative_path: "vimrc".into(),
848 absolute_path: "/d/vim/vimrc".into(),
849 pack: "vim".into(),
850 handler: "symlink".into(),
851 is_dir: false,
852 options: HashMap::new(),
853 preprocessor_source: None,
854 },
855 RuleMatch {
856 relative_path: "aliases.sh".into(),
857 absolute_path: "/d/vim/aliases.sh".into(),
858 pack: "vim".into(),
859 handler: "shell".into(),
860 is_dir: false,
861 options: HashMap::new(),
862 preprocessor_source: None,
863 },
864 RuleMatch {
865 relative_path: "gvimrc".into(),
866 absolute_path: "/d/vim/gvimrc".into(),
867 pack: "vim".into(),
868 handler: "symlink".into(),
869 is_dir: false,
870 options: HashMap::new(),
871 preprocessor_source: None,
872 },
873 ];
874
875 let groups = group_by_handler(&matches);
876 assert_eq!(groups.len(), 2);
877 assert_eq!(groups["symlink"].len(), 2);
878 assert_eq!(groups["shell"].len(), 1);
879 }
880
881 #[test]
882 fn handler_execution_order_follows_phase_declaration() {
883 let mut groups = HashMap::new();
884 groups.insert("symlink".into(), vec![]);
885 groups.insert("install".into(), vec![]);
886 groups.insert("shell".into(), vec![]);
887 groups.insert("homebrew".into(), vec![]);
888 groups.insert("path".into(), vec![]);
889
890 let fs = crate::fs::OsFs::new();
891 let registry = crate::handlers::create_registry(&fs);
892 let order = handler_execution_order(&groups, ®istry);
893
894 assert_eq!(
898 order,
899 vec!["homebrew", "install", "path", "shell", "symlink"]
900 );
901 }
902
903 #[test]
904 fn handler_execution_order_places_unknown_handlers_last() {
905 let mut groups = HashMap::new();
906 groups.insert("symlink".into(), vec![]);
907 groups.insert("zzz-unknown".into(), vec![]);
908 groups.insert("homebrew".into(), vec![]);
909
910 let fs = crate::fs::OsFs::new();
911 let registry = crate::handlers::create_registry(&fs);
912 let order = handler_execution_order(&groups, ®istry);
913
914 assert_eq!(order, vec!["homebrew", "symlink", "zzz-unknown"]);
916 }
917
918 #[test]
919 fn rule_match_serializes() {
920 let m = RuleMatch {
921 relative_path: "vimrc".into(),
922 absolute_path: "/dots/vim/vimrc".into(),
923 pack: "vim".into(),
924 handler: "symlink".into(),
925 is_dir: false,
926 options: HashMap::new(),
927 preprocessor_source: None,
928 };
929 let json = serde_json::to_string(&m).unwrap();
930 assert!(json.contains("vimrc"));
931 assert!(json.contains("symlink"));
932 assert!(!json.contains("options"));
933 }
934}