Skip to main content

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