Skip to main content

fallow_cli/
codeowners.rs

1//! CODEOWNERS file parser and ownership lookup.
2//!
3//! Parses GitHub/GitLab-style CODEOWNERS files and matches file paths
4//! to their owners. Used by `--group-by owner` to group analysis output
5//! by team ownership.
6//!
7//! # Pattern semantics
8//!
9//! CODEOWNERS patterns follow gitignore-like rules:
10//! - `*.js` matches any `.js` file in any directory
11//! - `/docs/*` matches files directly in `docs/` (root-anchored)
12//! - `docs/` matches everything under `docs/`
13//! - Last matching rule wins
14//! - First owner on a multi-owner line is the primary owner
15//!
16//! # GitLab extensions
17//!
18//! GitLab's CODEOWNERS format is a superset of GitHub's. The following
19//! GitLab-only syntax is accepted (though it doesn't affect ownership
20//! lookup beyond propagating the default owners within a section):
21//!
22//! - Section headers: `[Section name]`, `^[Section name]` (optional section),
23//!   `[Section name][N]` (N required approvals)
24//! - Section default owners: `[Section] @owner1 @owner2`. Pattern lines
25//!   inside the section that omit inline owners inherit the section's defaults
26//! - Exclusion patterns: `!path` clears ownership for matching files
27//!   (GitLab 17.10+). A negation that is the last matching rule for a
28//!   file makes it unowned.
29
30use std::path::Path;
31
32use globset::{Glob, GlobSet, GlobSetBuilder};
33
34/// Parsed CODEOWNERS file for ownership lookup.
35#[derive(Debug)]
36pub struct CodeOwners {
37    /// Primary owner per rule, indexed by glob position in the `GlobSet`.
38    /// Empty string for negation rules (see `is_negation`).
39    owners: Vec<String>,
40    /// Original CODEOWNERS pattern per rule (e.g. `/src/` or `*.ts`).
41    /// For negations, the raw pattern is prefixed with `!`.
42    patterns: Vec<String>,
43    /// Whether each rule is a GitLab-style negation (`!path`). A matching
44    /// negation as the last-matching rule clears ownership for that file.
45    is_negation: Vec<bool>,
46    /// Compiled glob patterns for matching.
47    globs: GlobSet,
48}
49
50/// Standard locations to probe for a CODEOWNERS file, in priority order.
51///
52/// Order: root catch-all → GitHub → GitLab → GitHub legacy (`docs/`).
53const PROBE_PATHS: &[&str] = &[
54    "CODEOWNERS",
55    ".github/CODEOWNERS",
56    ".gitlab/CODEOWNERS",
57    "docs/CODEOWNERS",
58];
59
60/// Label for files that match no CODEOWNERS rule.
61pub const UNOWNED_LABEL: &str = "(unowned)";
62
63impl CodeOwners {
64    /// Load and parse a CODEOWNERS file from the given path.
65    pub fn from_file(path: &Path) -> Result<Self, String> {
66        let content = std::fs::read_to_string(path)
67            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
68        Self::parse(&content)
69    }
70
71    /// Auto-probe standard CODEOWNERS locations relative to the project root.
72    ///
73    /// Tries `CODEOWNERS`, `.github/CODEOWNERS`, `.gitlab/CODEOWNERS`, `docs/CODEOWNERS`.
74    pub fn discover(root: &Path) -> Result<Self, String> {
75        for probe in PROBE_PATHS {
76            let path = root.join(probe);
77            if path.is_file() {
78                return Self::from_file(&path);
79            }
80        }
81        Err(format!(
82            "no CODEOWNERS file found (looked for: {}). \
83             Create one of these files or use --group-by directory instead",
84            PROBE_PATHS.join(", ")
85        ))
86    }
87
88    /// Load from a config-specified path, or auto-discover.
89    pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
90        if let Some(p) = config_path {
91            let path = root.join(p);
92            Self::from_file(&path)
93        } else {
94            Self::discover(root)
95        }
96    }
97
98    /// Parse CODEOWNERS content into a lookup structure.
99    pub(crate) fn parse(content: &str) -> Result<Self, String> {
100        let mut builder = GlobSetBuilder::new();
101        let mut owners = Vec::new();
102        let mut patterns = Vec::new();
103        let mut is_negation = Vec::new();
104        let mut section_default_owners: Vec<String> = Vec::new();
105
106        for line in content.lines() {
107            let line = line.trim();
108            if line.is_empty() || line.starts_with('#') {
109                continue;
110            }
111
112            // GitLab section header: `[Name]`, `^[Name]`, `[Name][N]`, optionally
113            // followed by section default owners. Update the running defaults
114            // and move on; section headers never produce a rule.
115            if let Some(defaults) = parse_section_header(line) {
116                section_default_owners = defaults;
117                continue;
118            }
119
120            // GitLab exclusion pattern: `!path` clears ownership for matching files.
121            let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
122                (true, after.trim_start())
123            } else {
124                (false, line)
125            };
126
127            let mut parts = rest.split_whitespace();
128            let Some(pattern) = parts.next() else {
129                continue;
130            };
131            let first_inline_owner = parts.next();
132
133            let effective_owner: &str = if negate {
134                // Negations clear ownership on match, so an owner token is
135                // irrelevant. GitLab doesn't require one anyway.
136                ""
137            } else if let Some(o) = first_inline_owner {
138                o
139            } else if let Some(o) = section_default_owners.first() {
140                o.as_str()
141            } else {
142                // Pattern without owners and no section default, skip.
143                continue;
144            };
145
146            let glob_pattern = translate_pattern(pattern);
147            let glob = Glob::new(&glob_pattern)
148                .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
149
150            builder.add(glob);
151            owners.push(effective_owner.to_string());
152            patterns.push(if negate {
153                format!("!{pattern}")
154            } else {
155                pattern.to_string()
156            });
157            is_negation.push(negate);
158        }
159
160        let globs = builder
161            .build()
162            .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
163
164        Ok(Self {
165            owners,
166            patterns,
167            is_negation,
168            globs,
169        })
170    }
171
172    /// Look up the primary owner of a file path (relative to project root).
173    ///
174    /// Returns the first owner from the last matching CODEOWNERS rule,
175    /// or `None` if no rule matches or the last matching rule is a
176    /// GitLab-style exclusion (`!path`).
177    pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
178        let matches = self.globs.matches(relative_path);
179        // Last match wins: highest index = last rule in file order
180        matches.iter().max().and_then(|&idx| {
181            if self.is_negation[idx] {
182                None
183            } else {
184                Some(self.owners[idx].as_str())
185            }
186        })
187    }
188
189    /// Look up the primary owner and the original CODEOWNERS pattern for a path.
190    ///
191    /// Returns `(owner, pattern)` from the last matching rule, or `None` if
192    /// no rule matches or the last matching rule is a GitLab-style exclusion.
193    /// The pattern is the raw string from the CODEOWNERS file (e.g. `/src/`
194    /// or `*.ts`).
195    pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
196        let matches = self.globs.matches(relative_path);
197        matches.iter().max().and_then(|&idx| {
198            if self.is_negation[idx] {
199                None
200            } else {
201                Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
202            }
203        })
204    }
205}
206
207/// Parse a GitLab CODEOWNERS section header.
208///
209/// Recognized forms (all optionally prefixed with `^` for optional sections):
210/// - `[Section name]`
211/// - `[Section name][N]` (N required approvals)
212/// - `[Section name] @owner1 @owner2` (section default owners)
213/// - `^[Section name][N] @owner` (any combination of the above)
214///
215/// Returns `Some(default_owners)` if the line is a well-formed section header
216/// (the returned vec is empty when the header declares no default owners),
217/// or `None` when the line is not a section header and should be parsed as a
218/// rule instead. Detection is strict: a line like `[abc]def @owner` that has
219/// non-whitespace content directly after the closing `]` is not treated as a
220/// section header, so legacy GitHub CODEOWNERS patterns continue to parse.
221fn parse_section_header(line: &str) -> Option<Vec<String>> {
222    let rest = line.strip_prefix('^').unwrap_or(line);
223    let rest = rest.strip_prefix('[')?;
224    let close = rest.find(']')?;
225    let name = &rest[..close];
226    if name.is_empty() {
227        return None;
228    }
229    let mut after = &rest[close + 1..];
230
231    // Optional `[N]` approval count.
232    if let Some(inner) = after.strip_prefix('[') {
233        let n_close = inner.find(']')?;
234        let count = &inner[..n_close];
235        if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
236            return None;
237        }
238        after = &inner[n_close + 1..];
239    }
240
241    // The remainder must be empty or start with whitespace. Otherwise this
242    // line isn't a section header, e.g. `[abc]def @owner` stays a rule.
243    if !after.is_empty() && !after.starts_with(char::is_whitespace) {
244        return None;
245    }
246
247    Some(after.split_whitespace().map(String::from).collect())
248}
249
250/// Translate a CODEOWNERS pattern to a `globset`-compatible glob pattern.
251///
252/// CODEOWNERS uses gitignore-like semantics:
253/// - Leading `/` anchors to root (stripped for globset)
254/// - Trailing `/` means directory contents (`dir/` → `dir/**`)
255/// - No `/` in pattern: matches in any directory (`*.js` → `**/*.js`)
256/// - Contains `/` (non-trailing): root-relative as-is
257fn translate_pattern(pattern: &str) -> String {
258    // Strip leading `/` — globset matches from root by default
259    let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
260        (true, p)
261    } else {
262        (false, pattern)
263    };
264
265    // Trailing `/` means directory contents
266    let expanded = if let Some(p) = rest.strip_suffix('/') {
267        format!("{p}/**")
268    } else {
269        rest.to_string()
270    };
271
272    // If not anchored and no directory separator, match in any directory
273    if !anchored && !expanded.contains('/') {
274        format!("**/{expanded}")
275    } else {
276        expanded
277    }
278}
279
280/// Extract the first path component for `--group-by directory` grouping.
281///
282/// Returns the first directory segment of a relative path.
283/// For monorepo structures (`packages/auth/...`), returns `packages`.
284pub fn directory_group(relative_path: &Path) -> &str {
285    let s = relative_path.to_str().unwrap_or("");
286    // Use forward-slash normalized path
287    let s = if s.contains('\\') {
288        // Windows paths: handled by caller normalizing, but be safe
289        return s.split(['/', '\\']).next().unwrap_or(s);
290    } else {
291        s
292    };
293
294    match s.find('/') {
295        Some(pos) => &s[..pos],
296        None => s, // Root-level file
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::path::PathBuf;
304
305    // ── translate_pattern ──────────────────────────────────────────
306
307    #[test]
308    fn translate_bare_glob() {
309        assert_eq!(translate_pattern("*.js"), "**/*.js");
310    }
311
312    #[test]
313    fn translate_rooted_pattern() {
314        assert_eq!(translate_pattern("/docs/*"), "docs/*");
315    }
316
317    #[test]
318    fn translate_directory_pattern() {
319        assert_eq!(translate_pattern("docs/"), "docs/**");
320    }
321
322    #[test]
323    fn translate_rooted_directory() {
324        assert_eq!(translate_pattern("/src/app/"), "src/app/**");
325    }
326
327    #[test]
328    fn translate_path_with_slash() {
329        assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
330    }
331
332    #[test]
333    fn translate_double_star() {
334        // Pattern already contains `/`, so it's root-relative — no extra prefix
335        assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
336    }
337
338    #[test]
339    fn translate_single_file() {
340        assert_eq!(translate_pattern("Makefile"), "**/Makefile");
341    }
342
343    // ── parse ──────────────────────────────────────────────────────
344
345    #[test]
346    fn parse_simple_codeowners() {
347        let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
348        let co = CodeOwners::parse(content).unwrap();
349        assert_eq!(co.owners.len(), 3);
350    }
351
352    #[test]
353    fn parse_skips_comments_and_blanks() {
354        let content = "# Comment\n\n* @owner\n  # Indented comment\n";
355        let co = CodeOwners::parse(content).unwrap();
356        assert_eq!(co.owners.len(), 1);
357    }
358
359    #[test]
360    fn parse_multi_owner_takes_first() {
361        let content = "*.ts @team-a @team-b @team-c\n";
362        let co = CodeOwners::parse(content).unwrap();
363        assert_eq!(co.owners[0], "@team-a");
364    }
365
366    #[test]
367    fn parse_skips_pattern_without_owner() {
368        let content = "*.ts\n*.js @owner\n";
369        let co = CodeOwners::parse(content).unwrap();
370        assert_eq!(co.owners.len(), 1);
371        assert_eq!(co.owners[0], "@owner");
372    }
373
374    #[test]
375    fn parse_empty_content() {
376        let co = CodeOwners::parse("").unwrap();
377        assert_eq!(co.owner_of(Path::new("anything.ts")), None);
378    }
379
380    // ── owner_of ───────────────────────────────────────────────────
381
382    #[test]
383    fn owner_of_last_match_wins() {
384        let content = "* @default\n/src/ @frontend\n";
385        let co = CodeOwners::parse(content).unwrap();
386        assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
387    }
388
389    #[test]
390    fn owner_of_falls_back_to_catch_all() {
391        let content = "* @default\n/src/ @frontend\n";
392        let co = CodeOwners::parse(content).unwrap();
393        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
394    }
395
396    #[test]
397    fn owner_of_no_match_returns_none() {
398        let content = "/src/ @frontend\n";
399        let co = CodeOwners::parse(content).unwrap();
400        assert_eq!(co.owner_of(Path::new("README.md")), None);
401    }
402
403    #[test]
404    fn owner_of_extension_glob() {
405        let content = "*.rs @rust-team\n*.ts @ts-team\n";
406        let co = CodeOwners::parse(content).unwrap();
407        assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
408        assert_eq!(
409            co.owner_of(Path::new("packages/ui/Button.ts")),
410            Some("@ts-team")
411        );
412    }
413
414    #[test]
415    fn owner_of_nested_directory() {
416        let content = "* @default\n/packages/auth/ @auth-team\n";
417        let co = CodeOwners::parse(content).unwrap();
418        assert_eq!(
419            co.owner_of(Path::new("packages/auth/src/login.ts")),
420            Some("@auth-team")
421        );
422        assert_eq!(
423            co.owner_of(Path::new("packages/ui/Button.ts")),
424            Some("@default")
425        );
426    }
427
428    #[test]
429    fn owner_of_specific_overrides_general() {
430        // Later, more specific rule wins
431        let content = "\
432            * @default\n\
433            /src/ @frontend\n\
434            /src/api/ @backend\n\
435        ";
436        let co = CodeOwners::parse(content).unwrap();
437        assert_eq!(
438            co.owner_of(Path::new("src/api/routes.ts")),
439            Some("@backend")
440        );
441        assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
442    }
443
444    // ── owner_and_rule_of ──────────────────────────────────────────
445
446    #[test]
447    fn owner_and_rule_of_returns_owner_and_pattern() {
448        let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
449        let co = CodeOwners::parse(content).unwrap();
450        assert_eq!(
451            co.owner_and_rule_of(Path::new("src/app.ts")),
452            Some(("@frontend", "/src/"))
453        );
454        assert_eq!(
455            co.owner_and_rule_of(Path::new("src/lib.rs")),
456            Some(("@rust-team", "*.rs"))
457        );
458        assert_eq!(
459            co.owner_and_rule_of(Path::new("README.md")),
460            Some(("@default", "*"))
461        );
462    }
463
464    #[test]
465    fn owner_and_rule_of_no_match() {
466        let content = "/src/ @frontend\n";
467        let co = CodeOwners::parse(content).unwrap();
468        assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
469    }
470
471    // ── directory_group ────────────────────────────────────────────
472
473    #[test]
474    fn directory_group_simple() {
475        assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
476    }
477
478    #[test]
479    fn directory_group_root_file() {
480        assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
481    }
482
483    #[test]
484    fn directory_group_monorepo() {
485        assert_eq!(
486            directory_group(Path::new("packages/auth/src/login.ts")),
487            "packages"
488        );
489    }
490
491    // ── discover ───────────────────────────────────────────────────
492
493    #[test]
494    fn discover_nonexistent_root() {
495        let result = CodeOwners::discover(Path::new("/nonexistent/path"));
496        assert!(result.is_err());
497        let err = result.unwrap_err();
498        assert!(err.contains("no CODEOWNERS file found"));
499        assert!(err.contains("--group-by directory"));
500    }
501
502    // ── from_file ──────────────────────────────────────────────────
503
504    #[test]
505    fn from_file_nonexistent() {
506        let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
507        assert!(result.is_err());
508    }
509
510    #[test]
511    fn from_file_real_codeowners() {
512        // Use the project's own CODEOWNERS file
513        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
514            .parent()
515            .unwrap()
516            .parent()
517            .unwrap()
518            .to_path_buf();
519        let path = root.join(".github/CODEOWNERS");
520        if path.exists() {
521            let co = CodeOwners::from_file(&path).unwrap();
522            // Our CODEOWNERS has `* @bartwaardenburg`
523            assert_eq!(
524                co.owner_of(Path::new("src/anything.ts")),
525                Some("@bartwaardenburg")
526            );
527        }
528    }
529
530    // ── edge cases ─────────────────────────────────────────────────
531
532    #[test]
533    fn email_owner() {
534        let content = "*.js user@example.com\n";
535        let co = CodeOwners::parse(content).unwrap();
536        assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
537    }
538
539    #[test]
540    fn team_owner() {
541        let content = "*.ts @org/frontend-team\n";
542        let co = CodeOwners::parse(content).unwrap();
543        assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
544    }
545
546    // ── GitLab section headers ─────────────────────────────────────
547
548    #[test]
549    fn gitlab_section_header_skipped_as_rule() {
550        // Previously produced: `invalid CODEOWNERS pattern '[Section'`.
551        let content = "[Section Name]\n*.ts @owner\n";
552        let co = CodeOwners::parse(content).unwrap();
553        assert_eq!(co.owners.len(), 1);
554        assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
555    }
556
557    #[test]
558    fn gitlab_optional_section_header_skipped() {
559        let content = "^[Optional Section]\n*.ts @owner\n";
560        let co = CodeOwners::parse(content).unwrap();
561        assert_eq!(co.owners.len(), 1);
562    }
563
564    #[test]
565    fn gitlab_section_header_with_approval_count_skipped() {
566        let content = "[Section Name][2]\n*.ts @owner\n";
567        let co = CodeOwners::parse(content).unwrap();
568        assert_eq!(co.owners.len(), 1);
569    }
570
571    #[test]
572    fn gitlab_optional_section_with_approval_count_skipped() {
573        let content = "^[Section Name][3] @fallback-team\nfoo/\n";
574        let co = CodeOwners::parse(content).unwrap();
575        assert_eq!(co.owners.len(), 1);
576        assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
577    }
578
579    #[test]
580    fn gitlab_section_default_owners_inherited() {
581        let content = "\
582            [Utilities] @utils-team\n\
583            src/utils/\n\
584            [UI Components] @ui-team\n\
585            src/components/\n\
586        ";
587        let co = CodeOwners::parse(content).unwrap();
588        assert_eq!(co.owners.len(), 2);
589        assert_eq!(
590            co.owner_of(Path::new("src/utils/greet.ts")),
591            Some("@utils-team")
592        );
593        assert_eq!(
594            co.owner_of(Path::new("src/components/button.ts")),
595            Some("@ui-team")
596        );
597    }
598
599    #[test]
600    fn gitlab_inline_owner_overrides_section_default() {
601        let content = "\
602            [Section] @section-owner\n\
603            src/generic/\n\
604            src/special/ @special-owner\n\
605        ";
606        let co = CodeOwners::parse(content).unwrap();
607        assert_eq!(
608            co.owner_of(Path::new("src/generic/a.ts")),
609            Some("@section-owner")
610        );
611        assert_eq!(
612            co.owner_of(Path::new("src/special/a.ts")),
613            Some("@special-owner")
614        );
615    }
616
617    #[test]
618    fn gitlab_section_defaults_reset_between_sections() {
619        // Section1 declares @team-a. Section2 declares no defaults. A bare
620        // pattern inside Section2 inherits nothing and is dropped.
621        let content = "\
622            [Section1] @team-a\n\
623            foo/\n\
624            [Section2]\n\
625            bar/\n\
626        ";
627        let co = CodeOwners::parse(content).unwrap();
628        assert_eq!(co.owners.len(), 1);
629        assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
630        assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
631    }
632
633    #[test]
634    fn gitlab_section_header_multiple_default_owners_uses_first() {
635        let content = "[Section] @first @second\nfoo/\n";
636        let co = CodeOwners::parse(content).unwrap();
637        assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
638    }
639
640    #[test]
641    fn gitlab_rules_before_first_section_retain_inline_owners() {
642        // Matches the reproduction in issue #127: rules before the first
643        // section header use their own inline owners.
644        let content = "\
645            * @default-owner\n\
646            [Utilities] @utils-team\n\
647            src/utils/\n\
648        ";
649        let co = CodeOwners::parse(content).unwrap();
650        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
651        assert_eq!(
652            co.owner_of(Path::new("src/utils/greet.ts")),
653            Some("@utils-team")
654        );
655    }
656
657    #[test]
658    fn gitlab_issue_127_reproduction() {
659        // Verbatim CODEOWNERS from issue #127.
660        let content = "\
661# Default section (no header, rules before first section)
662* @default-owner
663
664[Utilities] @utils-team
665src/utils/
666
667[UI Components] @ui-team
668src/components/
669";
670        let co = CodeOwners::parse(content).unwrap();
671        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
672        assert_eq!(
673            co.owner_of(Path::new("src/utils/greet.ts")),
674            Some("@utils-team")
675        );
676        assert_eq!(
677            co.owner_of(Path::new("src/components/button.ts")),
678            Some("@ui-team")
679        );
680    }
681
682    // ── GitLab exclusion patterns (negation) ───────────────────────
683
684    #[test]
685    fn gitlab_negation_last_match_clears_ownership() {
686        let content = "\
687            * @default\n\
688            !src/generated/\n\
689        ";
690        let co = CodeOwners::parse(content).unwrap();
691        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
692        assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
693    }
694
695    #[test]
696    fn gitlab_negation_only_clears_when_last_match() {
697        // A more specific positive rule after the negation wins again.
698        let content = "\
699            * @default\n\
700            !src/\n\
701            /src/special/ @special\n\
702        ";
703        let co = CodeOwners::parse(content).unwrap();
704        assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
705        assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
706    }
707
708    #[test]
709    fn gitlab_negation_owner_and_rule_returns_none() {
710        let content = "* @default\n!src/vendor/\n";
711        let co = CodeOwners::parse(content).unwrap();
712        assert_eq!(
713            co.owner_and_rule_of(Path::new("README.md")),
714            Some(("@default", "*"))
715        );
716        assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
717    }
718
719    // ── section header parser ──────────────────────────────────────
720
721    #[test]
722    fn parse_section_header_variants() {
723        assert_eq!(parse_section_header("[Section]"), Some(vec![]));
724        assert_eq!(parse_section_header("^[Section]"), Some(vec![]));
725        assert_eq!(parse_section_header("[Section][2]"), Some(vec![]));
726        assert_eq!(parse_section_header("^[Section][2]"), Some(vec![]));
727        assert_eq!(
728            parse_section_header("[Section] @a @b"),
729            Some(vec!["@a".into(), "@b".into()])
730        );
731        assert_eq!(
732            parse_section_header("[Section][2] @a"),
733            Some(vec!["@a".into()])
734        );
735    }
736
737    #[test]
738    fn parse_section_header_rejects_malformed() {
739        // Not a section header; should parse as a rule elsewhere.
740        assert_eq!(parse_section_header("[unclosed"), None);
741        assert_eq!(parse_section_header("[]"), None);
742        assert_eq!(parse_section_header("[abc]def @owner"), None);
743        assert_eq!(parse_section_header("[Section][] @owner"), None);
744        assert_eq!(parse_section_header("[Section][abc] @owner"), None);
745    }
746
747    #[test]
748    fn non_section_bracket_pattern_parses_as_rule() {
749        // `[abc]def` is not a section header (non-whitespace after `]`),
750        // so it falls through to regular glob parsing as a character class.
751        let content = "[abc]def @owner\n";
752        let co = CodeOwners::parse(content).unwrap();
753        assert_eq!(co.owners.len(), 1);
754        assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
755    }
756}