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