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, Serialize)]
39pub struct RuleMatch {
40 pub relative_path: PathBuf,
42
43 pub absolute_path: PathBuf,
45
46 pub pack: String,
48
49 pub handler: String,
51
52 pub is_dir: bool,
54
55 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
57 pub options: HashMap<String, String>,
58}
59
60pub fn group_by_handler(matches: &[RuleMatch]) -> HashMap<String, Vec<RuleMatch>> {
64 let mut groups: HashMap<String, Vec<RuleMatch>> = HashMap::new();
65 for m in matches {
66 groups.entry(m.handler.clone()).or_default().push(m.clone());
67 }
68 groups
69}
70
71pub fn handler_execution_order(groups: &HashMap<String, Vec<RuleMatch>>) -> Vec<String> {
77 use crate::handlers::{
78 HandlerCategory, HANDLER_HOMEBREW, HANDLER_INSTALL, HANDLER_PATH, HANDLER_SHELL,
79 HANDLER_SYMLINK,
80 };
81
82 fn category_of(name: &str) -> HandlerCategory {
83 match name {
84 HANDLER_INSTALL | HANDLER_HOMEBREW => HandlerCategory::CodeExecution,
85 HANDLER_SYMLINK | HANDLER_SHELL | HANDLER_PATH => HandlerCategory::Configuration,
86 _ => HandlerCategory::Configuration,
87 }
88 }
89
90 let mut names: Vec<String> = groups.keys().cloned().collect();
91 names.sort_by(|a, b| {
92 let cat_a = category_of(a);
93 let cat_b = category_of(b);
94 match (cat_a, cat_b) {
95 (HandlerCategory::CodeExecution, HandlerCategory::Configuration) => {
96 std::cmp::Ordering::Less
97 }
98 (HandlerCategory::Configuration, HandlerCategory::CodeExecution) => {
99 std::cmp::Ordering::Greater
100 }
101 _ => a.cmp(b),
102 }
103 });
104 names
105}
106
107#[derive(Debug)]
111enum CompiledPattern {
112 Exact(String),
114 Glob(glob::Pattern),
116 Directory(String),
119}
120
121#[derive(Debug)]
123struct CompiledRule {
124 pattern: CompiledPattern,
125 is_exclusion: bool,
126 handler: String,
127 priority: i32,
128 options: HashMap<String, String>,
129}
130
131fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
132 rules
133 .iter()
134 .map(|rule| {
135 let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
136 (rest.to_string(), true)
137 } else {
138 (rule.pattern.clone(), false)
139 };
140
141 let pattern = if raw_pattern.ends_with('/') {
142 let dir_name = raw_pattern.trim_end_matches('/').to_string();
144 CompiledPattern::Directory(dir_name)
145 } else if raw_pattern.contains('*')
146 || raw_pattern.contains('?')
147 || raw_pattern.contains('[')
148 {
149 match glob::Pattern::new(&raw_pattern) {
151 Ok(p) => CompiledPattern::Glob(p),
152 Err(_) => CompiledPattern::Exact(raw_pattern),
153 }
154 } else {
155 CompiledPattern::Exact(raw_pattern)
156 };
157
158 CompiledRule {
159 pattern,
160 is_exclusion,
161 handler: rule.handler.clone(),
162 priority: rule.priority,
163 options: rule.options.clone(),
164 }
165 })
166 .collect()
167}
168
169fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
170 match pattern {
171 CompiledPattern::Exact(name) => filename == name,
172 CompiledPattern::Glob(glob) => glob.matches(filename),
173 CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
174 }
175}
176
177const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
181
182pub struct Scanner<'a> {
184 fs: &'a dyn Fs,
185}
186
187impl<'a> Scanner<'a> {
188 pub fn new(fs: &'a dyn Fs) -> Self {
189 Self { fs }
190 }
191
192 pub fn scan_pack(
200 &self,
201 pack: &Pack,
202 rules: &[Rule],
203 pack_ignore: &[String],
204 ) -> Result<Vec<RuleMatch>> {
205 let compiled = compile_rules(rules);
206 let entries = self.walk_pack(&pack.path, pack_ignore)?;
207 let mut matches = Vec::new();
208
209 for (rel_path, abs_path, is_dir) in entries {
210 let filename = rel_path
211 .file_name()
212 .map(|n| n.to_string_lossy().to_string())
213 .unwrap_or_default();
214
215 if let Some(rule_match) = self.match_file(
216 &compiled, &filename, is_dir, &rel_path, &abs_path, &pack.name,
217 ) {
218 matches.push(rule_match);
219 }
220 }
221
222 matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
223 Ok(matches)
224 }
225
226 fn walk_pack(
228 &self,
229 pack_path: &Path,
230 ignore_patterns: &[String],
231 ) -> Result<Vec<(PathBuf, PathBuf, bool)>> {
232 let mut results = Vec::new();
233 self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
234 Ok(results)
235 }
236
237 fn walk_dir(
238 &self,
239 base: &Path,
240 dir: &Path,
241 ignore_patterns: &[String],
242 results: &mut Vec<(PathBuf, PathBuf, bool)>,
243 ) -> Result<()> {
244 let entries = self.fs.read_dir(dir)?;
245
246 for entry in entries {
247 let name = &entry.name;
248
249 if name.starts_with('.') && name != ".config" {
251 continue;
252 }
253
254 if SPECIAL_FILES.contains(&name.as_str()) {
256 continue;
257 }
258
259 if is_ignored(name, ignore_patterns) {
261 continue;
262 }
263
264 let rel_path = entry
265 .path
266 .strip_prefix(base)
267 .unwrap_or(&entry.path)
268 .to_path_buf();
269
270 if entry.is_dir {
271 results.push((rel_path.clone(), entry.path.clone(), true));
273 self.walk_dir(base, &entry.path, ignore_patterns, results)?;
275 } else {
276 results.push((rel_path, entry.path.clone(), false));
277 }
278 }
279
280 Ok(())
281 }
282
283 fn match_file(
288 &self,
289 compiled: &[CompiledRule],
290 filename: &str,
291 is_dir: bool,
292 rel_path: &Path,
293 abs_path: &Path,
294 pack: &str,
295 ) -> Option<RuleMatch> {
296 for rule in compiled {
298 if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
299 return None;
300 }
301 }
302
303 let mut inclusion_rules: Vec<&CompiledRule> =
306 compiled.iter().filter(|r| !r.is_exclusion).collect();
307 inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
308
309 for rule in inclusion_rules {
310 if matches_entry(&rule.pattern, filename, is_dir) {
311 return Some(RuleMatch {
312 relative_path: rel_path.to_path_buf(),
313 absolute_path: abs_path.to_path_buf(),
314 pack: pack.to_string(),
315 handler: rule.handler.clone(),
316 is_dir,
317 options: rule.options.clone(),
318 });
319 }
320 }
321
322 None
323 }
324}
325
326fn is_ignored(name: &str, patterns: &[String]) -> bool {
327 for pattern in patterns {
328 if let Ok(glob) = glob::Pattern::new(pattern) {
329 if glob.matches(name) {
330 return true;
331 }
332 }
333 if name == pattern {
335 return true;
336 }
337 }
338 false
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::handlers::HandlerConfig;
345 use crate::testing::TempEnvironment;
346
347 fn make_pack(name: &str, path: PathBuf) -> Pack {
348 Pack {
349 name: name.into(),
350 path,
351 config: HandlerConfig::default(),
352 }
353 }
354
355 fn default_rules() -> Vec<Rule> {
356 vec![
357 Rule {
358 pattern: "bin/".into(),
359 handler: "path".into(),
360 priority: 10,
361 options: HashMap::new(),
362 },
363 Rule {
364 pattern: "install.sh".into(),
365 handler: "install".into(),
366 priority: 10,
367 options: HashMap::new(),
368 },
369 Rule {
370 pattern: "aliases.sh".into(),
371 handler: "shell".into(),
372 priority: 10,
373 options: HashMap::new(),
374 },
375 Rule {
376 pattern: "profile.sh".into(),
377 handler: "shell".into(),
378 priority: 10,
379 options: HashMap::new(),
380 },
381 Rule {
382 pattern: "Brewfile".into(),
383 handler: "homebrew".into(),
384 priority: 10,
385 options: HashMap::new(),
386 },
387 Rule {
388 pattern: "*".into(),
389 handler: "symlink".into(),
390 priority: 0,
391 options: HashMap::new(),
392 },
393 ]
394 }
395
396 #[test]
399 fn exact_match() {
400 let compiled = compile_rules(&[Rule {
401 pattern: "install.sh".into(),
402 handler: "install".into(),
403 priority: 0,
404 options: HashMap::new(),
405 }]);
406 assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
407 assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
408 }
409
410 #[test]
411 fn glob_match() {
412 let compiled = compile_rules(&[Rule {
413 pattern: "*.sh".into(),
414 handler: "shell".into(),
415 priority: 0,
416 options: HashMap::new(),
417 }]);
418 assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
419 assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
420 assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
421 }
422
423 #[test]
424 fn directory_match() {
425 let compiled = compile_rules(&[Rule {
426 pattern: "bin/".into(),
427 handler: "path".into(),
428 priority: 0,
429 options: HashMap::new(),
430 }]);
431 assert!(matches_entry(&compiled[0].pattern, "bin", true));
432 assert!(!matches_entry(&compiled[0].pattern, "bin", false));
433 assert!(!matches_entry(&compiled[0].pattern, "lib", true));
434 }
435
436 #[test]
437 fn exclusion_prefix() {
438 let compiled = compile_rules(&[Rule {
439 pattern: "!*.tmp".into(),
440 handler: "exclude".into(),
441 priority: 100,
442 options: HashMap::new(),
443 }]);
444 assert!(compiled[0].is_exclusion);
445 assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
446 }
447
448 #[test]
449 fn catchall_matches_everything() {
450 let compiled = compile_rules(&[Rule {
451 pattern: "*".into(),
452 handler: "symlink".into(),
453 priority: 0,
454 options: HashMap::new(),
455 }]);
456 assert!(matches_entry(&compiled[0].pattern, "anything", false));
457 assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
458 }
459
460 #[test]
463 fn scan_pack_basic() {
464 let env = TempEnvironment::builder()
465 .pack("vim")
466 .file("vimrc", "set nocompatible")
467 .file("gvimrc", "set guifont=Mono")
468 .file("aliases.sh", "alias vi=vim")
469 .file("install.sh", "#!/bin/sh\necho setup")
470 .done()
471 .build();
472
473 let scanner = Scanner::new(env.fs.as_ref());
474 let pack = make_pack("vim", env.dotfiles_root.join("vim"));
475 let rules = default_rules();
476
477 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
478
479 let handler_map: HashMap<String, Vec<String>> = {
480 let mut m: HashMap<String, Vec<String>> = HashMap::new();
481 for rm in &matches {
482 m.entry(rm.handler.clone())
483 .or_default()
484 .push(rm.relative_path.to_string_lossy().to_string());
485 }
486 m
487 };
488
489 assert_eq!(handler_map["install"], vec!["install.sh"]);
490 assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
491 assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
492 assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
493 }
494
495 #[test]
496 fn scan_pack_skips_hidden_files() {
497 let env = TempEnvironment::builder()
498 .pack("test")
499 .file("visible", "yes")
500 .file(".hidden", "no")
501 .done()
502 .build();
503
504 let scanner = Scanner::new(env.fs.as_ref());
505 let pack = make_pack("test", env.dotfiles_root.join("test"));
506 let rules = default_rules();
507
508 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
509 let names: Vec<String> = matches
510 .iter()
511 .map(|m| m.relative_path.to_string_lossy().to_string())
512 .collect();
513
514 assert!(names.contains(&"visible".to_string()));
515 assert!(!names.contains(&".hidden".to_string()));
516 }
517
518 #[test]
519 fn scan_pack_skips_special_files() {
520 let env = TempEnvironment::builder()
521 .pack("test")
522 .file("normal", "yes")
523 .config("[pack]\nignore = []")
524 .done()
525 .build();
526
527 let pack_dir = env.dotfiles_root.join("test");
529 env.fs
530 .write_file(&pack_dir.join(".dodotignore"), b"")
531 .unwrap();
532
533 let scanner = Scanner::new(env.fs.as_ref());
534 let pack = make_pack("test", pack_dir);
535 let rules = default_rules();
536
537 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
538 let names: Vec<String> = matches
539 .iter()
540 .map(|m| m.relative_path.to_string_lossy().to_string())
541 .collect();
542
543 assert!(names.contains(&"normal".to_string()));
544 assert!(!names.contains(&".dodot.toml".to_string()));
545 assert!(!names.contains(&".dodotignore".to_string()));
546 }
547
548 #[test]
549 fn scan_pack_with_ignore_patterns() {
550 let env = TempEnvironment::builder()
551 .pack("test")
552 .file("keep.txt", "yes")
553 .file("skip.bak", "no")
554 .file("other.bak", "no")
555 .done()
556 .build();
557
558 let scanner = Scanner::new(env.fs.as_ref());
559 let pack = make_pack("test", env.dotfiles_root.join("test"));
560 let rules = default_rules();
561
562 let matches = scanner
563 .scan_pack(&pack, &rules, &["*.bak".to_string()])
564 .unwrap();
565 let names: Vec<String> = matches
566 .iter()
567 .map(|m| m.relative_path.to_string_lossy().to_string())
568 .collect();
569
570 assert!(names.contains(&"keep.txt".to_string()));
571 assert!(!names.contains(&"skip.bak".to_string()));
572 assert!(!names.contains(&"other.bak".to_string()));
573 }
574
575 #[test]
576 fn scan_pack_exclusion_rules_override_catchall() {
577 let env = TempEnvironment::builder()
578 .pack("test")
579 .file("good.txt", "yes")
580 .file("bad.tmp", "no")
581 .done()
582 .build();
583
584 let scanner = Scanner::new(env.fs.as_ref());
585 let pack = make_pack("test", env.dotfiles_root.join("test"));
586
587 let rules = vec![
588 Rule {
589 pattern: "!*.tmp".into(),
590 handler: "exclude".into(),
591 priority: 100,
592 options: HashMap::new(),
593 },
594 Rule {
595 pattern: "*".into(),
596 handler: "symlink".into(),
597 priority: 0,
598 options: HashMap::new(),
599 },
600 ];
601
602 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
603 let names: Vec<String> = matches
604 .iter()
605 .map(|m| m.relative_path.to_string_lossy().to_string())
606 .collect();
607
608 assert!(names.contains(&"good.txt".to_string()));
609 assert!(!names.contains(&"bad.tmp".to_string()));
610 }
611
612 #[test]
613 fn scan_pack_priority_ordering() {
614 let env = TempEnvironment::builder()
615 .pack("test")
616 .file("aliases.sh", "# shell")
617 .done()
618 .build();
619
620 let scanner = Scanner::new(env.fs.as_ref());
621 let pack = make_pack("test", env.dotfiles_root.join("test"));
622
623 let rules = vec![
625 Rule {
626 pattern: "*.sh".into(),
627 handler: "generic-shell".into(),
628 priority: 5,
629 options: HashMap::new(),
630 },
631 Rule {
632 pattern: "aliases.sh".into(),
633 handler: "specific-shell".into(),
634 priority: 10,
635 options: HashMap::new(),
636 },
637 Rule {
638 pattern: "*".into(),
639 handler: "symlink".into(),
640 priority: 0,
641 options: HashMap::new(),
642 },
643 ];
644
645 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
646 assert_eq!(matches.len(), 1);
647 assert_eq!(matches[0].handler, "specific-shell");
648 }
649
650 #[test]
651 fn scan_pack_directory_entry() {
652 let env = TempEnvironment::builder()
653 .pack("test")
654 .file("bin/my-script", "#!/bin/sh")
655 .file("normal", "x")
656 .done()
657 .build();
658
659 let scanner = Scanner::new(env.fs.as_ref());
660 let pack = make_pack("test", env.dotfiles_root.join("test"));
661 let rules = default_rules();
662
663 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
664
665 let bin_match = matches
666 .iter()
667 .find(|m| m.relative_path.to_string_lossy() == "bin");
668 assert!(bin_match.is_some(), "bin directory should match");
669 assert_eq!(bin_match.unwrap().handler, "path");
670 assert!(bin_match.unwrap().is_dir);
671 }
672
673 #[test]
674 fn scan_pack_nested_files() {
675 let env = TempEnvironment::builder()
676 .pack("nvim")
677 .file("nvim/init.lua", "require('config')")
678 .file("nvim/lua/plugins.lua", "return {}")
679 .done()
680 .build();
681
682 let scanner = Scanner::new(env.fs.as_ref());
683 let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
684 let rules = default_rules();
685
686 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
687
688 let file_matches: Vec<String> = matches
689 .iter()
690 .filter(|m| !m.is_dir)
691 .map(|m| m.relative_path.to_string_lossy().to_string())
692 .collect();
693
694 assert!(file_matches.contains(&"nvim/init.lua".to_string()));
695 assert!(file_matches.contains(&"nvim/lua/plugins.lua".to_string()));
696 }
697
698 #[test]
701 fn group_by_handler_groups_correctly() {
702 let matches = vec![
703 RuleMatch {
704 relative_path: "vimrc".into(),
705 absolute_path: "/d/vim/vimrc".into(),
706 pack: "vim".into(),
707 handler: "symlink".into(),
708 is_dir: false,
709 options: HashMap::new(),
710 },
711 RuleMatch {
712 relative_path: "aliases.sh".into(),
713 absolute_path: "/d/vim/aliases.sh".into(),
714 pack: "vim".into(),
715 handler: "shell".into(),
716 is_dir: false,
717 options: HashMap::new(),
718 },
719 RuleMatch {
720 relative_path: "gvimrc".into(),
721 absolute_path: "/d/vim/gvimrc".into(),
722 pack: "vim".into(),
723 handler: "symlink".into(),
724 is_dir: false,
725 options: HashMap::new(),
726 },
727 ];
728
729 let groups = group_by_handler(&matches);
730 assert_eq!(groups.len(), 2);
731 assert_eq!(groups["symlink"].len(), 2);
732 assert_eq!(groups["shell"].len(), 1);
733 }
734
735 #[test]
736 fn handler_execution_order_code_first() {
737 let mut groups = HashMap::new();
738 groups.insert("symlink".into(), vec![]);
739 groups.insert("install".into(), vec![]);
740 groups.insert("shell".into(), vec![]);
741 groups.insert("homebrew".into(), vec![]);
742 groups.insert("path".into(), vec![]);
743
744 let order = handler_execution_order(&groups);
745
746 let install_pos = order.iter().position(|n| n == "install").unwrap();
747 let homebrew_pos = order.iter().position(|n| n == "homebrew").unwrap();
748 let symlink_pos = order.iter().position(|n| n == "symlink").unwrap();
749 let shell_pos = order.iter().position(|n| n == "shell").unwrap();
750 let path_pos = order.iter().position(|n| n == "path").unwrap();
751
752 assert!(install_pos < symlink_pos);
753 assert!(homebrew_pos < shell_pos);
754 assert!(homebrew_pos < path_pos);
755 assert!(homebrew_pos < install_pos);
756 assert!(path_pos < shell_pos);
757 assert!(shell_pos < symlink_pos);
758 }
759
760 #[test]
761 fn rule_match_serializes() {
762 let m = RuleMatch {
763 relative_path: "vimrc".into(),
764 absolute_path: "/dots/vim/vimrc".into(),
765 pack: "vim".into(),
766 handler: "symlink".into(),
767 is_dir: false,
768 options: HashMap::new(),
769 };
770 let json = serde_json::to_string(&m).unwrap();
771 assert!(json.contains("vimrc"));
772 assert!(json.contains("symlink"));
773 assert!(!json.contains("options"));
774 }
775}