Skip to main content

fallow_engine/
codeowners.rs

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