Skip to main content

fallow_cli/
codeowners.rs

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