Skip to main content

dodot_lib/rules/
mod.rs

1//! Rule types, pattern matching, and file scanning.
2//!
3//! A rule pairs a file pattern with a handler name. The [`Scanner`]
4//! walks a pack directory and matches each file against the rule set.
5//! Exclusion rules are checked first, then inclusion rules by priority
6//! (descending). The first match wins.
7
8use 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// ── Types ───────────────────────────────────────────────────────
18
19/// A rule mapping a file pattern to a handler.
20#[derive(Debug, Clone, Serialize)]
21pub struct Rule {
22    /// Pattern to match against (e.g. `"install.sh"`, `"*.sh"`, `"bin/"`).
23    /// Prefixed with `!` for exclusion rules (e.g. `"!*.tmp"`).
24    pub pattern: String,
25
26    /// Handler to use for matching files (e.g. `"symlink"`, `"install"`).
27    pub handler: String,
28
29    /// Higher priority rules are checked first. Default is 0.
30    pub priority: i32,
31
32    /// Handler-specific options passed through from config.
33    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34    pub options: HashMap<String, String>,
35}
36
37/// A raw file entry discovered during directory walking (before rule matching).
38#[derive(Debug, Clone)]
39pub struct PackEntry {
40    /// Path relative to the pack root (e.g. `"vimrc"`, `"nvim/init.lua"`).
41    pub relative_path: PathBuf,
42    /// Absolute path to the file.
43    pub absolute_path: PathBuf,
44    /// Whether this entry is a directory.
45    pub is_dir: bool,
46}
47
48/// A file that matched a rule during pack scanning.
49#[derive(Debug, Clone, Serialize)]
50pub struct RuleMatch {
51    /// Path relative to the pack root (e.g. `"vimrc"`, `"nvim/init.lua"`).
52    pub relative_path: PathBuf,
53
54    /// Absolute path to the file.
55    pub absolute_path: PathBuf,
56
57    /// Name of the pack this file belongs to.
58    pub pack: String,
59
60    /// Name of the handler that should process this file.
61    pub handler: String,
62
63    /// Whether this entry is a directory.
64    pub is_dir: bool,
65
66    /// Handler-specific options from the matched rule.
67    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
68    pub options: HashMap<String, String>,
69
70    /// If this file was produced by a preprocessor, the original source path.
71    /// `None` for regular (non-preprocessed) files.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub preprocessor_source: Option<PathBuf>,
74}
75
76// ── Grouping helpers ────────────────────────────────────────────
77
78/// Groups rule matches by handler name.
79pub 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
87/// Returns handler names in execution order.
88///
89/// Code execution handlers (install, homebrew) run **first** so that
90/// provisioning happens before config linking. Within each category,
91/// handlers are sorted alphabetically for determinism.
92pub fn handler_execution_order(groups: &HashMap<String, Vec<RuleMatch>>) -> Vec<String> {
93    use crate::handlers::{
94        HandlerCategory, HANDLER_HOMEBREW, HANDLER_INSTALL, HANDLER_PATH, HANDLER_SHELL,
95        HANDLER_SYMLINK,
96    };
97
98    fn category_of(name: &str) -> HandlerCategory {
99        match name {
100            HANDLER_INSTALL | HANDLER_HOMEBREW => HandlerCategory::CodeExecution,
101            HANDLER_SYMLINK | HANDLER_SHELL | HANDLER_PATH => HandlerCategory::Configuration,
102            _ => HandlerCategory::Configuration,
103        }
104    }
105
106    let mut names: Vec<String> = groups.keys().cloned().collect();
107    names.sort_by(|a, b| {
108        let cat_a = category_of(a);
109        let cat_b = category_of(b);
110        match (cat_a, cat_b) {
111            (HandlerCategory::CodeExecution, HandlerCategory::Configuration) => {
112                std::cmp::Ordering::Less
113            }
114            (HandlerCategory::Configuration, HandlerCategory::CodeExecution) => {
115                std::cmp::Ordering::Greater
116            }
117            _ => a.cmp(b),
118        }
119    });
120    names
121}
122
123// ── Pattern matching ────────────────────────────────────────────
124
125/// A compiled pattern that can match filenames and directory names.
126#[derive(Debug)]
127enum CompiledPattern {
128    /// Exact filename match (e.g. `"install.sh"`).
129    Exact(String),
130    /// Glob match (e.g. `"*.sh"`).
131    Glob(glob::Pattern),
132    /// Directory match (e.g. `"bin/"` or `"bin"`). Matches directories
133    /// whose name equals the given string.
134    Directory(String),
135}
136
137/// A rule compiled for efficient matching.
138#[derive(Debug)]
139struct CompiledRule {
140    pattern: CompiledPattern,
141    is_exclusion: bool,
142    handler: String,
143    priority: i32,
144    options: HashMap<String, String>,
145}
146
147fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
148    rules
149        .iter()
150        .map(|rule| {
151            let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
152                (rest.to_string(), true)
153            } else {
154                (rule.pattern.clone(), false)
155            };
156
157            let pattern = if raw_pattern.ends_with('/') {
158                // Directory pattern
159                let dir_name = raw_pattern.trim_end_matches('/').to_string();
160                CompiledPattern::Directory(dir_name)
161            } else if raw_pattern.contains('*')
162                || raw_pattern.contains('?')
163                || raw_pattern.contains('[')
164            {
165                // Glob pattern
166                match glob::Pattern::new(&raw_pattern) {
167                    Ok(p) => CompiledPattern::Glob(p),
168                    Err(_) => CompiledPattern::Exact(raw_pattern),
169                }
170            } else {
171                CompiledPattern::Exact(raw_pattern)
172            };
173
174            CompiledRule {
175                pattern,
176                is_exclusion,
177                handler: rule.handler.clone(),
178                priority: rule.priority,
179                options: rule.options.clone(),
180            }
181        })
182        .collect()
183}
184
185fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
186    match pattern {
187        CompiledPattern::Exact(name) => filename == name,
188        CompiledPattern::Glob(glob) => glob.matches(filename),
189        CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
190    }
191}
192
193// ── Scanner ─────────────────────────────────────────────────────
194
195/// Files that are always skipped during scanning.
196pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
197
198/// Should this entry name be skipped at scan or handler-recursion time?
199///
200/// Combines the three always-on filters: dodot's own files
201/// (`SPECIAL_FILES`) and the pack's `ignore` glob patterns. Hidden
202/// files are NOT filtered here — the caller decides whether to skip
203/// dotfiles (the scanner does, for the top-level walk; the symlink
204/// handler's per-file fallback does not, since the user opted in).
205pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
206    SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
207}
208
209/// Scans pack directories and matches files against rules.
210pub struct Scanner<'a> {
211    fs: &'a dyn Fs,
212}
213
214impl<'a> Scanner<'a> {
215    pub fn new(fs: &'a dyn Fs) -> Self {
216        Self { fs }
217    }
218
219    /// Scan a pack directory and return all rule matches.
220    ///
221    /// Walks the pack directory (non-recursively for top-level, but
222    /// directories matched by the directory pattern are included as
223    /// single entries). Skips hidden files (except `.config`), special
224    /// files (`.dodot.toml`, `.dodotignore`), and files matching
225    /// pack-level ignore patterns.
226    ///
227    /// This is a convenience wrapper over [`walk_pack`] + [`match_entries`].
228    pub fn scan_pack(
229        &self,
230        pack: &Pack,
231        rules: &[Rule],
232        pack_ignore: &[String],
233    ) -> Result<Vec<RuleMatch>> {
234        let entries = self.walk_pack(&pack.path, pack_ignore)?;
235        Ok(self.match_entries(&entries, rules, &pack.name))
236    }
237
238    /// Walk a pack directory and return raw file entries.
239    ///
240    /// Skips hidden files (except `.config`), special files
241    /// (`.dodot.toml`, `.dodotignore`), and files matching
242    /// pack-level ignore patterns.
243    /// Walk the pack's top-level children only.
244    ///
245    /// Returns depth-1 entries (files and directories directly under
246    /// the pack root). Nested files/dirs are **not** returned — handlers
247    /// that receive a directory entry decide internally whether and how
248    /// to recurse (e.g. symlink falls back to per-file mode when
249    /// `protected_paths` or `targets` reach inside the dir).
250    ///
251    /// Preprocessing is the one exception: it still needs to see nested
252    /// files to discover templates (`*.tmpl`) and the like. Use
253    /// [`Scanner::walk_pack_recursive`] for that use case.
254    pub fn walk_pack(
255        &self,
256        pack_path: &Path,
257        ignore_patterns: &[String],
258    ) -> Result<Vec<PackEntry>> {
259        let mut results = Vec::new();
260        self.list_top_level(pack_path, ignore_patterns, &mut results)?;
261        Ok(results)
262    }
263
264    /// Walk the pack recursively. Only used by the preprocessing pipeline.
265    pub fn walk_pack_recursive(
266        &self,
267        pack_path: &Path,
268        ignore_patterns: &[String],
269    ) -> Result<Vec<PackEntry>> {
270        let mut results = Vec::new();
271        self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
272        Ok(results)
273    }
274
275    /// Match a list of entries against rules, returning rule matches.
276    ///
277    /// This is the second half of the scan pipeline: given raw entries
278    /// (from [`walk_pack`] or from preprocessing), match each against
279    /// the rule set to determine which handler processes it.
280    pub fn match_entries(
281        &self,
282        entries: &[PackEntry],
283        rules: &[Rule],
284        pack_name: &str,
285    ) -> Vec<RuleMatch> {
286        let compiled = compile_rules(rules);
287        let mut matches = Vec::new();
288
289        for entry in entries {
290            let filename = entry
291                .relative_path
292                .file_name()
293                .map(|n| n.to_string_lossy().to_string())
294                .unwrap_or_default();
295
296            if let Some(rule_match) = match_file(
297                &compiled,
298                &filename,
299                entry.is_dir,
300                &entry.relative_path,
301                &entry.absolute_path,
302                pack_name,
303            ) {
304                matches.push(rule_match);
305            }
306        }
307
308        matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
309        matches
310    }
311
312    /// Enumerate the direct children of `pack_path`, skipping hidden,
313    /// special, and ignored entries. No recursion.
314    fn list_top_level(
315        &self,
316        pack_path: &Path,
317        ignore_patterns: &[String],
318        results: &mut Vec<PackEntry>,
319    ) -> Result<()> {
320        let entries = self.fs.read_dir(pack_path)?;
321
322        for entry in entries {
323            let name = &entry.name;
324
325            if name.starts_with('.') && name != ".config" {
326                continue;
327            }
328            if SPECIAL_FILES.contains(&name.as_str()) {
329                continue;
330            }
331            if is_ignored(name, ignore_patterns) {
332                continue;
333            }
334
335            let rel_path = entry
336                .path
337                .strip_prefix(pack_path)
338                .unwrap_or(&entry.path)
339                .to_path_buf();
340
341            results.push(PackEntry {
342                relative_path: rel_path,
343                absolute_path: entry.path.clone(),
344                is_dir: entry.is_dir,
345            });
346        }
347
348        Ok(())
349    }
350
351    fn walk_dir(
352        &self,
353        base: &Path,
354        dir: &Path,
355        ignore_patterns: &[String],
356        results: &mut Vec<PackEntry>,
357    ) -> Result<()> {
358        let entries = self.fs.read_dir(dir)?;
359
360        for entry in entries {
361            let name = &entry.name;
362
363            // Skip hidden files/dirs (except .config)
364            if name.starts_with('.') && name != ".config" {
365                continue;
366            }
367
368            // Skip special files
369            if SPECIAL_FILES.contains(&name.as_str()) {
370                continue;
371            }
372
373            // Skip ignored patterns
374            if is_ignored(name, ignore_patterns) {
375                continue;
376            }
377
378            let rel_path = entry
379                .path
380                .strip_prefix(base)
381                .unwrap_or(&entry.path)
382                .to_path_buf();
383
384            if entry.is_dir {
385                // Add directory itself as a candidate (for path handler)
386                results.push(PackEntry {
387                    relative_path: rel_path.clone(),
388                    absolute_path: entry.path.clone(),
389                    is_dir: true,
390                });
391                // Recurse into subdirectories
392                self.walk_dir(base, &entry.path, ignore_patterns, results)?;
393            } else {
394                results.push(PackEntry {
395                    relative_path: rel_path,
396                    absolute_path: entry.path.clone(),
397                    is_dir: false,
398                });
399            }
400        }
401
402        Ok(())
403    }
404}
405
406/// Match a single file against the compiled rules.
407///
408/// 1. Check exclusion rules first — if any match, file is skipped.
409/// 2. Check inclusion rules by priority (descending), first match wins.
410fn match_file(
411    compiled: &[CompiledRule],
412    filename: &str,
413    is_dir: bool,
414    rel_path: &Path,
415    abs_path: &Path,
416    pack: &str,
417) -> Option<RuleMatch> {
418    // Phase 1: check exclusions
419    for rule in compiled {
420        if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
421            return None;
422        }
423    }
424
425    // Phase 2: find first matching inclusion rule (sorted by priority desc)
426    // We sort a copy so we don't modify the original
427    let mut inclusion_rules: Vec<&CompiledRule> =
428        compiled.iter().filter(|r| !r.is_exclusion).collect();
429    inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
430
431    for rule in inclusion_rules {
432        if matches_entry(&rule.pattern, filename, is_dir) {
433            return Some(RuleMatch {
434                relative_path: rel_path.to_path_buf(),
435                absolute_path: abs_path.to_path_buf(),
436                pack: pack.to_string(),
437                handler: rule.handler.clone(),
438                is_dir,
439                options: rule.options.clone(),
440                preprocessor_source: None,
441            });
442        }
443    }
444
445    None
446}
447
448fn is_ignored(name: &str, patterns: &[String]) -> bool {
449    for pattern in patterns {
450        if let Ok(glob) = glob::Pattern::new(pattern) {
451            if glob.matches(name) {
452                return true;
453            }
454        }
455        // Exact match fallback
456        if name == pattern {
457            return true;
458        }
459    }
460    false
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::handlers::HandlerConfig;
467    use crate::testing::TempEnvironment;
468
469    fn make_pack(name: &str, path: PathBuf) -> Pack {
470        Pack {
471            name: name.into(),
472            path,
473            config: HandlerConfig::default(),
474        }
475    }
476
477    fn default_rules() -> Vec<Rule> {
478        vec![
479            Rule {
480                pattern: "bin/".into(),
481                handler: "path".into(),
482                priority: 10,
483                options: HashMap::new(),
484            },
485            Rule {
486                pattern: "install.sh".into(),
487                handler: "install".into(),
488                priority: 10,
489                options: HashMap::new(),
490            },
491            Rule {
492                pattern: "aliases.sh".into(),
493                handler: "shell".into(),
494                priority: 10,
495                options: HashMap::new(),
496            },
497            Rule {
498                pattern: "profile.sh".into(),
499                handler: "shell".into(),
500                priority: 10,
501                options: HashMap::new(),
502            },
503            Rule {
504                pattern: "Brewfile".into(),
505                handler: "homebrew".into(),
506                priority: 10,
507                options: HashMap::new(),
508            },
509            Rule {
510                pattern: "*".into(),
511                handler: "symlink".into(),
512                priority: 0,
513                options: HashMap::new(),
514            },
515        ]
516    }
517
518    // ── Pattern matching unit tests ─────────────────────────────
519
520    #[test]
521    fn exact_match() {
522        let compiled = compile_rules(&[Rule {
523            pattern: "install.sh".into(),
524            handler: "install".into(),
525            priority: 0,
526            options: HashMap::new(),
527        }]);
528        assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
529        assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
530    }
531
532    #[test]
533    fn glob_match() {
534        let compiled = compile_rules(&[Rule {
535            pattern: "*.sh".into(),
536            handler: "shell".into(),
537            priority: 0,
538            options: HashMap::new(),
539        }]);
540        assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
541        assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
542        assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
543    }
544
545    #[test]
546    fn directory_match() {
547        let compiled = compile_rules(&[Rule {
548            pattern: "bin/".into(),
549            handler: "path".into(),
550            priority: 0,
551            options: HashMap::new(),
552        }]);
553        assert!(matches_entry(&compiled[0].pattern, "bin", true));
554        assert!(!matches_entry(&compiled[0].pattern, "bin", false));
555        assert!(!matches_entry(&compiled[0].pattern, "lib", true));
556    }
557
558    #[test]
559    fn exclusion_prefix() {
560        let compiled = compile_rules(&[Rule {
561            pattern: "!*.tmp".into(),
562            handler: "exclude".into(),
563            priority: 100,
564            options: HashMap::new(),
565        }]);
566        assert!(compiled[0].is_exclusion);
567        assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
568    }
569
570    #[test]
571    fn catchall_matches_everything() {
572        let compiled = compile_rules(&[Rule {
573            pattern: "*".into(),
574            handler: "symlink".into(),
575            priority: 0,
576            options: HashMap::new(),
577        }]);
578        assert!(matches_entry(&compiled[0].pattern, "anything", false));
579        assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
580    }
581
582    // ── Scanner integration tests ───────────────────────────────
583
584    #[test]
585    fn scan_pack_basic() {
586        let env = TempEnvironment::builder()
587            .pack("vim")
588            .file("vimrc", "set nocompatible")
589            .file("gvimrc", "set guifont=Mono")
590            .file("aliases.sh", "alias vi=vim")
591            .file("install.sh", "#!/bin/sh\necho setup")
592            .done()
593            .build();
594
595        let scanner = Scanner::new(env.fs.as_ref());
596        let pack = make_pack("vim", env.dotfiles_root.join("vim"));
597        let rules = default_rules();
598
599        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
600
601        let handler_map: HashMap<String, Vec<String>> = {
602            let mut m: HashMap<String, Vec<String>> = HashMap::new();
603            for rm in &matches {
604                m.entry(rm.handler.clone())
605                    .or_default()
606                    .push(rm.relative_path.to_string_lossy().to_string());
607            }
608            m
609        };
610
611        assert_eq!(handler_map["install"], vec!["install.sh"]);
612        assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
613        assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
614        assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
615    }
616
617    #[test]
618    fn scan_pack_skips_hidden_files() {
619        let env = TempEnvironment::builder()
620            .pack("test")
621            .file("visible", "yes")
622            .file(".hidden", "no")
623            .done()
624            .build();
625
626        let scanner = Scanner::new(env.fs.as_ref());
627        let pack = make_pack("test", env.dotfiles_root.join("test"));
628        let rules = default_rules();
629
630        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
631        let names: Vec<String> = matches
632            .iter()
633            .map(|m| m.relative_path.to_string_lossy().to_string())
634            .collect();
635
636        assert!(names.contains(&"visible".to_string()));
637        assert!(!names.contains(&".hidden".to_string()));
638    }
639
640    #[test]
641    fn scan_pack_skips_special_files() {
642        let env = TempEnvironment::builder()
643            .pack("test")
644            .file("normal", "yes")
645            .config("[pack]\nignore = []")
646            .done()
647            .build();
648
649        // Also manually create .dodotignore (even though it shouldn't be scanned)
650        let pack_dir = env.dotfiles_root.join("test");
651        env.fs
652            .write_file(&pack_dir.join(".dodotignore"), b"")
653            .unwrap();
654
655        let scanner = Scanner::new(env.fs.as_ref());
656        let pack = make_pack("test", pack_dir);
657        let rules = default_rules();
658
659        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
660        let names: Vec<String> = matches
661            .iter()
662            .map(|m| m.relative_path.to_string_lossy().to_string())
663            .collect();
664
665        assert!(names.contains(&"normal".to_string()));
666        assert!(!names.contains(&".dodot.toml".to_string()));
667        assert!(!names.contains(&".dodotignore".to_string()));
668    }
669
670    #[test]
671    fn scan_pack_with_ignore_patterns() {
672        let env = TempEnvironment::builder()
673            .pack("test")
674            .file("keep.txt", "yes")
675            .file("skip.bak", "no")
676            .file("other.bak", "no")
677            .done()
678            .build();
679
680        let scanner = Scanner::new(env.fs.as_ref());
681        let pack = make_pack("test", env.dotfiles_root.join("test"));
682        let rules = default_rules();
683
684        let matches = scanner
685            .scan_pack(&pack, &rules, &["*.bak".to_string()])
686            .unwrap();
687        let names: Vec<String> = matches
688            .iter()
689            .map(|m| m.relative_path.to_string_lossy().to_string())
690            .collect();
691
692        assert!(names.contains(&"keep.txt".to_string()));
693        assert!(!names.contains(&"skip.bak".to_string()));
694        assert!(!names.contains(&"other.bak".to_string()));
695    }
696
697    #[test]
698    fn scan_pack_exclusion_rules_override_catchall() {
699        let env = TempEnvironment::builder()
700            .pack("test")
701            .file("good.txt", "yes")
702            .file("bad.tmp", "no")
703            .done()
704            .build();
705
706        let scanner = Scanner::new(env.fs.as_ref());
707        let pack = make_pack("test", env.dotfiles_root.join("test"));
708
709        let rules = vec![
710            Rule {
711                pattern: "!*.tmp".into(),
712                handler: "exclude".into(),
713                priority: 100,
714                options: HashMap::new(),
715            },
716            Rule {
717                pattern: "*".into(),
718                handler: "symlink".into(),
719                priority: 0,
720                options: HashMap::new(),
721            },
722        ];
723
724        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
725        let names: Vec<String> = matches
726            .iter()
727            .map(|m| m.relative_path.to_string_lossy().to_string())
728            .collect();
729
730        assert!(names.contains(&"good.txt".to_string()));
731        assert!(!names.contains(&"bad.tmp".to_string()));
732    }
733
734    #[test]
735    fn scan_pack_priority_ordering() {
736        let env = TempEnvironment::builder()
737            .pack("test")
738            .file("aliases.sh", "# shell")
739            .done()
740            .build();
741
742        let scanner = Scanner::new(env.fs.as_ref());
743        let pack = make_pack("test", env.dotfiles_root.join("test"));
744
745        // Both *.sh and aliases.sh match — higher priority should win
746        let rules = vec![
747            Rule {
748                pattern: "*.sh".into(),
749                handler: "generic-shell".into(),
750                priority: 5,
751                options: HashMap::new(),
752            },
753            Rule {
754                pattern: "aliases.sh".into(),
755                handler: "specific-shell".into(),
756                priority: 10,
757                options: HashMap::new(),
758            },
759            Rule {
760                pattern: "*".into(),
761                handler: "symlink".into(),
762                priority: 0,
763                options: HashMap::new(),
764            },
765        ];
766
767        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
768        assert_eq!(matches.len(), 1);
769        assert_eq!(matches[0].handler, "specific-shell");
770    }
771
772    #[test]
773    fn scan_pack_directory_entry() {
774        let env = TempEnvironment::builder()
775            .pack("test")
776            .file("bin/my-script", "#!/bin/sh")
777            .file("normal", "x")
778            .done()
779            .build();
780
781        let scanner = Scanner::new(env.fs.as_ref());
782        let pack = make_pack("test", env.dotfiles_root.join("test"));
783        let rules = default_rules();
784
785        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
786
787        let bin_match = matches
788            .iter()
789            .find(|m| m.relative_path.to_string_lossy() == "bin");
790        assert!(bin_match.is_some(), "bin directory should match");
791        assert_eq!(bin_match.unwrap().handler, "path");
792        assert!(bin_match.unwrap().is_dir);
793    }
794
795    #[test]
796    fn nested_install_sh_is_not_matched_by_install_rule() {
797        // A file named install.sh that lives deep inside a directory
798        // must NOT activate the install handler. Only a top-level
799        // install.sh triggers it.
800        let env = TempEnvironment::builder()
801            .pack("sneaky")
802            .file("config/install.sh", "echo boom")
803            .done()
804            .build();
805
806        let scanner = Scanner::new(env.fs.as_ref());
807        let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
808        let rules = default_rules();
809
810        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
811
812        assert!(
813            !matches.iter().any(|m| m.handler == "install"),
814            "nested install.sh should not route to install handler: {matches:?}"
815        );
816    }
817
818    #[test]
819    fn scan_pack_returns_only_top_level_entries() {
820        // Under the top-level-only scanner, nested files are not surfaced
821        // as individual matches. The containing dir is the matched entry;
822        // handlers (symlink wholesale, path, …) decide how to recurse.
823        let env = TempEnvironment::builder()
824            .pack("nvim")
825            .file("nvim/init.lua", "require('config')")
826            .file("nvim/lua/plugins.lua", "return {}")
827            .done()
828            .build();
829
830        let scanner = Scanner::new(env.fs.as_ref());
831        let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
832        let rules = default_rules();
833
834        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
835
836        let relpaths: Vec<String> = matches
837            .iter()
838            .map(|m| m.relative_path.to_string_lossy().to_string())
839            .collect();
840
841        assert!(
842            relpaths.iter().any(|p| p == "nvim"),
843            "top-level nvim dir should match: {relpaths:?}"
844        );
845        assert!(
846            !relpaths.iter().any(|p| p.contains('/')),
847            "no nested paths expected: {relpaths:?}"
848        );
849    }
850
851    // ── Grouping tests (from PR 5, kept) ────────────────────────
852
853    #[test]
854    fn group_by_handler_groups_correctly() {
855        let matches = vec![
856            RuleMatch {
857                relative_path: "vimrc".into(),
858                absolute_path: "/d/vim/vimrc".into(),
859                pack: "vim".into(),
860                handler: "symlink".into(),
861                is_dir: false,
862                options: HashMap::new(),
863                preprocessor_source: None,
864            },
865            RuleMatch {
866                relative_path: "aliases.sh".into(),
867                absolute_path: "/d/vim/aliases.sh".into(),
868                pack: "vim".into(),
869                handler: "shell".into(),
870                is_dir: false,
871                options: HashMap::new(),
872                preprocessor_source: None,
873            },
874            RuleMatch {
875                relative_path: "gvimrc".into(),
876                absolute_path: "/d/vim/gvimrc".into(),
877                pack: "vim".into(),
878                handler: "symlink".into(),
879                is_dir: false,
880                options: HashMap::new(),
881                preprocessor_source: None,
882            },
883        ];
884
885        let groups = group_by_handler(&matches);
886        assert_eq!(groups.len(), 2);
887        assert_eq!(groups["symlink"].len(), 2);
888        assert_eq!(groups["shell"].len(), 1);
889    }
890
891    #[test]
892    fn handler_execution_order_code_first() {
893        let mut groups = HashMap::new();
894        groups.insert("symlink".into(), vec![]);
895        groups.insert("install".into(), vec![]);
896        groups.insert("shell".into(), vec![]);
897        groups.insert("homebrew".into(), vec![]);
898        groups.insert("path".into(), vec![]);
899
900        let order = handler_execution_order(&groups);
901
902        let install_pos = order.iter().position(|n| n == "install").unwrap();
903        let homebrew_pos = order.iter().position(|n| n == "homebrew").unwrap();
904        let symlink_pos = order.iter().position(|n| n == "symlink").unwrap();
905        let shell_pos = order.iter().position(|n| n == "shell").unwrap();
906        let path_pos = order.iter().position(|n| n == "path").unwrap();
907
908        assert!(install_pos < symlink_pos);
909        assert!(homebrew_pos < shell_pos);
910        assert!(homebrew_pos < path_pos);
911        assert!(homebrew_pos < install_pos);
912        assert!(path_pos < shell_pos);
913        assert!(shell_pos < symlink_pos);
914    }
915
916    #[test]
917    fn rule_match_serializes() {
918        let m = RuleMatch {
919            relative_path: "vimrc".into(),
920            absolute_path: "/dots/vim/vimrc".into(),
921            pack: "vim".into(),
922            handler: "symlink".into(),
923            is_dir: false,
924            options: HashMap::new(),
925            preprocessor_source: None,
926        };
927        let json = serde_json::to_string(&m).unwrap();
928        assert!(json.contains("vimrc"));
929        assert!(json.contains("symlink"));
930        assert!(!json.contains("options"));
931    }
932}