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/// Order is driven by each handler's [`ExecutionPhase`]
90/// (see [`crate::handlers::ExecutionPhase`] for the full phase list and
91/// why each slot is where it is). The phase enum's declaration order
92/// *is* the execution order — `Provision` → `Setup` → `PathExport` →
93/// `ShellInit` → `Link`.
94///
95/// Handler names not present in the registry are placed last in
96/// alphabetical order (they get ignored by the pipeline anyway).
97///
98/// [`ExecutionPhase`]: crate::handlers::ExecutionPhase
99pub 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// ── Pattern matching ────────────────────────────────────────────
118
119/// A compiled pattern that can match filenames and directory names.
120#[derive(Debug)]
121enum CompiledPattern {
122    /// Exact filename match (e.g. `"install.sh"`).
123    Exact(String),
124    /// Glob match (e.g. `"*.sh"`).
125    Glob(glob::Pattern),
126    /// Directory match (e.g. `"bin/"` or `"bin"`). Matches directories
127    /// whose name equals the given string.
128    Directory(String),
129}
130
131/// A rule compiled for efficient matching.
132#[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                // Directory pattern
153                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                // Glob pattern
160                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
187// ── Scanner ─────────────────────────────────────────────────────
188
189/// Files that are always skipped during scanning.
190pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
191
192/// Should this entry name be skipped at scan or handler-recursion time?
193///
194/// Combines the three always-on filters: dodot's own files
195/// (`SPECIAL_FILES`) and the pack's `ignore` glob patterns. Hidden
196/// files are NOT filtered here — the caller decides whether to skip
197/// dotfiles (the scanner does, for the top-level walk; the symlink
198/// handler's per-file fallback does not, since the user opted in).
199pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
200    SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
201}
202
203/// Scans pack directories and matches files against rules.
204pub 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    /// Scan a pack directory and return all rule matches.
214    ///
215    /// Walks the pack directory (non-recursively for top-level, but
216    /// directories matched by the directory pattern are included as
217    /// single entries). Skips hidden files (except `.config`), special
218    /// files (`.dodot.toml`, `.dodotignore`), and files matching
219    /// pack-level ignore patterns.
220    ///
221    /// This is a convenience wrapper over [`walk_pack`] + [`match_entries`].
222    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    /// Walk a pack directory and return raw file entries.
233    ///
234    /// Skips hidden files (except `.config`), special files
235    /// (`.dodot.toml`, `.dodotignore`), and files matching
236    /// pack-level ignore patterns.
237    /// Walk the pack's top-level children only.
238    ///
239    /// Returns depth-1 entries (files and directories directly under
240    /// the pack root). Nested files/dirs are **not** returned — handlers
241    /// that receive a directory entry decide internally whether and how
242    /// to recurse (e.g. symlink falls back to per-file mode when
243    /// `protected_paths` or `targets` reach inside the dir).
244    ///
245    /// Preprocessing is the one exception: it still needs to see nested
246    /// files to discover templates (`*.tmpl`) and the like. Use
247    /// [`Scanner::walk_pack_recursive`] for that use case.
248    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    /// Walk the pack recursively. Only used by the preprocessing pipeline.
259    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    /// Match a list of entries against rules, returning rule matches.
270    ///
271    /// This is the second half of the scan pipeline: given raw entries
272    /// (from [`walk_pack`] or from preprocessing), match each against
273    /// the rule set to determine which handler processes it.
274    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    /// Enumerate the direct children of `pack_path`, skipping hidden,
307    /// special, and ignored entries. No recursion.
308    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            // Skip hidden files/dirs (except .config)
358            if name.starts_with('.') && name != ".config" {
359                continue;
360            }
361
362            // Skip special files
363            if SPECIAL_FILES.contains(&name.as_str()) {
364                continue;
365            }
366
367            // Skip ignored patterns
368            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                // Add directory itself as a candidate (for path handler)
380                results.push(PackEntry {
381                    relative_path: rel_path.clone(),
382                    absolute_path: entry.path.clone(),
383                    is_dir: true,
384                });
385                // Recurse into subdirectories
386                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
400/// Match a single file against the compiled rules.
401///
402/// 1. Check exclusion rules first — if any match, file is skipped.
403/// 2. Check inclusion rules by priority (descending), first match wins.
404fn 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    // Phase 1: check exclusions
413    for rule in compiled {
414        if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
415            return None;
416        }
417    }
418
419    // Phase 2: find first matching inclusion rule (sorted by priority desc)
420    // We sort a copy so we don't modify the original
421    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        // Exact match fallback
450        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 {
465            name: name.into(),
466            path,
467            config: HandlerConfig::default(),
468        }
469    }
470
471    fn default_rules() -> Vec<Rule> {
472        vec![
473            Rule {
474                pattern: "bin/".into(),
475                handler: "path".into(),
476                priority: 10,
477                options: HashMap::new(),
478            },
479            Rule {
480                pattern: "install.sh".into(),
481                handler: "install".into(),
482                priority: 10,
483                options: HashMap::new(),
484            },
485            Rule {
486                pattern: "aliases.sh".into(),
487                handler: "shell".into(),
488                priority: 10,
489                options: HashMap::new(),
490            },
491            Rule {
492                pattern: "profile.sh".into(),
493                handler: "shell".into(),
494                priority: 10,
495                options: HashMap::new(),
496            },
497            Rule {
498                pattern: "Brewfile".into(),
499                handler: "homebrew".into(),
500                priority: 10,
501                options: HashMap::new(),
502            },
503            Rule {
504                pattern: "*".into(),
505                handler: "symlink".into(),
506                priority: 0,
507                options: HashMap::new(),
508            },
509        ]
510    }
511
512    // ── Pattern matching unit tests ─────────────────────────────
513
514    #[test]
515    fn exact_match() {
516        let compiled = compile_rules(&[Rule {
517            pattern: "install.sh".into(),
518            handler: "install".into(),
519            priority: 0,
520            options: HashMap::new(),
521        }]);
522        assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
523        assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
524    }
525
526    #[test]
527    fn glob_match() {
528        let compiled = compile_rules(&[Rule {
529            pattern: "*.sh".into(),
530            handler: "shell".into(),
531            priority: 0,
532            options: HashMap::new(),
533        }]);
534        assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
535        assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
536        assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
537    }
538
539    #[test]
540    fn directory_match() {
541        let compiled = compile_rules(&[Rule {
542            pattern: "bin/".into(),
543            handler: "path".into(),
544            priority: 0,
545            options: HashMap::new(),
546        }]);
547        assert!(matches_entry(&compiled[0].pattern, "bin", true));
548        assert!(!matches_entry(&compiled[0].pattern, "bin", false));
549        assert!(!matches_entry(&compiled[0].pattern, "lib", true));
550    }
551
552    #[test]
553    fn exclusion_prefix() {
554        let compiled = compile_rules(&[Rule {
555            pattern: "!*.tmp".into(),
556            handler: "exclude".into(),
557            priority: 100,
558            options: HashMap::new(),
559        }]);
560        assert!(compiled[0].is_exclusion);
561        assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
562    }
563
564    #[test]
565    fn catchall_matches_everything() {
566        let compiled = compile_rules(&[Rule {
567            pattern: "*".into(),
568            handler: "symlink".into(),
569            priority: 0,
570            options: HashMap::new(),
571        }]);
572        assert!(matches_entry(&compiled[0].pattern, "anything", false));
573        assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
574    }
575
576    // ── Scanner integration tests ───────────────────────────────
577
578    #[test]
579    fn scan_pack_basic() {
580        let env = TempEnvironment::builder()
581            .pack("vim")
582            .file("vimrc", "set nocompatible")
583            .file("gvimrc", "set guifont=Mono")
584            .file("aliases.sh", "alias vi=vim")
585            .file("install.sh", "#!/bin/sh\necho setup")
586            .done()
587            .build();
588
589        let scanner = Scanner::new(env.fs.as_ref());
590        let pack = make_pack("vim", env.dotfiles_root.join("vim"));
591        let rules = default_rules();
592
593        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
594
595        let handler_map: HashMap<String, Vec<String>> = {
596            let mut m: HashMap<String, Vec<String>> = HashMap::new();
597            for rm in &matches {
598                m.entry(rm.handler.clone())
599                    .or_default()
600                    .push(rm.relative_path.to_string_lossy().to_string());
601            }
602            m
603        };
604
605        assert_eq!(handler_map["install"], vec!["install.sh"]);
606        assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
607        assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
608        assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
609    }
610
611    #[test]
612    fn scan_pack_skips_hidden_files() {
613        let env = TempEnvironment::builder()
614            .pack("test")
615            .file("visible", "yes")
616            .file(".hidden", "no")
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        let rules = default_rules();
623
624        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
625        let names: Vec<String> = matches
626            .iter()
627            .map(|m| m.relative_path.to_string_lossy().to_string())
628            .collect();
629
630        assert!(names.contains(&"visible".to_string()));
631        assert!(!names.contains(&".hidden".to_string()));
632    }
633
634    #[test]
635    fn scan_pack_skips_special_files() {
636        let env = TempEnvironment::builder()
637            .pack("test")
638            .file("normal", "yes")
639            .config("[pack]\nignore = []")
640            .done()
641            .build();
642
643        // Also manually create .dodotignore (even though it shouldn't be scanned)
644        let pack_dir = env.dotfiles_root.join("test");
645        env.fs
646            .write_file(&pack_dir.join(".dodotignore"), b"")
647            .unwrap();
648
649        let scanner = Scanner::new(env.fs.as_ref());
650        let pack = make_pack("test", pack_dir);
651        let rules = default_rules();
652
653        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
654        let names: Vec<String> = matches
655            .iter()
656            .map(|m| m.relative_path.to_string_lossy().to_string())
657            .collect();
658
659        assert!(names.contains(&"normal".to_string()));
660        assert!(!names.contains(&".dodot.toml".to_string()));
661        assert!(!names.contains(&".dodotignore".to_string()));
662    }
663
664    #[test]
665    fn scan_pack_with_ignore_patterns() {
666        let env = TempEnvironment::builder()
667            .pack("test")
668            .file("keep.txt", "yes")
669            .file("skip.bak", "no")
670            .file("other.bak", "no")
671            .done()
672            .build();
673
674        let scanner = Scanner::new(env.fs.as_ref());
675        let pack = make_pack("test", env.dotfiles_root.join("test"));
676        let rules = default_rules();
677
678        let matches = scanner
679            .scan_pack(&pack, &rules, &["*.bak".to_string()])
680            .unwrap();
681        let names: Vec<String> = matches
682            .iter()
683            .map(|m| m.relative_path.to_string_lossy().to_string())
684            .collect();
685
686        assert!(names.contains(&"keep.txt".to_string()));
687        assert!(!names.contains(&"skip.bak".to_string()));
688        assert!(!names.contains(&"other.bak".to_string()));
689    }
690
691    #[test]
692    fn scan_pack_exclusion_rules_override_catchall() {
693        let env = TempEnvironment::builder()
694            .pack("test")
695            .file("good.txt", "yes")
696            .file("bad.tmp", "no")
697            .done()
698            .build();
699
700        let scanner = Scanner::new(env.fs.as_ref());
701        let pack = make_pack("test", env.dotfiles_root.join("test"));
702
703        let rules = vec![
704            Rule {
705                pattern: "!*.tmp".into(),
706                handler: "exclude".into(),
707                priority: 100,
708                options: HashMap::new(),
709            },
710            Rule {
711                pattern: "*".into(),
712                handler: "symlink".into(),
713                priority: 0,
714                options: HashMap::new(),
715            },
716        ];
717
718        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
719        let names: Vec<String> = matches
720            .iter()
721            .map(|m| m.relative_path.to_string_lossy().to_string())
722            .collect();
723
724        assert!(names.contains(&"good.txt".to_string()));
725        assert!(!names.contains(&"bad.tmp".to_string()));
726    }
727
728    #[test]
729    fn scan_pack_priority_ordering() {
730        let env = TempEnvironment::builder()
731            .pack("test")
732            .file("aliases.sh", "# shell")
733            .done()
734            .build();
735
736        let scanner = Scanner::new(env.fs.as_ref());
737        let pack = make_pack("test", env.dotfiles_root.join("test"));
738
739        // Both *.sh and aliases.sh match — higher priority should win
740        let rules = vec![
741            Rule {
742                pattern: "*.sh".into(),
743                handler: "generic-shell".into(),
744                priority: 5,
745                options: HashMap::new(),
746            },
747            Rule {
748                pattern: "aliases.sh".into(),
749                handler: "specific-shell".into(),
750                priority: 10,
751                options: HashMap::new(),
752            },
753            Rule {
754                pattern: "*".into(),
755                handler: "symlink".into(),
756                priority: 0,
757                options: HashMap::new(),
758            },
759        ];
760
761        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
762        assert_eq!(matches.len(), 1);
763        assert_eq!(matches[0].handler, "specific-shell");
764    }
765
766    #[test]
767    fn scan_pack_directory_entry() {
768        let env = TempEnvironment::builder()
769            .pack("test")
770            .file("bin/my-script", "#!/bin/sh")
771            .file("normal", "x")
772            .done()
773            .build();
774
775        let scanner = Scanner::new(env.fs.as_ref());
776        let pack = make_pack("test", env.dotfiles_root.join("test"));
777        let rules = default_rules();
778
779        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
780
781        let bin_match = matches
782            .iter()
783            .find(|m| m.relative_path.to_string_lossy() == "bin");
784        assert!(bin_match.is_some(), "bin directory should match");
785        assert_eq!(bin_match.unwrap().handler, "path");
786        assert!(bin_match.unwrap().is_dir);
787    }
788
789    #[test]
790    fn nested_install_sh_is_not_matched_by_install_rule() {
791        // A file named install.sh that lives deep inside a directory
792        // must NOT activate the install handler. Only a top-level
793        // install.sh triggers it.
794        let env = TempEnvironment::builder()
795            .pack("sneaky")
796            .file("config/install.sh", "echo boom")
797            .done()
798            .build();
799
800        let scanner = Scanner::new(env.fs.as_ref());
801        let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
802        let rules = default_rules();
803
804        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
805
806        assert!(
807            !matches.iter().any(|m| m.handler == "install"),
808            "nested install.sh should not route to install handler: {matches:?}"
809        );
810    }
811
812    #[test]
813    fn scan_pack_returns_only_top_level_entries() {
814        // Under the top-level-only scanner, nested files are not surfaced
815        // as individual matches. The containing dir is the matched entry;
816        // handlers (symlink wholesale, path, …) decide how to recurse.
817        let env = TempEnvironment::builder()
818            .pack("nvim")
819            .file("nvim/init.lua", "require('config')")
820            .file("nvim/lua/plugins.lua", "return {}")
821            .done()
822            .build();
823
824        let scanner = Scanner::new(env.fs.as_ref());
825        let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
826        let rules = default_rules();
827
828        let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
829
830        let relpaths: Vec<String> = matches
831            .iter()
832            .map(|m| m.relative_path.to_string_lossy().to_string())
833            .collect();
834
835        assert!(
836            relpaths.iter().any(|p| p == "nvim"),
837            "top-level nvim dir should match: {relpaths:?}"
838        );
839        assert!(
840            !relpaths.iter().any(|p| p.contains('/')),
841            "no nested paths expected: {relpaths:?}"
842        );
843    }
844
845    // ── Grouping tests (from PR 5, kept) ────────────────────────
846
847    #[test]
848    fn group_by_handler_groups_correctly() {
849        let matches = vec![
850            RuleMatch {
851                relative_path: "vimrc".into(),
852                absolute_path: "/d/vim/vimrc".into(),
853                pack: "vim".into(),
854                handler: "symlink".into(),
855                is_dir: false,
856                options: HashMap::new(),
857                preprocessor_source: None,
858            },
859            RuleMatch {
860                relative_path: "aliases.sh".into(),
861                absolute_path: "/d/vim/aliases.sh".into(),
862                pack: "vim".into(),
863                handler: "shell".into(),
864                is_dir: false,
865                options: HashMap::new(),
866                preprocessor_source: None,
867            },
868            RuleMatch {
869                relative_path: "gvimrc".into(),
870                absolute_path: "/d/vim/gvimrc".into(),
871                pack: "vim".into(),
872                handler: "symlink".into(),
873                is_dir: false,
874                options: HashMap::new(),
875                preprocessor_source: None,
876            },
877        ];
878
879        let groups = group_by_handler(&matches);
880        assert_eq!(groups.len(), 2);
881        assert_eq!(groups["symlink"].len(), 2);
882        assert_eq!(groups["shell"].len(), 1);
883    }
884
885    #[test]
886    fn handler_execution_order_follows_phase_declaration() {
887        let mut groups = HashMap::new();
888        groups.insert("symlink".into(), vec![]);
889        groups.insert("install".into(), vec![]);
890        groups.insert("shell".into(), vec![]);
891        groups.insert("homebrew".into(), vec![]);
892        groups.insert("path".into(), vec![]);
893
894        let fs = crate::fs::OsFs::new();
895        let registry = crate::handlers::create_registry(&fs);
896        let order = handler_execution_order(&groups, &registry);
897
898        // Exact order matches ExecutionPhase declaration:
899        // Provision(homebrew) -> Setup(install) -> PathExport(path)
900        //   -> ShellInit(shell) -> Link(symlink)
901        assert_eq!(
902            order,
903            vec!["homebrew", "install", "path", "shell", "symlink"]
904        );
905    }
906
907    #[test]
908    fn handler_execution_order_places_unknown_handlers_last() {
909        let mut groups = HashMap::new();
910        groups.insert("symlink".into(), vec![]);
911        groups.insert("zzz-unknown".into(), vec![]);
912        groups.insert("homebrew".into(), vec![]);
913
914        let fs = crate::fs::OsFs::new();
915        let registry = crate::handlers::create_registry(&fs);
916        let order = handler_execution_order(&groups, &registry);
917
918        // Known handlers keep phase order; unknown lands at the end.
919        assert_eq!(order, vec!["homebrew", "symlink", "zzz-unknown"]);
920    }
921
922    #[test]
923    fn rule_match_serializes() {
924        let m = RuleMatch {
925            relative_path: "vimrc".into(),
926            absolute_path: "/dots/vim/vimrc".into(),
927            pack: "vim".into(),
928            handler: "symlink".into(),
929            is_dir: false,
930            options: HashMap::new(),
931            preprocessor_source: None,
932        };
933        let json = serde_json::to_string(&m).unwrap();
934        assert!(json.contains("vimrc"));
935        assert!(json.contains("symlink"));
936        assert!(!json.contains("options"));
937    }
938}