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