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