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