codeowners_parser/pattern.rs
1//! Fast CODEOWNERS pattern matching with shape-based specialization.
2//!
3//! Every CODEOWNERS pattern is classified at compile time into one of a
4//! handful of shapes, and each shape gets its own matcher. Typical monorepo
5//! CODEOWNERS files are ~80% `*.ext` extension patterns and directory
6//! prefixes — those get raw `ends_with` / `starts_with` on bytes, no glob
7//! engine involvement.
8//!
9//! For the minority of truly globby patterns (`src/**/*.rs`), we fall through
10//! to [`fast_glob`](https://crates.io/crates/fast-glob).
11
12/// A CODEOWNERS pattern, pre-processed for repeated fast matching.
13///
14/// Use [`CompiledPattern::new`] to compile, then [`CompiledPattern::matches`]
15/// to match paths. Construction is O(pattern length); matching is close to
16/// O(path length) for all variants except the glob ones.
17pub enum CompiledPattern {
18 /// Matches everything (unanchored `*` or `**`).
19 MatchAll,
20 /// Anchored `/*` — matches only root-level files (no slashes in path).
21 RootFilesOnly,
22 /// Extension suffix like `.rs` (from `*.rs`) — simple `ends_with` check.
23 ///
24 /// Stored as the literal suffix including the leading dot. Matcher also
25 /// verifies there is a `/` immediately before the suffix to avoid false
26 /// positives like `notrs` matching `.rs`.
27 ExtensionSuffix(String),
28 /// Single-segment glob like `*.rs` that fell out of the extension
29 /// fast-path — matched via `fast_glob` with a prepended `**/` so it
30 /// triggers in any directory.
31 SingleSegmentGlob(String),
32 /// Multi-segment glob like `src/**/*.rs` — matched directly via `fast_glob`.
33 MultiSegmentGlob(String),
34 /// Anchored directory pattern like `/src/` — matches a prefix at the root only.
35 AnchoredDirectory(String),
36 /// Unanchored directory pattern like `docs/` — matches anywhere in the tree.
37 UnanchoredDirectory(String),
38 /// An exact path, always anchored (no wildcards, no trailing slash).
39 Exact(String),
40}
41
42impl CompiledPattern {
43 /// Compile a CODEOWNERS pattern string into a specialized matcher.
44 ///
45 /// The leading `/` (anchor) is inspected to distinguish anchored from
46 /// unanchored directory patterns; a trailing `/` selects directory
47 /// matching over exact matching.
48 pub fn new(pattern: &str) -> Self {
49 let anchored = pattern.starts_with('/');
50 let pattern = pattern.trim_start_matches('/');
51
52 // Catch-all patterns
53 if pattern == "*" || pattern == "**" {
54 if anchored && pattern == "*" {
55 return CompiledPattern::RootFilesOnly;
56 }
57 return CompiledPattern::MatchAll;
58 }
59
60 // Patterns with wildcards
61 if pattern.contains('*') {
62 // Unanchored single-segment like *.rs
63 if !anchored && !pattern.contains('/') {
64 // Fast path: *.ext patterns use suffix check (no glob needed)
65 if let Some(ext) = pattern.strip_prefix('*') {
66 if !ext.contains('*') && !ext.contains('?') {
67 return CompiledPattern::ExtensionSuffix(ext.to_string());
68 }
69 }
70 return CompiledPattern::SingleSegmentGlob(format!("**/{}", pattern));
71 }
72 return CompiledPattern::MultiSegmentGlob(pattern.to_string());
73 }
74
75 // Directory patterns (trailing /)
76 if pattern.ends_with('/') {
77 let dir = pattern.trim_end_matches('/').to_string();
78 if anchored {
79 return CompiledPattern::AnchoredDirectory(dir);
80 } else {
81 return CompiledPattern::UnanchoredDirectory(dir);
82 }
83 }
84
85 // Exact patterns (always anchored)
86 CompiledPattern::Exact(pattern.to_string())
87 }
88
89 /// Test whether `path` matches this pattern.
90 ///
91 /// Empty paths never match. Pattern semantics follow GitHub's
92 /// CODEOWNERS rules (last match wins is handled by the caller —
93 /// see [`check_file_ownership_parsed`](crate::check_file_ownership_parsed)).
94 #[inline]
95 pub fn matches(&self, path: &str) -> bool {
96 if path.is_empty() {
97 return false;
98 }
99
100 match self {
101 CompiledPattern::MatchAll => true,
102 CompiledPattern::RootFilesOnly => !path.contains('/'),
103 CompiledPattern::ExtensionSuffix(ext) => {
104 path.ends_with(ext.as_str())
105 && (path.len() == ext.len()
106 || path.as_bytes()[path.len() - ext.len() - 1] != b'/')
107 }
108 CompiledPattern::SingleSegmentGlob(glob) => fast_glob::glob_match(glob, path),
109 CompiledPattern::MultiSegmentGlob(glob) => fast_glob::glob_match(glob, path),
110 CompiledPattern::AnchoredDirectory(dir) => {
111 path.starts_with(dir.as_str())
112 && (path.len() == dir.len() || path.as_bytes().get(dir.len()) == Some(&b'/'))
113 }
114 CompiledPattern::UnanchoredDirectory(dir) => {
115 // Match at root
116 if path.starts_with(dir.as_str())
117 && (path.len() == dir.len() || path.as_bytes().get(dir.len()) == Some(&b'/'))
118 {
119 return true;
120 }
121 // Match anywhere: check each path segment boundary
122 let dir_bytes = dir.as_bytes();
123 let path_bytes = path.as_bytes();
124 for (i, _) in path.match_indices('/') {
125 let rest = &path_bytes[i + 1..];
126 if rest.len() >= dir_bytes.len()
127 && &rest[..dir_bytes.len()] == dir_bytes
128 && (rest.len() == dir_bytes.len() || rest[dir_bytes.len()] == b'/')
129 {
130 return true;
131 }
132 }
133 false
134 }
135 CompiledPattern::Exact(exact) => {
136 path == exact
137 || (path.starts_with(exact.as_str())
138 && path.as_bytes().get(exact.len()) == Some(&b'/'))
139 }
140 }
141 }
142}
143
144/// Simple glob pattern matching for CODEOWNERS patterns
145///
146/// Key rules:
147/// - Leading `/` anchors pattern to repository root
148/// - No leading `/`:
149/// - If ends with `/`: directory pattern, matches anywhere in tree
150/// - If contains `/`: implicitly anchored to root
151/// - If no `/` and has `*`: matches at any depth (e.g., `*.rs`)
152/// - If no `/` and no `*`: anchored to root
153/// - `*` matches any characters except `/`
154/// - `**` matches zero or more directories
155#[inline]
156pub fn pattern_matches(pattern: &str, path: &str) -> bool {
157 // Empty path never matches (edge case)
158 if path.is_empty() {
159 return false;
160 }
161
162 // Check if pattern is anchored (has leading /)
163 let anchored = pattern.starts_with('/');
164 let pattern = pattern.trim_start_matches('/');
165
166 // Handle catch-all patterns (matches everything)
167 // Only unanchored * or ** are true catch-alls
168 if !anchored && (pattern == "*" || pattern == "**") {
169 return true;
170 }
171
172 // Handle patterns with wildcards
173 if pattern.contains('*') {
174 // Anchored single-star only matches root level
175 // /* should only match files directly in root, not nested
176 if anchored && pattern == "*" {
177 // Match only if path has no directory separator
178 return !path.contains('/');
179 }
180
181 // Single-segment patterns like *.rs (unanchored) match at any depth
182 if !anchored && !pattern.contains('/') {
183 // Fast path: *.ext is just a suffix check
184 if let Some(ext) = pattern.strip_prefix('*') {
185 return path.ends_with(ext)
186 && (path.len() == ext.len()
187 || path.as_bytes()[path.len() - ext.len() - 1] != b'/');
188 }
189 let glob_pattern = format!("**/{}", pattern);
190 return fast_glob::glob_match(&glob_pattern, path);
191 }
192
193 // Multi-segment patterns or anchored patterns: match directly
194 return fast_glob::glob_match(pattern, path);
195 }
196
197 // Handle directory patterns (trailing /)
198 if pattern.ends_with('/') {
199 let dir = pattern.trim_end_matches('/');
200
201 if anchored {
202 // /docs/ - only matches docs/ at root
203 return path.starts_with(dir)
204 && (path.len() == dir.len() || path.as_bytes().get(dir.len()) == Some(&b'/'));
205 } else {
206 // docs/ - matches any directory named "docs" anywhere
207 if path.starts_with(dir)
208 && (path.len() == dir.len() || path.as_bytes().get(dir.len()) == Some(&b'/'))
209 {
210 return true;
211 }
212 // Check each path segment boundary (no allocations)
213 let dir_bytes = dir.as_bytes();
214 let path_bytes = path.as_bytes();
215 for (i, _) in path.match_indices('/') {
216 let rest = &path_bytes[i + 1..];
217 if rest.len() >= dir_bytes.len()
218 && &rest[..dir_bytes.len()] == dir_bytes
219 && (rest.len() == dir_bytes.len() || rest[dir_bytes.len()] == b'/')
220 {
221 return true;
222 }
223 }
224 return false;
225 }
226 }
227
228 // Handle exact/prefix patterns (no wildcards, no trailing /)
229 // These are always anchored (either explicitly or implicitly)
230 path == pattern
231 || (path.starts_with(pattern) && path.as_bytes().get(pattern.len()) == Some(&b'/'))
232}
233
234/// Check if pattern `a` is subsumed by pattern `b` (i.e., everything `a` matches, `b` also matches).
235/// If true, and `b` comes after `a` in CODEOWNERS, then `a` is a dead rule.
236///
237/// Key anchoring rules:
238/// - Unanchored patterns (no leading `/`) match MORE files than anchored patterns
239/// - `/docs/` (anchored) IS subsumed by `docs/` (unanchored)
240/// - `docs/` (unanchored) is NOT subsumed by `/docs/` (anchored)
241#[inline]
242pub fn pattern_subsumes(a: &str, b: &str) -> bool {
243 // Track anchoring before stripping
244 let a_anchored = a.starts_with('/');
245 let b_anchored = b.starts_with('/');
246
247 let a = a.trim_start_matches('/');
248 let b = b.trim_start_matches('/');
249
250 // Identical patterns (both same anchoring and content)
251 if a == b && a_anchored == b_anchored {
252 return true;
253 }
254
255 // For exact paths (no wildcards, no trailing /), patterns containing /
256 // are implicitly anchored, so /src/foo and src/foo are equivalent
257 let a_is_exact = !a.contains('*') && !a.ends_with('/');
258 let b_is_exact = !b.contains('*') && !b.ends_with('/');
259 if a_is_exact && b_is_exact && a == b && a.contains('/') {
260 // Both are exact paths with /, one has leading / and one doesn't
261 // They match the same files, so b subsumes a
262 return true;
263 }
264
265 // Universal patterns subsume everything. `/*` is not one of them — it
266 // covers root-level entries only, so it must not swallow nested rules.
267 if b == "**" || (b == "*" && !b_anchored) {
268 return true;
269 }
270 if b == "*" {
271 // Anchored `/*`: only rules that are themselves confined to the root.
272 return (a == "*" && a_anchored) || (!a.contains('/') && !a.contains('*'));
273 }
274
275 // Extension patterns: *.rs is subsumed by *
276 if let Some(a_ext) = a.strip_prefix('*') {
277 if b == "*" || b == "**" {
278 return true;
279 }
280 // *.rs.bak is subsumed by *.bak
281 if let Some(b_ext) = b.strip_prefix('*') {
282 return a_ext.ends_with(b_ext);
283 }
284 return false;
285 }
286
287 // Directory patterns: /src/lib/ is subsumed by /src/
288 let a_dir = a
289 .trim_end_matches('/')
290 .trim_end_matches("/**")
291 .trim_end_matches("/*");
292 let b_dir = b
293 .trim_end_matches('/')
294 .trim_end_matches("/**")
295 .trim_end_matches("/*");
296
297 let a_reach = dir_reach(a);
298 let b_reach = dir_reach(b);
299
300 // Only a bare trailing slash floats: `docs/` matches a docs directory at
301 // any depth, while `docs/*` and `docs/**` are globs pinned to the root.
302 let a_floats = !a_anchored && a.ends_with('/');
303 let b_floats = !b_anchored && b.ends_with('/');
304
305 // Handle anchored vs unanchored directory patterns
306 if let (Some(a_reach), Some(b_reach)) = (a_reach, b_reach) {
307 // The floating pattern matches MORE files, so it is never subsumed by
308 // a pinned one — `docs/` also covers nested `x/docs/`.
309 if a_floats && !b_floats {
310 return false;
311 }
312 if b_reach == DirReach::DirectChildren {
313 return a_reach == DirReach::DirectChildren && a_dir == b_dir;
314 }
315 return a_dir == b_dir || starts_with_dir(a_dir, b_dir);
316 }
317
318 // Exact file in directory: src/main.rs subsumed by src/ or src/**
319 // An exact path is always root-anchored, so only `b`'s reach matters here.
320 if let (None, Some(b_reach)) = (a_reach, b_reach) {
321 if b_reach == DirReach::DirectChildren {
322 return is_direct_child(a, b_dir);
323 }
324 return a == b_dir || starts_with_dir(a, b_dir);
325 }
326
327 false
328}
329
330/// How deep a directory-style pattern reaches.
331///
332/// `docs/` and `docs/**` cover the whole subtree, but `docs/*` stops at the
333/// directory's immediate entries — GitHub's own docs spell this out: `docs/*`
334/// matches `docs/getting-started.md` and not `docs/build-app/troubleshooting.md`.
335/// Collapsing the two is what makes a rule look dead when it isn't.
336#[derive(PartialEq, Clone, Copy)]
337enum DirReach {
338 Recursive,
339 DirectChildren,
340}
341
342fn dir_reach(pattern: &str) -> Option<DirReach> {
343 if pattern.ends_with('/') || pattern.ends_with("/**") {
344 Some(DirReach::Recursive)
345 } else if pattern.ends_with("/*") {
346 Some(DirReach::DirectChildren)
347 } else {
348 None
349 }
350}
351
352/// Check if `path` starts with `dir` followed by `/`
353#[inline]
354fn starts_with_dir(path: &str, dir: &str) -> bool {
355 path.starts_with(dir) && path.as_bytes().get(dir.len()) == Some(&b'/')
356}
357
358/// Whether `path` sits directly inside `dir` with nothing nested in between.
359#[inline]
360fn is_direct_child(path: &str, dir: &str) -> bool {
361 starts_with_dir(path, dir) && !path[dir.len() + 1..].contains('/')
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 // =============================================================================
369 // GITHUB CODEOWNERS CONFORMANCE TEST SUITE
370 // =============================================================================
371 // Based on official GitHub documentation and observed behavior.
372 // Reference: reports/compass_artifact_wf-*.md
373 //
374 // Key rules:
375 // 1. Last matching pattern wins (not most specific)
376 // 2. Leading `/` anchors pattern to repository root
377 // 3. No leading `/` = pattern can match anywhere in tree (for certain patterns)
378 // 4. Trailing `/` = directory pattern, matches recursively
379 // 5. `*` matches any characters EXCEPT `/` (doesn't cross directories)
380 // 6. `**` matches zero or more directories (crosses boundaries)
381 // 7. Patterns are case-sensitive
382 // 8. `[abc]` character ranges NOT supported (unlike gitignore)
383 // 9. `!` negation patterns NOT supported (unlike gitignore)
384 // =============================================================================
385
386 // ---------------------------------------------------------------------------
387 // CATEGORY 1: CATCH-ALL PATTERNS
388 // ---------------------------------------------------------------------------
389 // `*` and `**` at the root level match every file in the repository.
390
391 #[test]
392 fn test_catchall_star() {
393 // * matches every file in the repository (not just root)
394 assert!(pattern_matches("*", "readme.md"));
395 assert!(pattern_matches("*", "src/main.rs"));
396 assert!(pattern_matches("*", "a/b/c/d/e/f.txt"));
397 assert!(pattern_matches("*", ".hidden"));
398 assert!(pattern_matches("*", "deep/nested/path/file.ext"));
399 }
400
401 #[test]
402 fn test_catchall_double_star() {
403 // ** also matches everything
404 assert!(pattern_matches("**", "readme.md"));
405 assert!(pattern_matches("**", "src/main.rs"));
406 assert!(pattern_matches("**", "a/b/c/d/e/f.txt"));
407 }
408
409 // ---------------------------------------------------------------------------
410 // CATEGORY 2: EXTENSION PATTERNS (no path separator)
411 // ---------------------------------------------------------------------------
412 // Patterns like `*.rs` without any `/` match files at ANY depth.
413 // This is CODEOWNERS-specific behavior.
414
415 #[test]
416 fn test_extension_matches_any_depth() {
417 // *.rs matches .rs files at any depth
418 assert!(pattern_matches("*.rs", "main.rs"));
419 assert!(pattern_matches("*.rs", "src/main.rs"));
420 assert!(pattern_matches("*.rs", "src/lib/mod.rs"));
421 assert!(pattern_matches("*.rs", "a/b/c/d/file.rs"));
422 }
423
424 #[test]
425 fn test_extension_no_match_wrong_ext() {
426 assert!(!pattern_matches("*.rs", "main.go"));
427 assert!(!pattern_matches("*.rs", "src/main.go"));
428 assert!(!pattern_matches("*.rs", "readme.md"));
429 assert!(!pattern_matches("*.rs", "file.rs.bak")); // .rs.bak != .rs
430 }
431
432 #[test]
433 fn test_extension_complex() {
434 // *.config.js should match config.js files
435 assert!(pattern_matches("*.config.js", "webpack.config.js"));
436 assert!(pattern_matches("*.config.js", "src/babel.config.js"));
437 assert!(!pattern_matches("*.config.js", "config.js")); // no prefix before .config.js
438 }
439
440 // ---------------------------------------------------------------------------
441 // CATEGORY 3: ANCHORED DIRECTORY PATTERNS (leading `/`, trailing `/`)
442 // ---------------------------------------------------------------------------
443 // `/docs/` = directory at root, matches ALL contents recursively
444
445 #[test]
446 fn test_anchored_dir_matches_recursively() {
447 // /docs/ matches docs/* and docs/**/* at root only
448 assert!(pattern_matches("/docs/", "docs/readme.md"));
449 assert!(pattern_matches("/docs/", "docs/api/index.md"));
450 assert!(pattern_matches("/docs/", "docs/a/b/c/deep.txt"));
451 }
452
453 #[test]
454 fn test_anchored_dir_no_match_elsewhere() {
455 // /docs/ does NOT match docs/ nested elsewhere
456 assert!(!pattern_matches("/docs/", "src/docs/file.txt"));
457 assert!(!pattern_matches("/docs/", "other/docs/readme.md"));
458 assert!(!pattern_matches("/docs/", "a/b/docs/c.txt"));
459 }
460
461 #[test]
462 fn test_anchored_dir_no_match_partial_name() {
463 // /docs/ should not match /documentation/
464 assert!(!pattern_matches("/docs/", "documentation/readme.md"));
465 assert!(!pattern_matches("/docs/", "docs-old/readme.md"));
466 }
467
468 // ---------------------------------------------------------------------------
469 // CATEGORY 4: UNANCHORED DIRECTORY PATTERNS (no leading `/`, trailing `/`)
470 // ---------------------------------------------------------------------------
471 // `docs/` (no leading slash) = matches ANY directory named `docs` ANYWHERE
472
473 #[test]
474 fn test_unanchored_dir_matches_at_root() {
475 assert!(pattern_matches("docs/", "docs/readme.md"));
476 assert!(pattern_matches("docs/", "docs/api/index.md"));
477 }
478
479 #[test]
480 fn test_unanchored_dir_matches_nested() {
481 // This is the critical difference from anchored patterns
482 assert!(pattern_matches("docs/", "src/docs/file.txt"));
483 assert!(pattern_matches("docs/", "a/b/docs/deep.txt"));
484 assert!(pattern_matches("docs/", "project/src/docs/api/v1/spec.md"));
485 }
486
487 #[test]
488 fn test_unanchored_dir_no_match_partial_name() {
489 // Must be exact directory name
490 assert!(!pattern_matches("docs/", "documentation/readme.md"));
491 assert!(!pattern_matches("docs/", "mydocs/readme.md"));
492 assert!(!pattern_matches("docs/", "src/mydocs/file.txt"));
493 }
494
495 // ---------------------------------------------------------------------------
496 // CATEGORY 5: ANCHORED PATH PATTERNS (leading `/`, no wildcards)
497 // ---------------------------------------------------------------------------
498 // `/src/main.rs` = exact file at root
499 // `/src` = file or directory named `src` at root
500
501 #[test]
502 fn test_anchored_exact_file() {
503 assert!(pattern_matches("/Makefile", "Makefile"));
504 assert!(pattern_matches("/src/main.rs", "src/main.rs"));
505 assert!(!pattern_matches("/Makefile", "build/Makefile"));
506 assert!(!pattern_matches("/src/main.rs", "other/src/main.rs"));
507 }
508
509 #[test]
510 fn test_anchored_name_as_directory() {
511 // /docs (no trailing slash) matches file or directory named docs at root
512 assert!(pattern_matches("/docs", "docs")); // exact file match
513 assert!(pattern_matches("/docs", "docs/anything")); // as directory prefix
514 assert!(pattern_matches("/docs", "docs/nested/deep.txt"));
515 assert!(!pattern_matches("/docs", "src/docs"));
516 assert!(!pattern_matches("/docs", "src/docs/file.txt"));
517 }
518
519 // ---------------------------------------------------------------------------
520 // CATEGORY 6: SINGLE-SEGMENT UNANCHORED PATTERNS (no `/` at all)
521 // ---------------------------------------------------------------------------
522 // `Makefile` (no slashes) - behavior depends on interpretation
523 // In gitignore: matches anywhere. In CODEOWNERS: typically anchored.
524
525 #[test]
526 fn test_single_segment_exact() {
527 // Single segment without slashes - anchored to root
528 assert!(pattern_matches("Makefile", "Makefile"));
529 assert!(pattern_matches("README.md", "README.md"));
530 }
531
532 #[test]
533 fn test_single_segment_as_directory() {
534 // Single segment can also act as directory prefix
535 assert!(pattern_matches("src", "src/main.rs"));
536 assert!(pattern_matches("src", "src/lib/mod.rs"));
537 }
538
539 #[test]
540 fn test_single_segment_no_match_nested() {
541 // Should NOT match same name nested elsewhere (anchored behavior)
542 assert!(!pattern_matches("Makefile", "build/Makefile"));
543 assert!(!pattern_matches("src", "project/src/file.rs"));
544 }
545
546 // ---------------------------------------------------------------------------
547 // CATEGORY 7: MULTI-SEGMENT UNANCHORED PATTERNS (has `/`, no leading `/`)
548 // ---------------------------------------------------------------------------
549 // `src/main.rs` (contains `/` but no leading `/`) - implicitly anchored
550
551 #[test]
552 fn test_multi_segment_implicitly_anchored() {
553 assert!(pattern_matches("src/main.rs", "src/main.rs"));
554 assert!(pattern_matches("src/lib/mod.rs", "src/lib/mod.rs"));
555 // Should NOT match nested (implicitly anchored)
556 assert!(!pattern_matches("src/main.rs", "project/src/main.rs"));
557 }
558
559 // ---------------------------------------------------------------------------
560 // CATEGORY 8: SINGLE STAR PATTERNS (`*` - doesn't cross directories)
561 // ---------------------------------------------------------------------------
562 // `*` matches any character EXCEPT `/`
563
564 #[test]
565 fn test_star_direct_children_only() {
566 // /docs/* matches only direct children of docs
567 assert!(pattern_matches("/docs/*", "docs/readme.md"));
568 assert!(pattern_matches("/docs/*", "docs/index.html"));
569 // Does NOT match nested paths
570 assert!(!pattern_matches("/docs/*", "docs/api/index.md"));
571 assert!(!pattern_matches("/docs/*", "docs/a/b/file.txt"));
572 }
573
574 #[test]
575 fn test_star_in_filename() {
576 // Pattern with * in filename portion
577 assert!(pattern_matches("docs/*.md", "docs/readme.md"));
578 assert!(pattern_matches("docs/*.md", "docs/CHANGELOG.md"));
579 assert!(!pattern_matches("docs/*.md", "docs/readme.txt"));
580 assert!(!pattern_matches("docs/*.md", "docs/api/readme.md")); // nested
581 }
582
583 #[test]
584 fn test_star_prefix_suffix() {
585 // *crowdin* matches files with crowdin in the name
586 assert!(pattern_matches(
587 ".github/workflows/*crowdin*",
588 ".github/workflows/crowdin-download.yaml"
589 ));
590 assert!(pattern_matches(
591 ".github/workflows/*crowdin*",
592 ".github/workflows/upload-crowdin.yaml"
593 ));
594 assert!(!pattern_matches(
595 ".github/workflows/*crowdin*",
596 ".github/workflows/deploy.yaml"
597 ));
598 }
599
600 #[test]
601 fn test_star_middle_of_path() {
602 // deployment/*/config.yaml - single directory wildcard
603 assert!(pattern_matches(
604 "deployment/*/config.yaml",
605 "deployment/prod/config.yaml"
606 ));
607 assert!(pattern_matches(
608 "deployment/*/config.yaml",
609 "deployment/staging/config.yaml"
610 ));
611 // Does not match nested
612 assert!(!pattern_matches(
613 "deployment/*/config.yaml",
614 "deployment/env/prod/config.yaml"
615 ));
616 }
617
618 // ---------------------------------------------------------------------------
619 // CATEGORY 9: DOUBLE STAR PATTERNS (`**` - crosses directories)
620 // ---------------------------------------------------------------------------
621
622 #[test]
623 fn test_double_star_leading() {
624 // **/file.txt matches file.txt at any depth
625 assert!(pattern_matches("**/config.json", "config.json"));
626 assert!(pattern_matches("**/config.json", "src/config.json"));
627 assert!(pattern_matches("**/config.json", "a/b/c/config.json"));
628 assert!(!pattern_matches("**/config.json", "src/other.json"));
629 }
630
631 #[test]
632 fn test_double_star_trailing() {
633 // docs/** matches everything inside docs recursively
634 assert!(pattern_matches("docs/**", "docs/readme.md"));
635 assert!(pattern_matches("docs/**", "docs/api/index.md"));
636 assert!(pattern_matches("docs/**", "docs/a/b/c/deep.txt"));
637 assert!(!pattern_matches("docs/**", "other/readme.md"));
638 }
639
640 #[test]
641 fn test_double_star_middle() {
642 // a/**/b matches a/b, a/x/b, a/x/y/b
643 assert!(pattern_matches("a/**/b", "a/b"));
644 assert!(pattern_matches("a/**/b", "a/x/b"));
645 assert!(pattern_matches("a/**/b", "a/x/y/b"));
646 assert!(pattern_matches("a/**/b", "a/x/y/z/b"));
647 assert!(!pattern_matches("a/**/b", "a/x/y/c"));
648 assert!(!pattern_matches("a/**/b", "x/a/b")); // anchored
649 }
650
651 #[test]
652 fn test_double_star_with_extension() {
653 // docs/**/*.md matches all .md files under docs
654 assert!(pattern_matches("docs/**/*.md", "docs/readme.md"));
655 assert!(pattern_matches("docs/**/*.md", "docs/api/spec.md"));
656 assert!(pattern_matches("docs/**/*.md", "docs/a/b/c/guide.md"));
657 assert!(!pattern_matches("docs/**/*.md", "docs/readme.txt"));
658 assert!(!pattern_matches("docs/**/*.md", "src/readme.md"));
659 }
660
661 // ---------------------------------------------------------------------------
662 // CATEGORY 10: CASE SENSITIVITY
663 // ---------------------------------------------------------------------------
664
665 #[test]
666 fn test_case_sensitive_exact() {
667 assert!(pattern_matches("/Docs/", "Docs/readme.md"));
668 assert!(!pattern_matches("/Docs/", "docs/readme.md"));
669 assert!(!pattern_matches("/docs/", "Docs/readme.md"));
670 }
671
672 #[test]
673 fn test_case_sensitive_extension() {
674 assert!(pattern_matches("*.RS", "main.RS"));
675 assert!(!pattern_matches("*.RS", "main.rs"));
676 assert!(!pattern_matches("*.rs", "main.RS"));
677 }
678
679 // ---------------------------------------------------------------------------
680 // CATEGORY 11: UNSUPPORTED FEATURES (should be treated literally or ignored)
681 // ---------------------------------------------------------------------------
682 // Unlike gitignore, CODEOWNERS does NOT support:
683 // - `[abc]` character ranges
684 // - `!pattern` negation
685
686 #[test]
687 fn test_character_range_not_supported() {
688 // [abc] should be treated literally, not as character class
689 // A file literally named "[abc].txt" would match
690 // But "a.txt", "b.txt", "c.txt" should NOT match
691 assert!(!pattern_matches("[abc].txt", "a.txt"));
692 assert!(!pattern_matches("[abc].txt", "b.txt"));
693 assert!(!pattern_matches("[abc].txt", "c.txt"));
694 // Note: matching "[abc].txt" literally is tricky due to glob libs
695 }
696
697 #[test]
698 fn test_negation_not_supported() {
699 // !pattern should NOT work as negation
700 // It should either fail to match or be treated literally
701 // The key is it shouldn't "un-match" files
702 // (Hard to test directly, but we verify it doesn't crash or behave unexpectedly)
703 assert!(!pattern_matches("!docs/", "docs/readme.md"));
704 }
705
706 // ---------------------------------------------------------------------------
707 // CATEGORY 12: COMPOSITE TABLE TEST (from GitHub docs)
708 // ---------------------------------------------------------------------------
709
710 #[test]
711 fn test_github_docs_table() {
712 // From the report:
713 // | Pattern | `/docs/readme.md` | `/docs/api/index.md` | `/src/docs/file.txt` |
714 // |--------------|-------------------|----------------------|----------------------|
715 // | `/docs/` | ✓ | ✓ | ✗ |
716 // | `/docs/*` | ✓ | ✗ | ✗ |
717 // | `/docs/**` | ✓ | ✓ | ✗ |
718 // | `docs/` | ✓ | ✓ | ✓ |
719 // | `**/docs/**` | ✓ | ✓ | ✓ |
720
721 // /docs/ - anchored, recursive
722 assert!(pattern_matches("/docs/", "docs/readme.md"));
723 assert!(pattern_matches("/docs/", "docs/api/index.md"));
724 assert!(!pattern_matches("/docs/", "src/docs/file.txt"));
725
726 // /docs/* - anchored, direct children only
727 assert!(pattern_matches("/docs/*", "docs/readme.md"));
728 assert!(!pattern_matches("/docs/*", "docs/api/index.md"));
729 assert!(!pattern_matches("/docs/*", "src/docs/file.txt"));
730
731 // /docs/** - anchored, recursive via **
732 assert!(pattern_matches("/docs/**", "docs/readme.md"));
733 assert!(pattern_matches("/docs/**", "docs/api/index.md"));
734 assert!(!pattern_matches("/docs/**", "src/docs/file.txt"));
735
736 // docs/ - unanchored, matches anywhere
737 assert!(pattern_matches("docs/", "docs/readme.md"));
738 assert!(pattern_matches("docs/", "docs/api/index.md"));
739 assert!(pattern_matches("docs/", "src/docs/file.txt"));
740
741 // **/docs/** - explicit anywhere match
742 assert!(pattern_matches("**/docs/**", "docs/readme.md"));
743 assert!(pattern_matches("**/docs/**", "docs/api/index.md"));
744 assert!(pattern_matches("**/docs/**", "src/docs/file.txt"));
745 }
746
747 // ---------------------------------------------------------------------------
748 // CATEGORY 13: EDGE CASES
749 // ---------------------------------------------------------------------------
750
751 #[test]
752 fn test_root_files_only() {
753 // /* should match only files at root, not nested
754 assert!(pattern_matches("/*", "readme.md"));
755 assert!(pattern_matches("/*", "Makefile"));
756 // Should NOT match nested files
757 assert!(!pattern_matches("/*", "src/main.rs"));
758 assert!(!pattern_matches("/*", "docs/readme.md"));
759 }
760
761 #[test]
762 fn test_hidden_files() {
763 // Patterns should match hidden files (starting with .)
764 assert!(pattern_matches("*", ".gitignore"));
765 assert!(pattern_matches(".*", ".gitignore"));
766 assert!(pattern_matches(".github/", ".github/workflows/ci.yml"));
767 }
768
769 #[test]
770 fn test_deeply_nested() {
771 let deep = "a/b/c/d/e/f/g/h/i/j/k/file.txt";
772 assert!(pattern_matches("*", deep));
773 assert!(pattern_matches("**", deep));
774 assert!(pattern_matches("a/", deep));
775 assert!(pattern_matches("a/**", deep));
776 assert!(pattern_matches("**/file.txt", deep));
777 assert!(!pattern_matches("a/*", deep)); // * doesn't cross dirs
778 }
779
780 #[test]
781 fn test_empty_path_segments() {
782 // Paths shouldn't have empty segments, but verify no panic
783 // This is more of a robustness test
784 assert!(!pattern_matches("/docs/", ""));
785 assert!(!pattern_matches("*", ""));
786 }
787
788 // ---------------------------------------------------------------------------
789 // SUBSUMPTION TESTS
790 // ---------------------------------------------------------------------------
791 // pattern_subsumes(a, b) = true if everything a matches, b also matches
792 // Used for detecting dead rules in CODEOWNERS
793
794 #[test]
795 fn test_subsumes_identical() {
796 assert!(pattern_subsumes("*.rs", "*.rs"));
797 assert!(pattern_subsumes("/src/", "/src/"));
798 assert!(pattern_subsumes("Makefile", "Makefile"));
799 }
800
801 #[test]
802 fn test_subsumes_catchall() {
803 // * and ** subsume everything - this is the "docs/ @baz" followed by "* @gav" case
804 // where docs/ becomes a dead rule because * matches everything and comes later
805 assert!(pattern_subsumes("*.rs", "*"));
806 assert!(pattern_subsumes("*.go", "*"));
807 assert!(pattern_subsumes("/src/", "*")); // anchored dir
808 assert!(pattern_subsumes("docs/", "*")); // unanchored dir - critical case!
809 assert!(pattern_subsumes("src/lib/", "*")); // unanchored nested dir
810 assert!(pattern_subsumes("Makefile", "*"));
811 assert!(pattern_subsumes("src/main.rs", "*"));
812 assert!(pattern_subsumes("*.rs", "**"));
813 assert!(pattern_subsumes("/src/lib/", "**"));
814 assert!(pattern_subsumes("docs/", "**")); // unanchored dir subsumed by **
815 }
816
817 #[test]
818 fn test_subsumes_dead_rule_scenarios() {
819 // Real-world CODEOWNERS footguns where earlier rules become dead
820 //
821 // Scenario 1: Catch-all at end shadows everything
822 // ```
823 // docs/ @docs-team
824 // * @default-team <- docs/ is now dead, @default-team owns docs/
825 // ```
826 assert!(pattern_subsumes("docs/", "*"));
827
828 // Scenario 2: Specific before general (WRONG order)
829 // ```
830 // /src/auth/ @security
831 // /src/ @backend <- /src/auth/ is dead, @backend owns it
832 // ```
833 assert!(pattern_subsumes("/src/auth/", "/src/"));
834
835 // Scenario 3: Extension pattern before catch-all
836 // ```
837 // *.rs @rust-team
838 // * @default <- *.rs is dead
839 // ```
840 assert!(pattern_subsumes("*.rs", "*"));
841
842 // Scenario 4: NOT dead - unanchored survives anchored
843 // ```
844 // docs/ @docs-team <- still matches src/docs/, lib/docs/, etc.
845 // /docs/ @root-docs <- only matches root docs/
846 // ```
847 assert!(!pattern_subsumes("docs/", "/docs/"));
848 }
849
850 #[test]
851 fn test_subsumes_extension() {
852 // *.rs.bak subsumed by *.bak
853 assert!(pattern_subsumes("*.rs.bak", "*.bak"));
854 assert!(!pattern_subsumes("*.bak", "*.rs.bak"));
855 assert!(!pattern_subsumes("*.rs", "*.go"));
856 }
857
858 #[test]
859 fn test_subsumes_directory() {
860 // /src/lib/ subsumed by /src/
861 assert!(pattern_subsumes("/src/lib/", "/src/"));
862 assert!(pattern_subsumes("src/lib/", "src/"));
863 assert!(pattern_subsumes("/src/lib/", "/src/**"));
864 assert!(!pattern_subsumes("/src/", "/src/lib/"));
865 }
866
867 #[test]
868 fn test_subsumes_file_in_dir() {
869 assert!(pattern_subsumes("src/main.rs", "src/"));
870 assert!(pattern_subsumes("src/main.rs", "src/**"));
871 assert!(!pattern_subsumes("src/main.rs", "lib/"));
872 }
873
874 #[test]
875 fn test_not_subsumed() {
876 assert!(!pattern_subsumes("*.rs", "*.go"));
877 assert!(!pattern_subsumes("/src/", "/lib/"));
878 assert!(!pattern_subsumes("*", "*.rs"));
879 }
880
881 // ---------------------------------------------------------------------------
882 // ANCHORED VS UNANCHORED SUBSUMPTION
883 // ---------------------------------------------------------------------------
884 // Unanchored patterns match MORE files than anchored patterns.
885 // - /docs/ (anchored) IS subsumed by docs/ (unanchored)
886 // - docs/ (unanchored) is NOT subsumed by /docs/ (anchored)
887
888 #[test]
889 fn test_subsumes_anchored_by_unanchored() {
890 // Anchored pattern IS subsumed by unanchored (unanchored matches more)
891 assert!(pattern_subsumes("/docs/", "docs/"));
892 assert!(pattern_subsumes("/src/lib/", "src/lib/"));
893 }
894
895 #[test]
896 fn test_subsumes_unanchored_not_by_anchored() {
897 // Unanchored pattern is NOT subsumed by anchored (unanchored matches nested paths)
898 assert!(!pattern_subsumes("docs/", "/docs/"));
899 assert!(!pattern_subsumes("src/", "/src/"));
900 }
901
902 #[test]
903 fn test_subsumes_both_anchored() {
904 // Both anchored - normal subsumption rules apply
905 assert!(pattern_subsumes("/src/lib/", "/src/"));
906 assert!(!pattern_subsumes("/src/", "/src/lib/"));
907 }
908
909 #[test]
910 fn test_subsumes_both_unanchored() {
911 // Both unanchored - normal subsumption rules apply
912 assert!(pattern_subsumes("src/lib/", "src/"));
913 assert!(!pattern_subsumes("src/", "src/lib/"));
914 }
915
916 // ---------------------------------------------------------------------------
917 // COMPILED PATTERN TESTS
918 // ---------------------------------------------------------------------------
919 // Tests specifically for the CompiledPattern struct
920
921 #[test]
922 fn test_compiled_pattern_match_all() {
923 let p = CompiledPattern::new("*");
924 assert!(p.matches("anything.txt"));
925 assert!(p.matches("a/b/c.txt"));
926
927 let p2 = CompiledPattern::new("**");
928 assert!(p2.matches("anything.txt"));
929 assert!(p2.matches("a/b/c.txt"));
930 }
931
932 #[test]
933 fn test_compiled_pattern_root_files_only() {
934 let p = CompiledPattern::new("/*");
935 assert!(p.matches("readme.md"));
936 assert!(p.matches("Makefile"));
937 assert!(!p.matches("src/main.rs"));
938 assert!(!p.matches("a/b/c.txt"));
939 }
940
941 #[test]
942 fn test_compiled_pattern_single_segment_glob() {
943 let p = CompiledPattern::new("*.rs");
944 assert!(p.matches("main.rs"));
945 assert!(p.matches("src/main.rs"));
946 assert!(p.matches("a/b/c/mod.rs"));
947 assert!(!p.matches("main.go"));
948 }
949
950 #[test]
951 fn test_compiled_pattern_multi_segment_glob() {
952 let p = CompiledPattern::new("src/**/*.rs");
953 assert!(p.matches("src/main.rs"));
954 assert!(p.matches("src/lib/mod.rs"));
955 assert!(!p.matches("lib/main.rs"));
956
957 let p2 = CompiledPattern::new("/docs/*.md");
958 assert!(p2.matches("docs/readme.md"));
959 assert!(!p2.matches("docs/api/readme.md"));
960 }
961
962 #[test]
963 fn test_compiled_pattern_anchored_directory() {
964 let p = CompiledPattern::new("/src/");
965 assert!(p.matches("src/main.rs"));
966 assert!(p.matches("src/lib/mod.rs"));
967 assert!(!p.matches("other/src/file.rs"));
968 }
969
970 #[test]
971 fn test_compiled_pattern_unanchored_directory() {
972 let p = CompiledPattern::new("docs/");
973 assert!(p.matches("docs/readme.md"));
974 assert!(p.matches("src/docs/file.txt"));
975 assert!(p.matches("a/b/docs/deep.txt"));
976 assert!(!p.matches("documentation/readme.md"));
977 }
978
979 #[test]
980 fn test_compiled_pattern_exact() {
981 let p = CompiledPattern::new("/Makefile");
982 assert!(p.matches("Makefile"));
983 assert!(!p.matches("build/Makefile"));
984
985 let p2 = CompiledPattern::new("/src");
986 assert!(p2.matches("src"));
987 assert!(p2.matches("src/main.rs"));
988 assert!(!p2.matches("other/src/file.rs"));
989 }
990
991 #[test]
992 fn test_compiled_pattern_empty_path() {
993 // Empty path should never match anything
994 let p = CompiledPattern::new("*");
995 assert!(!p.matches(""));
996
997 let p2 = CompiledPattern::new("/src/");
998 assert!(!p2.matches(""));
999 }
1000
1001 #[test]
1002 fn test_compiled_pattern_unanchored_dir_exact_match() {
1003 // Unanchored dir pattern matching exact dir name
1004 let p = CompiledPattern::new("docs/");
1005 // Test where path exactly equals the dir (edge case)
1006 assert!(p.matches("docs/file.txt"));
1007 }
1008
1009 #[test]
1010 fn test_compiled_pattern_unanchored_dir_slash_search() {
1011 // Test the /dir/ search path in UnanchoredDirectory
1012 let p = CompiledPattern::new("lib/");
1013 assert!(p.matches("src/lib/file.rs"));
1014 assert!(p.matches("a/b/lib/c/d.rs"));
1015 assert!(!p.matches("library/file.rs"));
1016 }
1017
1018 // ---------------------------------------------------------------------------
1019 // ADDITIONAL EDGE CASES
1020 // ---------------------------------------------------------------------------
1021
1022 #[test]
1023 fn test_subsumes_exact_paths_with_slash() {
1024 // Exact paths containing / are implicitly anchored
1025 // So /src/main.rs and src/main.rs are equivalent
1026 assert!(pattern_subsumes("src/main.rs", "/src/main.rs"));
1027 assert!(pattern_subsumes("/src/main.rs", "src/main.rs"));
1028 }
1029
1030 #[test]
1031 fn test_subsumes_unanchored_file_not_by_anchored_dir() {
1032 // An unanchored file path shouldn't be subsumed by an anchored directory
1033 // because the unanchored file might be somewhere else
1034 // (This tests the !a_anchored && b_anchored case in subsumes)
1035 // Note: "lib/foo.rs" contains / so is implicitly anchored
1036 // For truly unanchored file, use single segment
1037 assert!(!pattern_subsumes("foo.rs", "/src/")); // foo.rs is single segment, implicitly anchored
1038 }
1039
1040 #[test]
1041 fn test_compiled_anchored_star_vs_unanchored() {
1042 // //* (anchored *) should be RootFilesOnly
1043 // /* matches only root level files
1044 let p = CompiledPattern::new("/*");
1045 assert!(matches!(p, CompiledPattern::RootFilesOnly));
1046
1047 // Unanchored * is MatchAll
1048 let p2 = CompiledPattern::new("*");
1049 assert!(matches!(p2, CompiledPattern::MatchAll));
1050 }
1051
1052 #[test]
1053 fn test_anchored_dir_exact_length_match() {
1054 // Test the exact length boundary in AnchoredDirectory
1055 let p = CompiledPattern::new("/src/");
1056
1057 // Path exactly equal to dir (edge case) - matches because it's the dir itself
1058 assert!(p.matches("src")); // Directory pattern matches the directory itself
1059
1060 // With content after
1061 assert!(p.matches("src/file.rs"));
1062
1063 // But not partial prefix
1064 assert!(!p.matches("srcfile.rs")); // "srcfile" != "src/"
1065 }
1066
1067 #[test]
1068 fn test_compiled_unanchored_dir_nested_match() {
1069 // Test the loop in UnanchoredDirectory that checks /dir at various positions (lines 86-89)
1070 let p = CompiledPattern::new("lib/");
1071
1072 // Should match lib at various depths
1073 assert!(p.matches("lib/file.rs")); // root
1074 assert!(p.matches("src/lib/file.rs")); // one level deep
1075 assert!(p.matches("a/b/lib/c.rs")); // two levels deep
1076 assert!(p.matches("x/y/z/lib/deep.txt")); // three levels deep
1077
1078 // Should not match partial names
1079 assert!(!p.matches("libfoo/file.rs"));
1080 assert!(!p.matches("src/libfoo/file.rs"));
1081 }
1082
1083 #[test]
1084 fn test_pattern_matches_unanchored_dir_loop() {
1085 // Test the loop in pattern_matches for unanchored directories (lines 177-182)
1086 // This covers the second check branch in the loop
1087 assert!(pattern_matches("docs/", "project/docs/readme.md"));
1088 assert!(pattern_matches("docs/", "a/b/docs/file.txt"));
1089 assert!(pattern_matches("docs/", "x/docs/y/z.md"));
1090 }
1091
1092 #[test]
1093 fn test_subsumes_extension_not_by_non_extension() {
1094 // Test line 234 - *.rs not subsumed by a non-* pattern
1095 assert!(!pattern_subsumes("*.rs", "src/"));
1096 assert!(!pattern_subsumes("*.rs", "/src/main.rs"));
1097 assert!(!pattern_subsumes("*.js", "docs/"));
1098 }
1099
1100 #[test]
1101 fn test_compiled_exact_as_directory_prefix() {
1102 // Test Exact pattern matching as directory prefix
1103 let p = CompiledPattern::new("/build");
1104
1105 assert!(p.matches("build")); // exact
1106 assert!(p.matches("build/output")); // as prefix
1107 assert!(p.matches("build/a/b/c")); // deep prefix
1108 assert!(!p.matches("builder")); // not prefix with /
1109 assert!(!p.matches("src/build")); // nested - anchored
1110 }
1111}