Skip to main content

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}