majordomus_cli/discovery/
glob.rs1#[derive(Debug, Clone, PartialEq, Eq)]
38enum Segment {
39 Any,
40 Pattern(String),
41}
42
43#[derive(Debug, Clone)]
44pub struct Glob {
53 segments: Vec<Segment>,
54}
55
56impl Glob {
57 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 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 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
111fn 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
159fn 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 #[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 assert!(segment_matches("a[b", "a[b"));
251 assert!(!segment_matches("a[b", "ab"));
252 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}