Skip to main content

dodot_lib/preprocessing/
no_reverse.rs

1//! Per-file `no_reverse` opt-out for the template reverse-merge.
2//!
3//! `[preprocessor.template] no_reverse = ["pattern", ...]` lets a user
4//! exclude specific template sources from `dodot transform check`'s
5//! reverse-merge engine and from the clean filter's slow path. Patterns
6//! are globs, matched against the source file's *filename component*
7//! (so `*.gen.tmpl` matches `app/foo.gen.tmpl`, and a fully-spelled
8//! `complex-config.toml.tmpl` matches anywhere in the tree).
9//!
10//! The matching itself is dumb (no path walking, no recursion) — this
11//! module exists mostly to localise the rule and keep the two callers
12//! (transform check, template clean) consistent.
13
14use std::path::Path;
15
16/// Returns true iff `source_path`'s filename matches any of the
17/// glob patterns in `patterns`. Patterns that fail to compile as
18/// globs are silently ignored (config validation upstream is the
19/// place to surface user error; from the engine's point of view a
20/// bad pattern means "matches nothing").
21pub fn is_no_reverse(source_path: &Path, patterns: &[String]) -> bool {
22    if patterns.is_empty() {
23        return false;
24    }
25    let Some(filename) = source_path.file_name().and_then(|f| f.to_str()) else {
26        return false;
27    };
28    patterns.iter().any(|p| {
29        glob::Pattern::new(p)
30            .map(|g| g.matches(filename))
31            .unwrap_or(false)
32    })
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38    use std::path::PathBuf;
39
40    #[test]
41    fn empty_patterns_never_match() {
42        let p = PathBuf::from("/dotfiles/app/config.toml.tmpl");
43        assert!(!is_no_reverse(&p, &[]));
44    }
45
46    #[test]
47    fn exact_filename_match() {
48        let p = PathBuf::from("/dotfiles/app/complex-config.toml.tmpl");
49        assert!(is_no_reverse(&p, &["complex-config.toml.tmpl".to_string()]));
50        assert!(!is_no_reverse(&p, &["other-config.toml.tmpl".to_string()]));
51    }
52
53    #[test]
54    fn glob_matches_by_suffix() {
55        let p = PathBuf::from("/dotfiles/app/foo.gen.tmpl");
56        assert!(is_no_reverse(&p, &["*.gen.tmpl".to_string()]));
57    }
58
59    #[test]
60    fn glob_matches_one_of_many() {
61        let p = PathBuf::from("/dotfiles/app/foo.gen.tmpl");
62        let patterns = vec![
63            "first.tmpl".to_string(),
64            "*.gen.tmpl".to_string(),
65            "third.tmpl".to_string(),
66        ];
67        assert!(is_no_reverse(&p, &patterns));
68    }
69
70    #[test]
71    fn no_match_returns_false() {
72        let p = PathBuf::from("/dotfiles/app/regular.tmpl");
73        assert!(!is_no_reverse(&p, &["*.gen.tmpl".to_string()]));
74    }
75
76    #[test]
77    fn invalid_glob_pattern_is_ignored() {
78        // `[` opens a character class; never closing it makes the
79        // pattern invalid. Matching defaults to "no match" rather
80        // than blowing up.
81        let p = PathBuf::from("/dotfiles/app/cfg.tmpl");
82        assert!(!is_no_reverse(&p, &["[unclosed".to_string()]));
83    }
84
85    #[test]
86    fn matches_against_filename_not_full_path() {
87        // Glob characters in directory portions of the path don't
88        // affect matching — we only look at the filename.
89        let p = PathBuf::from("/dotfiles/strange-name/cfg.tmpl");
90        assert!(is_no_reverse(&p, &["cfg.tmpl".to_string()]));
91        assert!(!is_no_reverse(&p, &["strange-name".to_string()]));
92    }
93}