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::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    // ── Pattern matching unit tests ─────────────────────────────
509
510    #[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    // ── Scanner integration tests ───────────────────────────────
573
574    #[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        // Also manually create .dodotignore (even though it shouldn't be scanned)
640        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        // Both *.sh and aliases.sh match — higher priority should win
736        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        // A file named install.sh that lives deep inside a directory
788        // must NOT activate the install handler. Only a top-level
789        // install.sh triggers it.
790        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        // Under the top-level-only scanner, nested files are not surfaced
811        // as individual matches. The containing dir is the matched entry;
812        // handlers (symlink wholesale, path, …) decide how to recurse.
813        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    // ── Grouping tests (from PR 5, kept) ────────────────────────
842
843    #[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, &registry);
893
894        // Exact order matches ExecutionPhase declaration:
895        // Provision(homebrew) -> Setup(install) -> PathExport(path)
896        //   -> ShellInit(shell) -> Link(symlink)
897        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, &registry);
913
914        // Known handlers keep phase order; unknown lands at the end.
915        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}