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    /// Number of owners matched by each rule, indexed by glob position.
41    /// Zero for negation rules.
42    owner_counts: Vec<u32>,
43    /// Original CODEOWNERS pattern per rule (e.g. `/src/` or `*.ts`).
44    /// For negations, the raw pattern is prefixed with `!`.
45    patterns: Vec<String>,
46    /// Whether each rule is a GitLab-style negation (`!path`). A matching
47    /// negation as the last-matching rule clears ownership for that file.
48    is_negation: Vec<bool>,
49    /// GitLab section name per rule, or `None` for rules that appear before
50    /// the first section header. Used by `--group-by section`.
51    sections: Vec<Option<String>>,
52    /// Section default owners per rule (cloned from the active section
53    /// header). Empty for rules outside any section, used as metadata in
54    /// JSON output for `--group-by section`.
55    section_owners: Vec<Vec<String>>,
56    /// Whether the file contains at least one GitLab section header.
57    has_sections: bool,
58    /// Compiled glob patterns for matching.
59    globs: GlobSet,
60}
61
62/// Standard locations to probe for a CODEOWNERS file, in priority order.
63///
64/// Order: root catch-all → GitHub → GitLab → GitHub legacy (`docs/`).
65const PROBE_PATHS: &[&str] = &[
66    "CODEOWNERS",
67    ".github/CODEOWNERS",
68    ".gitlab/CODEOWNERS",
69    "docs/CODEOWNERS",
70];
71
72/// Label for files that match no CODEOWNERS rule.
73pub const UNOWNED_LABEL: &str = "(unowned)";
74
75/// Label for files owned by a rule declared before any GitLab section header.
76///
77/// Used as the group key for `--group-by section` when the last matching rule
78/// isn't inside any `[Section]` block.
79pub const NO_SECTION_LABEL: &str = "(no section)";
80
81impl CodeOwners {
82    /// Load and parse a CODEOWNERS file from the given path.
83    pub fn from_file(path: &Path) -> Result<Self, String> {
84        let content = std::fs::read_to_string(path)
85            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
86        Self::parse(&content)
87    }
88
89    /// Auto-probe standard CODEOWNERS locations relative to the project root.
90    ///
91    /// Tries `CODEOWNERS`, `.github/CODEOWNERS`, `.gitlab/CODEOWNERS`, `docs/CODEOWNERS`.
92    pub fn discover(root: &Path) -> Result<Self, String> {
93        for probe in PROBE_PATHS {
94            let path = root.join(probe);
95            if path.is_file() {
96                return Self::from_file(&path);
97            }
98        }
99        Err(format!(
100            "no CODEOWNERS file found (looked for: {}). \
101             Create one of these files or use --group-by directory instead",
102            PROBE_PATHS.join(", ")
103        ))
104    }
105
106    /// Load from a config-specified path, or auto-discover.
107    pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
108        if let Some(p) = config_path {
109            let path = root.join(p);
110            Self::from_file(&path)
111        } else {
112            Self::discover(root)
113        }
114    }
115
116    /// Parse CODEOWNERS content into a lookup structure.
117    pub(crate) fn parse(content: &str) -> Result<Self, String> {
118        let mut parser = CodeOwnersParser::new();
119        for line in content.lines() {
120            parser.parse_line(line)?;
121        }
122        parser.finish()
123    }
124
125    /// Look up the primary owner of a file path (relative to project root).
126    ///
127    /// Returns the first owner from the last matching CODEOWNERS rule,
128    /// or `None` if no rule matches or the last matching rule is a
129    /// GitLab-style exclusion (`!path`).
130    pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
131        let matches = self.globs.matches(relative_path);
132        matches.iter().max().and_then(|&idx| {
133            if self.is_negation[idx] {
134                None
135            } else {
136                Some(self.owners[idx].as_str())
137            }
138        })
139    }
140
141    /// Look up the number of owners matched by the last matching CODEOWNERS rule.
142    ///
143    /// Returns `Some(0)` when the path is explicitly unowned by a GitLab
144    /// negation, and `None` when no CODEOWNERS rule matches.
145    pub fn owner_count_of(&self, relative_path: &Path) -> Option<u32> {
146        let matches = self.globs.matches(relative_path);
147        matches.iter().max().map(|&idx| {
148            if self.is_negation[idx] {
149                0
150            } else {
151                self.owner_counts[idx]
152            }
153        })
154    }
155
156    /// Look up the primary owner and the original CODEOWNERS pattern for a path.
157    ///
158    /// Returns `(owner, pattern)` from the last matching rule, or `None` if
159    /// no rule matches or the last matching rule is a GitLab-style exclusion.
160    /// The pattern is the raw string from the CODEOWNERS file (e.g. `/src/`
161    /// or `*.ts`).
162    pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
163        let matches = self.globs.matches(relative_path);
164        matches.iter().max().and_then(|&idx| {
165            if self.is_negation[idx] {
166                None
167            } else {
168                Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
169            }
170        })
171    }
172
173    /// Look up the GitLab CODEOWNERS section that owns a file.
174    ///
175    /// Returns `Some(Some(name))` when the last matching rule is inside a
176    /// named section, `Some(None)` when the rule appears before any section
177    /// header, or `None` when no rule matches or the last match is a
178    /// GitLab-style exclusion.
179    #[allow(
180        clippy::option_option,
181        reason = "three distinct states: no match, matched pre-section, matched in named section"
182    )]
183    pub fn section_of(&self, relative_path: &Path) -> Option<Option<&str>> {
184        let matches = self.globs.matches(relative_path);
185        matches.iter().max().and_then(|&idx| {
186            if self.is_negation[idx] {
187                None
188            } else {
189                Some(self.sections[idx].as_deref())
190            }
191        })
192    }
193
194    /// Look up the section name plus the section's default owners for a file.
195    ///
196    /// Used by `--group-by section` to attach owner metadata to each group.
197    /// Returns `None` when no rule matches or the last match is a negation.
198    /// The returned owner slice is empty for rules declared outside any
199    /// section or for sections that declare no default owners.
200    pub fn section_and_owners_of(&self, relative_path: &Path) -> Option<(Option<&str>, &[String])> {
201        let matches = self.globs.matches(relative_path);
202        matches.iter().max().and_then(|&idx| {
203            if self.is_negation[idx] {
204                None
205            } else {
206                Some((
207                    self.sections[idx].as_deref(),
208                    self.section_owners[idx].as_slice(),
209                ))
210            }
211        })
212    }
213
214    /// Look up section, section owners, and the raw CODEOWNERS pattern in one
215    /// glob pass.
216    ///
217    /// Used by `--group-by section` display paths that need both the section
218    /// key and the matching rule text without walking the `GlobSet` twice.
219    pub fn section_owners_and_rule_of(
220        &self,
221        relative_path: &Path,
222    ) -> Option<(Option<&str>, &[String], &str)> {
223        let matches = self.globs.matches(relative_path);
224        matches.iter().max().and_then(|&idx| {
225            if self.is_negation[idx] {
226                None
227            } else {
228                Some((
229                    self.sections[idx].as_deref(),
230                    self.section_owners[idx].as_slice(),
231                    self.patterns[idx].as_str(),
232                ))
233            }
234        })
235    }
236
237    /// Whether the parsed file contains at least one GitLab section header.
238    ///
239    /// `--group-by section` errors out when this is false, since every file
240    /// would collapse into the `(no section)` bucket.
241    pub fn has_sections(&self) -> bool {
242        self.has_sections
243    }
244}
245
246struct CodeOwnersParser {
247    builder: GlobSetBuilder,
248    owners: Vec<String>,
249    owner_counts: Vec<u32>,
250    patterns: Vec<String>,
251    is_negation: Vec<bool>,
252    sections: Vec<Option<String>>,
253    section_owners: Vec<Vec<String>>,
254    current_section: Option<String>,
255    current_section_owners: Vec<String>,
256    has_sections: bool,
257}
258
259impl CodeOwnersParser {
260    fn new() -> Self {
261        Self {
262            builder: GlobSetBuilder::new(),
263            owners: Vec::new(),
264            owner_counts: Vec::new(),
265            patterns: Vec::new(),
266            is_negation: Vec::new(),
267            sections: Vec::new(),
268            section_owners: Vec::new(),
269            current_section: None,
270            current_section_owners: Vec::new(),
271            has_sections: false,
272        }
273    }
274
275    fn parse_line(&mut self, line: &str) -> Result<(), String> {
276        match parse_codeowners_line(line, &self.current_section_owners) {
277            ParsedCodeOwnersLine::Skip => Ok(()),
278            ParsedCodeOwnersLine::Section { name, defaults } => {
279                self.current_section = Some(name);
280                self.current_section_owners = defaults;
281                self.has_sections = true;
282                Ok(())
283            }
284            ParsedCodeOwnersLine::Rule {
285                pattern,
286                owner,
287                owner_count,
288                negate,
289            } => self.add_rule(pattern, owner, owner_count, negate),
290        }
291    }
292
293    fn add_rule(
294        &mut self,
295        pattern: String,
296        owner: String,
297        owner_count: u32,
298        negate: bool,
299    ) -> Result<(), String> {
300        let glob_pattern = translate_pattern(&pattern);
301        let glob = Glob::new(&glob_pattern)
302            .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
303
304        self.builder.add(glob);
305        self.owners.push(owner);
306        self.owner_counts.push(owner_count);
307        self.patterns.push(if negate {
308            format!("!{pattern}")
309        } else {
310            pattern
311        });
312        self.is_negation.push(negate);
313        self.sections.push(self.current_section.clone());
314        self.section_owners
315            .push(self.current_section_owners.clone());
316        Ok(())
317    }
318
319    fn finish(self) -> Result<CodeOwners, String> {
320        let globs = self
321            .builder
322            .build()
323            .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
324
325        Ok(CodeOwners {
326            owners: self.owners,
327            owner_counts: self.owner_counts,
328            patterns: self.patterns,
329            is_negation: self.is_negation,
330            sections: self.sections,
331            section_owners: self.section_owners,
332            has_sections: self.has_sections,
333            globs,
334        })
335    }
336}
337
338enum ParsedCodeOwnersLine {
339    Skip,
340    Section {
341        name: String,
342        defaults: Vec<String>,
343    },
344    Rule {
345        pattern: String,
346        owner: String,
347        owner_count: u32,
348        negate: bool,
349    },
350}
351
352fn parse_codeowners_line(line: &str, current_section_owners: &[String]) -> ParsedCodeOwnersLine {
353    let line = line.trim();
354    if line.is_empty() || line.starts_with('#') {
355        return ParsedCodeOwnersLine::Skip;
356    }
357
358    if let Some((name, defaults)) = parse_section_header(line) {
359        return ParsedCodeOwnersLine::Section { name, defaults };
360    }
361
362    let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
363        (true, after.trim_start())
364    } else {
365        (false, line)
366    };
367
368    let mut parts = rest.split_whitespace();
369    let Some(pattern) = parts.next() else {
370        return ParsedCodeOwnersLine::Skip;
371    };
372    let inline_owners = parts.collect::<Vec<_>>();
373
374    let (owner, owner_count) = if negate {
375        (String::new(), 0)
376    } else if let Some(owner) = inline_owners.first() {
377        (
378            (*owner).to_string(),
379            u32::try_from(inline_owners.len()).unwrap_or(u32::MAX),
380        )
381    } else if let Some(owner) = current_section_owners.first() {
382        (
383            owner.clone(),
384            u32::try_from(current_section_owners.len()).unwrap_or(u32::MAX),
385        )
386    } else {
387        return ParsedCodeOwnersLine::Skip;
388    };
389
390    ParsedCodeOwnersLine::Rule {
391        pattern: pattern.to_string(),
392        owner,
393        owner_count,
394        negate,
395    }
396}
397
398/// Parse a GitLab CODEOWNERS section header.
399///
400/// Recognized forms (all optionally prefixed with `^` for optional sections):
401/// - `[Section name]`
402/// - `[Section name][N]` (N required approvals)
403/// - `[Section name] @owner1 @owner2` (section default owners)
404/// - `^[Section name][N] @owner` (any combination of the above)
405///
406/// Returns `Some((name, default_owners))` if the line is a well-formed section
407/// header. The returned owner vec is empty when the header declares no default
408/// owners. Returns `None` when the line is not a section header and should be
409/// parsed as a rule instead. Detection is strict: a line like `[abc]def @owner`
410/// that has non-whitespace content directly after the closing `]` is not
411/// treated as a section header, so legacy GitHub CODEOWNERS patterns continue
412/// to parse.
413fn parse_section_header(line: &str) -> Option<(String, Vec<String>)> {
414    let rest = line.strip_prefix('^').unwrap_or(line);
415    let rest = rest.strip_prefix('[')?;
416    let close = rest.find(']')?;
417    let name = &rest[..close];
418    if name.is_empty() {
419        return None;
420    }
421    let mut after = &rest[close + 1..];
422
423    if let Some(inner) = after.strip_prefix('[') {
424        let n_close = inner.find(']')?;
425        let count = &inner[..n_close];
426        if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
427            return None;
428        }
429        after = &inner[n_close + 1..];
430    }
431
432    if !after.is_empty() && !after.starts_with(char::is_whitespace) {
433        return None;
434    }
435
436    Some((
437        name.to_string(),
438        after.split_whitespace().map(String::from).collect(),
439    ))
440}
441
442/// Translate a CODEOWNERS pattern to a `globset`-compatible glob pattern.
443///
444/// CODEOWNERS uses gitignore-like semantics:
445/// - Leading `/` anchors to root (stripped for globset)
446/// - Trailing `/` means directory contents (`dir/` → `dir/**`)
447/// - No `/` in pattern: matches in any directory (`*.js` → `**/*.js`)
448/// - Contains `/` (non-trailing): root-relative as-is
449fn translate_pattern(pattern: &str) -> String {
450    let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
451        (true, p)
452    } else {
453        (false, pattern)
454    };
455
456    let expanded = if let Some(p) = rest.strip_suffix('/') {
457        format!("{p}/**")
458    } else {
459        rest.to_string()
460    };
461
462    if !anchored && !expanded.contains('/') {
463        format!("**/{expanded}")
464    } else {
465        expanded
466    }
467}
468
469/// Extract the first path component for `--group-by directory` grouping.
470///
471/// Returns the first directory segment of a relative path.
472/// For monorepo structures (`packages/auth/...`), returns `packages`.
473pub fn directory_group(relative_path: &Path) -> &str {
474    let s = relative_path.to_str().unwrap_or("");
475    let s = if s.contains('\\') {
476        return s.split(['/', '\\']).next().unwrap_or(s);
477    } else {
478        s
479    };
480
481    match s.find('/') {
482        Some(pos) => &s[..pos],
483        None => s, // Root-level file
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use std::path::PathBuf;
491
492    #[test]
493    fn translate_bare_glob() {
494        assert_eq!(translate_pattern("*.js"), "**/*.js");
495    }
496
497    #[test]
498    fn translate_rooted_pattern() {
499        assert_eq!(translate_pattern("/docs/*"), "docs/*");
500    }
501
502    #[test]
503    fn translate_directory_pattern() {
504        assert_eq!(translate_pattern("docs/"), "docs/**");
505    }
506
507    #[test]
508    fn translate_rooted_directory() {
509        assert_eq!(translate_pattern("/src/app/"), "src/app/**");
510    }
511
512    #[test]
513    fn translate_path_with_slash() {
514        assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
515    }
516
517    #[test]
518    fn translate_double_star() {
519        assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
520    }
521
522    #[test]
523    fn translate_single_file() {
524        assert_eq!(translate_pattern("Makefile"), "**/Makefile");
525    }
526
527    #[test]
528    fn parse_simple_codeowners() {
529        let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
530        let co = CodeOwners::parse(content).unwrap();
531        assert_eq!(co.owners.len(), 3);
532    }
533
534    #[test]
535    fn parse_skips_comments_and_blanks() {
536        let content = "# Comment\n\n* @owner\n  # Indented comment\n";
537        let co = CodeOwners::parse(content).unwrap();
538        assert_eq!(co.owners.len(), 1);
539    }
540
541    #[test]
542    fn parse_multi_owner_takes_first() {
543        let content = "*.ts @team-a @team-b @team-c\n";
544        let co = CodeOwners::parse(content).unwrap();
545        assert_eq!(co.owners[0], "@team-a");
546    }
547
548    #[test]
549    fn parse_skips_pattern_without_owner() {
550        let content = "*.ts\n*.js @owner\n";
551        let co = CodeOwners::parse(content).unwrap();
552        assert_eq!(co.owners.len(), 1);
553        assert_eq!(co.owners[0], "@owner");
554    }
555
556    #[test]
557    fn parse_empty_content() {
558        let co = CodeOwners::parse("").unwrap();
559        assert_eq!(co.owner_of(Path::new("anything.ts")), None);
560    }
561
562    #[test]
563    fn owner_of_last_match_wins() {
564        let content = "* @default\n/src/ @frontend\n";
565        let co = CodeOwners::parse(content).unwrap();
566        assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
567    }
568
569    #[test]
570    fn owner_of_falls_back_to_catch_all() {
571        let content = "* @default\n/src/ @frontend\n";
572        let co = CodeOwners::parse(content).unwrap();
573        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
574    }
575
576    #[test]
577    fn owner_of_no_match_returns_none() {
578        let content = "/src/ @frontend\n";
579        let co = CodeOwners::parse(content).unwrap();
580        assert_eq!(co.owner_of(Path::new("README.md")), None);
581    }
582
583    #[test]
584    fn owner_of_extension_glob() {
585        let content = "*.rs @rust-team\n*.ts @ts-team\n";
586        let co = CodeOwners::parse(content).unwrap();
587        assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
588        assert_eq!(
589            co.owner_of(Path::new("packages/ui/Button.ts")),
590            Some("@ts-team")
591        );
592    }
593
594    #[test]
595    fn owner_of_nested_directory() {
596        let content = "* @default\n/packages/auth/ @auth-team\n";
597        let co = CodeOwners::parse(content).unwrap();
598        assert_eq!(
599            co.owner_of(Path::new("packages/auth/src/login.ts")),
600            Some("@auth-team")
601        );
602        assert_eq!(
603            co.owner_of(Path::new("packages/ui/Button.ts")),
604            Some("@default")
605        );
606    }
607
608    #[test]
609    fn owner_of_specific_overrides_general() {
610        let content = "\
611            * @default\n\
612            /src/ @frontend\n\
613            /src/api/ @backend\n\
614        ";
615        let co = CodeOwners::parse(content).unwrap();
616        assert_eq!(
617            co.owner_of(Path::new("src/api/routes.ts")),
618            Some("@backend")
619        );
620        assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
621    }
622
623    #[test]
624    fn owner_and_rule_of_returns_owner_and_pattern() {
625        let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
626        let co = CodeOwners::parse(content).unwrap();
627        assert_eq!(
628            co.owner_and_rule_of(Path::new("src/app.ts")),
629            Some(("@frontend", "/src/"))
630        );
631        assert_eq!(
632            co.owner_and_rule_of(Path::new("src/lib.rs")),
633            Some(("@rust-team", "*.rs"))
634        );
635        assert_eq!(
636            co.owner_and_rule_of(Path::new("README.md")),
637            Some(("@default", "*"))
638        );
639    }
640
641    #[test]
642    fn owner_and_rule_of_no_match() {
643        let content = "/src/ @frontend\n";
644        let co = CodeOwners::parse(content).unwrap();
645        assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
646    }
647
648    #[test]
649    fn directory_group_simple() {
650        assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
651    }
652
653    #[test]
654    fn directory_group_root_file() {
655        assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
656    }
657
658    #[test]
659    fn directory_group_monorepo() {
660        assert_eq!(
661            directory_group(Path::new("packages/auth/src/login.ts")),
662            "packages"
663        );
664    }
665
666    #[test]
667    fn discover_nonexistent_root() {
668        let result = CodeOwners::discover(Path::new("/nonexistent/path"));
669        assert!(result.is_err());
670        let err = result.unwrap_err();
671        assert!(err.contains("no CODEOWNERS file found"));
672        assert!(err.contains("--group-by directory"));
673    }
674
675    #[test]
676    fn from_file_nonexistent() {
677        let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn from_file_real_codeowners() {
683        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
684            .parent()
685            .unwrap()
686            .parent()
687            .unwrap()
688            .to_path_buf();
689        let path = root.join(".github/CODEOWNERS");
690        if path.exists() {
691            let co = CodeOwners::from_file(&path).unwrap();
692            assert_eq!(
693                co.owner_of(Path::new(".claude/rules/detection.md")),
694                Some("@bartwaardenburg")
695            );
696        }
697    }
698
699    #[test]
700    fn email_owner() {
701        let content = "*.js user@example.com\n";
702        let co = CodeOwners::parse(content).unwrap();
703        assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
704    }
705
706    #[test]
707    fn team_owner() {
708        let content = "*.ts @org/frontend-team\n";
709        let co = CodeOwners::parse(content).unwrap();
710        assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
711    }
712
713    #[test]
714    fn gitlab_section_header_skipped_as_rule() {
715        let content = "[Section Name]\n*.ts @owner\n";
716        let co = CodeOwners::parse(content).unwrap();
717        assert_eq!(co.owners.len(), 1);
718        assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
719    }
720
721    #[test]
722    fn gitlab_optional_section_header_skipped() {
723        let content = "^[Optional Section]\n*.ts @owner\n";
724        let co = CodeOwners::parse(content).unwrap();
725        assert_eq!(co.owners.len(), 1);
726    }
727
728    #[test]
729    fn gitlab_section_header_with_approval_count_skipped() {
730        let content = "[Section Name][2]\n*.ts @owner\n";
731        let co = CodeOwners::parse(content).unwrap();
732        assert_eq!(co.owners.len(), 1);
733    }
734
735    #[test]
736    fn gitlab_optional_section_with_approval_count_skipped() {
737        let content = "^[Section Name][3] @fallback-team\nfoo/\n";
738        let co = CodeOwners::parse(content).unwrap();
739        assert_eq!(co.owners.len(), 1);
740        assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
741    }
742
743    #[test]
744    fn gitlab_section_default_owners_inherited() {
745        let content = "\
746            [Utilities] @utils-team\n\
747            src/utils/\n\
748            [UI Components] @ui-team\n\
749            src/components/\n\
750        ";
751        let co = CodeOwners::parse(content).unwrap();
752        assert_eq!(co.owners.len(), 2);
753        assert_eq!(
754            co.owner_of(Path::new("src/utils/greet.ts")),
755            Some("@utils-team")
756        );
757        assert_eq!(
758            co.owner_of(Path::new("src/components/button.ts")),
759            Some("@ui-team")
760        );
761    }
762
763    #[test]
764    fn gitlab_inline_owner_overrides_section_default() {
765        let content = "\
766            [Section] @section-owner\n\
767            src/generic/\n\
768            src/special/ @special-owner\n\
769        ";
770        let co = CodeOwners::parse(content).unwrap();
771        assert_eq!(
772            co.owner_of(Path::new("src/generic/a.ts")),
773            Some("@section-owner")
774        );
775        assert_eq!(
776            co.owner_of(Path::new("src/special/a.ts")),
777            Some("@special-owner")
778        );
779    }
780
781    #[test]
782    fn gitlab_section_defaults_reset_between_sections() {
783        let content = "\
784            [Section1] @team-a\n\
785            foo/\n\
786            [Section2]\n\
787            bar/\n\
788        ";
789        let co = CodeOwners::parse(content).unwrap();
790        assert_eq!(co.owners.len(), 1);
791        assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
792        assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
793    }
794
795    #[test]
796    fn gitlab_section_header_multiple_default_owners_uses_first() {
797        let content = "[Section] @first @second\nfoo/\n";
798        let co = CodeOwners::parse(content).unwrap();
799        assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
800    }
801
802    #[test]
803    fn gitlab_rules_before_first_section_retain_inline_owners() {
804        let content = "\
805            * @default-owner\n\
806            [Utilities] @utils-team\n\
807            src/utils/\n\
808        ";
809        let co = CodeOwners::parse(content).unwrap();
810        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
811        assert_eq!(
812            co.owner_of(Path::new("src/utils/greet.ts")),
813            Some("@utils-team")
814        );
815    }
816
817    #[test]
818    fn gitlab_issue_127_reproduction() {
819        let content = "\
820# Default section (no header, rules before first section)
821* @default-owner
822
823[Utilities] @utils-team
824src/utils/
825
826[UI Components] @ui-team
827src/components/
828";
829        let co = CodeOwners::parse(content).unwrap();
830        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
831        assert_eq!(
832            co.owner_of(Path::new("src/utils/greet.ts")),
833            Some("@utils-team")
834        );
835        assert_eq!(
836            co.owner_of(Path::new("src/components/button.ts")),
837            Some("@ui-team")
838        );
839    }
840
841    #[test]
842    fn gitlab_negation_last_match_clears_ownership() {
843        let content = "\
844            * @default\n\
845            !src/generated/\n\
846        ";
847        let co = CodeOwners::parse(content).unwrap();
848        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
849        assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
850    }
851
852    #[test]
853    fn gitlab_negation_only_clears_when_last_match() {
854        let content = "\
855            * @default\n\
856            !src/\n\
857            /src/special/ @special\n\
858        ";
859        let co = CodeOwners::parse(content).unwrap();
860        assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
861        assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
862    }
863
864    #[test]
865    fn gitlab_negation_owner_and_rule_returns_none() {
866        let content = "* @default\n!src/vendor/\n";
867        let co = CodeOwners::parse(content).unwrap();
868        assert_eq!(
869            co.owner_and_rule_of(Path::new("README.md")),
870            Some(("@default", "*"))
871        );
872        assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
873    }
874
875    #[test]
876    fn parse_section_header_variants() {
877        assert_eq!(
878            parse_section_header("[Section]"),
879            Some(("Section".into(), vec![]))
880        );
881        assert_eq!(
882            parse_section_header("^[Section]"),
883            Some(("Section".into(), vec![]))
884        );
885        assert_eq!(
886            parse_section_header("[Section][2]"),
887            Some(("Section".into(), vec![]))
888        );
889        assert_eq!(
890            parse_section_header("^[Section][2]"),
891            Some(("Section".into(), vec![]))
892        );
893        assert_eq!(
894            parse_section_header("[Section] @a @b"),
895            Some(("Section".into(), vec!["@a".into(), "@b".into()]))
896        );
897        assert_eq!(
898            parse_section_header("[Section][2] @a"),
899            Some(("Section".into(), vec!["@a".into()]))
900        );
901    }
902
903    #[test]
904    fn parse_section_header_rejects_malformed() {
905        assert_eq!(parse_section_header("[unclosed"), None);
906        assert_eq!(parse_section_header("[]"), None);
907        assert_eq!(parse_section_header("[abc]def @owner"), None);
908        assert_eq!(parse_section_header("[Section][] @owner"), None);
909        assert_eq!(parse_section_header("[Section][abc] @owner"), None);
910    }
911
912    #[test]
913    fn has_sections_false_without_headers() {
914        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
915        assert!(!co.has_sections());
916    }
917
918    #[test]
919    fn has_sections_true_with_headers() {
920        let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
921        assert!(co.has_sections());
922    }
923
924    #[test]
925    fn section_of_returns_named_section() {
926        let content = "\
927            [Billing] @billing-team\n\
928            src/billing/\n\
929            [Search] @search-team\n\
930            src/search/\n\
931        ";
932        let co = CodeOwners::parse(content).unwrap();
933        assert_eq!(
934            co.section_of(Path::new("src/billing/invoice.ts")),
935            Some(Some("Billing"))
936        );
937        assert_eq!(
938            co.section_of(Path::new("src/search/indexer.ts")),
939            Some(Some("Search"))
940        );
941    }
942
943    #[test]
944    fn section_of_returns_some_none_for_pre_section_rule() {
945        let content = "\
946            * @default\n\
947            [Billing] @billing-team\n\
948            src/billing/\n\
949        ";
950        let co = CodeOwners::parse(content).unwrap();
951        assert_eq!(co.section_of(Path::new("README.md")), Some(None));
952        assert_eq!(
953            co.section_of(Path::new("src/billing/invoice.ts")),
954            Some(Some("Billing"))
955        );
956    }
957
958    #[test]
959    fn section_of_returns_none_for_unmatched_path() {
960        let content = "[Billing] @billing-team\nsrc/billing/\n";
961        let co = CodeOwners::parse(content).unwrap();
962        assert_eq!(co.section_of(Path::new("src/other/x.ts")), None);
963    }
964
965    #[test]
966    fn section_of_returns_none_for_negation_last_match() {
967        let content = "\
968            [Billing] @billing-team\n\
969            src/billing/\n\
970            !src/billing/vendor/\n\
971        ";
972        let co = CodeOwners::parse(content).unwrap();
973        assert_eq!(
974            co.section_of(Path::new("src/billing/invoice.ts")),
975            Some(Some("Billing"))
976        );
977        assert_eq!(co.section_of(Path::new("src/billing/vendor/lib.js")), None);
978    }
979
980    #[test]
981    fn section_and_owners_of_returns_section_defaults() {
982        let content = "\
983            [Billing] @core-reviewers @alice\n\
984            src/billing/\n\
985        ";
986        let co = CodeOwners::parse(content).unwrap();
987        let (section, owners) = co
988            .section_and_owners_of(Path::new("src/billing/invoice.ts"))
989            .unwrap();
990        assert_eq!(section, Some("Billing"));
991        assert_eq!(
992            owners,
993            &["@core-reviewers".to_string(), "@alice".to_string()]
994        );
995    }
996
997    #[test]
998    fn section_and_owners_of_same_owners_distinct_sections() {
999        let content = "\
1000            [billing] @core-reviewers @alice @bob\n\
1001            src/billing/\n\
1002            [notifications] @core-reviewers @alice @bob\n\
1003            src/notifications/\n\
1004        ";
1005        let co = CodeOwners::parse(content).unwrap();
1006        let (billing_sec, _) = co
1007            .section_and_owners_of(Path::new("src/billing/invoice.ts"))
1008            .unwrap();
1009        let (notifications_sec, _) = co
1010            .section_and_owners_of(Path::new("src/notifications/email.ts"))
1011            .unwrap();
1012        assert_eq!(billing_sec, Some("billing"));
1013        assert_eq!(notifications_sec, Some("notifications"));
1014    }
1015
1016    #[test]
1017    fn section_and_owners_of_empty_owners_for_pre_section_rule() {
1018        let content = "* @default\n[Billing]\nsrc/billing/ @billing\n";
1019        let co = CodeOwners::parse(content).unwrap();
1020        let (section, owners) = co.section_and_owners_of(Path::new("README.md")).unwrap();
1021        assert_eq!(section, None);
1022        assert!(owners.is_empty());
1023    }
1024
1025    #[test]
1026    fn owner_count_of_counts_all_matched_owners() {
1027        let content = "\
1028            * @default\n\
1029            src/api/ @backend @payments @security\n\
1030            [Frontend] @ui @design\n\
1031            src/ui/\n\
1032            !src/generated/\n\
1033        ";
1034        let co = CodeOwners::parse(content).unwrap();
1035        assert_eq!(co.owner_count_of(Path::new("src/api/payments.ts")), Some(3));
1036        assert_eq!(co.owner_count_of(Path::new("src/ui/button.tsx")), Some(2));
1037        assert_eq!(co.owner_count_of(Path::new("README.md")), Some(1));
1038        assert_eq!(
1039            co.owner_count_of(Path::new("src/generated/types.ts")),
1040            Some(0)
1041        );
1042        assert_eq!(
1043            co.owner_count_of(Path::new("other/generated/types.ts")),
1044            Some(1)
1045        );
1046    }
1047
1048    #[test]
1049    fn non_section_bracket_pattern_parses_as_rule() {
1050        let content = "[abc]def @owner\n";
1051        let co = CodeOwners::parse(content).unwrap();
1052        assert_eq!(co.owners.len(), 1);
1053        assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
1054    }
1055}