Skip to main content

fallow_engine/
codeowners.rs

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