Skip to main content

majordomus_cli/discovery/
glob.rs

1//! The `:(glob)` pathspec subset the repository's `sources.yaml` is written in.
2//!
3//! Deliberately small, and the smallness is the point: this has to agree with what git
4//! matches for the same pathspec, because discovery asks git for the tracked files and then
5//! judges them here. `*` and `?` match within one path segment and never cross a `/`; `**`
6//! is a segment of its own and matches any number of segments, none included; and a bracket
7//! expression — `[0-9]`, `[abc]`, `[!x]`, `[^x]` — matches one character of its class,
8//! inside a segment, as git's own matcher reads it. There is no brace expansion and no
9//! negation of a whole pattern: a pattern this cannot express is a pattern the layer does
10//! not use.
11//!
12//! Matching is on repository-relative paths, always with `/`, never with a leading `./`.
13//!
14//! ```
15//! use majordomus_cli::discovery::glob::Glob;
16//!
17//! let rules = Glob::new(".ai/repo/rules/**/*.md");
18//! assert!(rules.matches(".ai/repo/rules/project/scope.v1.md"));
19//! assert!(rules.matches(".ai/repo/rules/vendor/majordomus/rules/adr.v1.md"));
20//! assert!(!rules.matches(".ai/repo/rules/project/scope.v1.yaml"), "the extension decides");
21//!
22//! // a single star stays inside its segment, which is what keeps `*.md` from matching a tree
23//! let flat = Glob::new(".ai/repo/*.yaml");
24//! assert!(flat.matches(".ai/repo/policy.yaml"));
25//! assert!(!flat.matches(".ai/repo/ci/gates.yaml"));
26//!
27//! // a bracket expression matches one character of its class, and negates with ! or ^
28//! let versioned = Glob::new(".ai/repo/rules/**/*.v[0-9].md");
29//! assert!(versioned.matches(".ai/repo/rules/project/scope.v1.md"));
30//! assert!(!versioned.matches(".ai/repo/rules/project/scope.vx.md"));
31//!
32//! // and a walk can skip a directory the pattern could never match under
33//! assert!(rules.could_match_under(".ai/repo/rules"));
34//! assert!(!rules.could_match_under("docs"));
35//! ```
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38enum Segment {
39    Any,
40    Pattern(String),
41}
42
43#[derive(Debug, Clone)]
44/// A compiled `:(glob)` pattern.
45///
46/// ```
47/// use majordomus_cli::discovery::glob::Glob;
48/// let g = Glob::new(".ai/repo/rules/**/*.md");
49/// assert!(g.matches(".ai/repo/rules/project/a.v1.md"));
50/// assert!(!g.matches("docs/a.md"));
51/// ```
52pub struct Glob {
53    segments: Vec<Segment>,
54}
55
56impl Glob {
57    /// Compile a pattern; `**` is a segment of its own, `*` and `?` stay inside one segment.
58    pub fn new(pattern: &str) -> Self {
59        let segments = pattern
60            .split('/')
61            .filter(|s| !s.is_empty())
62            .map(|s| {
63                if s == "**" {
64                    Segment::Any
65                } else {
66                    Segment::Pattern(s.to_string())
67                }
68            })
69            .collect();
70        Glob { segments }
71    }
72
73    /// Does `path` match the whole pattern?
74    pub fn matches(&self, path: &str) -> bool {
75        let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
76        Self::match_from(&self.segments, &parts)
77    }
78
79    /// Could some file under directory `dir` match? Used to prune a walk.
80    pub fn could_match_under(&self, dir: &str) -> bool {
81        let parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
82        Self::prefix_from(&self.segments, &parts)
83    }
84
85    fn match_from(segs: &[Segment], parts: &[&str]) -> bool {
86        match (segs.first(), parts.first()) {
87            (None, None) => true,
88            (None, Some(_)) => false,
89            (Some(Segment::Any), _) => {
90                (0..=parts.len()).any(|skip| Self::match_from(&segs[1..], &parts[skip..]))
91            }
92            (Some(Segment::Pattern(_)), None) => false,
93            (Some(Segment::Pattern(p)), Some(part)) => {
94                segment_matches(p, part) && Self::match_from(&segs[1..], &parts[1..])
95            }
96        }
97    }
98
99    fn prefix_from(segs: &[Segment], parts: &[&str]) -> bool {
100        match (segs.first(), parts.first()) {
101            (_, None) => true,
102            (None, Some(_)) => false,
103            (Some(Segment::Any), _) => true,
104            (Some(Segment::Pattern(p)), Some(part)) => {
105                segment_matches(p, part) && Self::prefix_from(&segs[1..], &parts[1..])
106            }
107        }
108    }
109}
110
111/// One segment against one path component; `*`, `?` and a bracket expression stay inside
112/// the component.
113fn segment_matches(pattern: &str, text: &str) -> bool {
114    let p: Vec<char> = pattern.chars().collect();
115    let t: Vec<char> = text.chars().collect();
116    let (mut pi, mut ti) = (0, 0);
117    let (mut star, mut mark) = (None, 0);
118    while ti < t.len() {
119        let class = if pi < p.len() && p[pi] == '[' {
120            bracket(&p, pi, t[ti])
121        } else {
122            None
123        };
124        if let Some((matched, end)) = class {
125            if matched {
126                pi = end;
127                ti += 1;
128                continue;
129            } else if let Some(s) = star {
130                pi = s + 1;
131                mark += 1;
132                ti = mark;
133                continue;
134            } else {
135                return false;
136            }
137        }
138        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
139            pi += 1;
140            ti += 1;
141        } else if pi < p.len() && p[pi] == '*' {
142            star = Some(pi);
143            mark = ti;
144            pi += 1;
145        } else if let Some(s) = star {
146            pi = s + 1;
147            mark += 1;
148            ti = mark;
149        } else {
150            return false;
151        }
152    }
153    while pi < p.len() && p[pi] == '*' {
154        pi += 1;
155    }
156    pi == p.len()
157}
158
159/// A bracket expression starting at `p[open]` (`[0-9]`, `[abc]`, `[!x]`, `[^x]`): whether
160/// `c` is in the class, and the index just past the closing bracket. `None` when the
161/// bracket never closes, in which case `[` is an ordinary character, as git treats it.
162fn bracket(p: &[char], open: usize, c: char) -> Option<(bool, usize)> {
163    let mut i = open + 1;
164    let negated = matches!(p.get(i), Some('!') | Some('^'));
165    if negated {
166        i += 1;
167    }
168    let mut matched = false;
169    let mut first = true;
170    while i < p.len() {
171        let ch = p[i];
172        if ch == ']' && !first {
173            return Some((matched != negated, i + 1));
174        }
175        first = false;
176        if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
177            if ch <= c && c <= p[i + 2] {
178                matched = true;
179            }
180            i += 3;
181        } else {
182            if ch == c {
183                matched = true;
184            }
185            i += 1;
186        }
187    }
188    None
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn star_does_not_cross_separators() {
197        let g = Glob::new("docs/*.md");
198        assert!(g.matches("docs/CLI.md"));
199        assert!(!g.matches("docs/claims/x.md"));
200        assert!(!g.matches("CLI.md"));
201    }
202
203    #[test]
204    fn double_star_crosses_any_depth() {
205        let g = Glob::new(".ai/repo/rules/**/*.md");
206        assert!(g.matches(".ai/repo/rules/project/a.v1.md"));
207        assert!(g.matches(".ai/repo/rules/vendor/majordomus/rules/b.v1.md"));
208        assert!(g.matches(".ai/repo/rules/README.md"));
209        assert!(!g.matches(".ai/repo/prompts/a.md"));
210    }
211
212    #[test]
213    fn root_star_matches_only_root_files() {
214        let g = Glob::new("*.md");
215        assert!(g.matches("README.md"));
216        assert!(!g.matches("docs/README.md"));
217    }
218
219    #[test]
220    fn exact_path_matches_itself_only() {
221        let g = Glob::new(".ai/repo/policy.yaml");
222        assert!(g.matches(".ai/repo/policy.yaml"));
223        assert!(!g.matches(".ai/repo/policy.yaml.bak"));
224    }
225
226    #[test]
227    fn prefix_pruning() {
228        let g = Glob::new(".ai/repo/rules/**/*.md");
229        assert!(g.could_match_under(".ai"));
230        assert!(g.could_match_under(".ai/repo/rules/vendor"));
231        assert!(!g.could_match_under("docs"));
232        assert!(Glob::new("*.md").could_match_under(""));
233        assert!(!Glob::new("*.md").could_match_under("docs"));
234    }
235
236    /// The decisions are discovered by `[0-9][0-9][0-9][0-9]-*.md`, and a matcher that
237    /// read `[` as a letter discovered none of them while git listed every one.
238    #[test]
239    fn bracket_classes_match_one_character_as_git_reads_them() {
240        let g = Glob::new(".ai/repo/adrs/[0-9][0-9][0-9][0-9]-*.md");
241        assert!(g.matches(".ai/repo/adrs/0021-the-topology.md"));
242        assert!(!g.matches(".ai/repo/adrs/README.md"));
243        assert!(!g.matches(".ai/repo/adrs/021-short.md"));
244        assert!(segment_matches("[abc]x", "bx"));
245        assert!(!segment_matches("[abc]x", "dx"));
246        assert!(segment_matches("[!abc]x", "dx"));
247        assert!(segment_matches("[^0-9]?", "az"));
248        assert!(segment_matches("a[-x]b", "a-b"));
249        // a bracket that never closes is an ordinary character
250        assert!(segment_matches("a[b", "a[b"));
251        assert!(!segment_matches("a[b", "ab"));
252        // classes and stars together, backtracking through the star
253        assert!(segment_matches("*[0-9].md", "issue-7.md"));
254        assert!(!segment_matches("*[0-9].md", "issue-x.md"));
255    }
256
257    #[test]
258    fn question_mark_and_mixed() {
259        assert!(segment_matches("I0?0?.yaml", "I0101.yaml"));
260        assert!(segment_matches("*.v1.md", "a.v1.md"));
261        assert!(!segment_matches("*.v1.md", "a.v2.md"));
262    }
263}