Skip to main content

codeowners_parser/
ownership.rs

1//! Resolve which rule in a CODEOWNERS file owns a given path, and locate
2//! the CODEOWNERS file itself within a repository.
3//!
4//! GitHub's CODEOWNERS semantics are "last match wins": later rules override
5//! earlier ones. The functions here encode that rule.
6
7use std::path::{Path, PathBuf};
8
9use crate::parser::{parse_codeowners_file_with_positions, CodeownersLine, ParsedLine};
10use crate::pattern::pattern_matches;
11
12/// Locate a CODEOWNERS file by walking up from `start`, checking
13/// `.github/CODEOWNERS`, `CODEOWNERS`, and `docs/CODEOWNERS` at each level.
14///
15/// These are the three locations GitHub recognizes, checked in the same
16/// priority order as `git`'s own CODEOWNERS resolution.
17///
18/// # Example
19///
20/// ```no_run
21/// use std::path::Path;
22/// use codeowners_parser::find_codeowners;
23///
24/// if let Some(path) = find_codeowners(Path::new(".")) {
25///     println!("Found CODEOWNERS at {}", path.display());
26/// }
27/// ```
28pub fn find_codeowners(start: &Path) -> Option<PathBuf> {
29    const CANDIDATES: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
30    let mut current = Some(start);
31    while let Some(dir) = current {
32        for candidate in CANDIDATES {
33            let path = dir.join(candidate);
34            if path.is_file() {
35                return Some(path);
36            }
37        }
38        current = dir.parent();
39    }
40    None
41}
42
43/// Get the repository root from a CODEOWNERS file path.
44///
45/// `.github/CODEOWNERS` and `docs/CODEOWNERS` live one directory below
46/// the repo root; a top-level `CODEOWNERS` sits at the root itself.
47/// If the parent cannot be determined, `fallback` is returned.
48pub fn get_repo_root(codeowners_path: &Path, fallback: &Path) -> PathBuf {
49    codeowners_path
50        .parent()
51        .and_then(|p| {
52            if p.ends_with(".github") || p.ends_with("docs") {
53                p.parent()
54            } else {
55                Some(p)
56            }
57        })
58        .map(|p| p.to_path_buf())
59        .unwrap_or_else(|| fallback.to_path_buf())
60}
61
62/// The rule that owns a file: which line matched, with what pattern, and
63/// which owners were assigned.
64///
65/// Returned by [`check_file_ownership`] and [`check_file_ownership_parsed`].
66#[derive(Debug, Clone)]
67pub struct OwnershipResult {
68    /// 0-indexed line number of the matching rule in the CODEOWNERS file.
69    pub line_number: u32,
70    /// The pattern text, as written in CODEOWNERS.
71    pub pattern: String,
72    /// Owners assigned to the matching rule. May be empty if the rule
73    /// declares a pattern without any owners (a legal way of "unassigning"
74    /// ownership via later rules).
75    pub owners: Vec<String>,
76}
77
78/// Find which CODEOWNERS rule owns `file_path`, parsing `content` each call.
79///
80/// For hot loops where the same CODEOWNERS content is checked against many
81/// paths, parse once and use [`check_file_ownership_parsed`] instead.
82///
83/// Returns `None` if no rule matches.
84///
85/// # Example
86///
87/// ```
88/// use codeowners_parser::check_file_ownership;
89///
90/// let content = "*.rs @rust-team\n/src/main.rs @main-owner\n";
91/// let result = check_file_ownership(content, "src/main.rs").unwrap();
92/// assert_eq!(result.pattern, "/src/main.rs");
93/// assert_eq!(result.owners, vec!["@main-owner"]);
94/// ```
95pub fn check_file_ownership(content: &str, file_path: &str) -> Option<OwnershipResult> {
96    let lines = parse_codeowners_file_with_positions(content);
97    check_file_ownership_parsed(&lines, file_path)
98}
99
100/// Find which CODEOWNERS rule owns `file_path` against pre-parsed lines.
101///
102/// Follows CODEOWNERS "last match wins" semantics: the returned result is
103/// the last rule in `lines` whose pattern matches `file_path`.
104///
105/// Use this in hot loops where you'd otherwise call [`check_file_ownership`]
106/// repeatedly against the same content — parse once with
107/// [`crate::parser::parse_codeowners_file_with_positions`]
108/// and reuse the result.
109///
110/// Leading `./` is stripped from `file_path` before matching.
111pub fn check_file_ownership_parsed(
112    lines: &[ParsedLine],
113    file_path: &str,
114) -> Option<OwnershipResult> {
115    let file_path = file_path.trim_start_matches("./");
116
117    let mut matching_rule = None;
118    for parsed_line in lines {
119        if let CodeownersLine::Rule { pattern, owners } = &parsed_line.content {
120            if pattern_matches(pattern, file_path) {
121                matching_rule = Some(OwnershipResult {
122                    line_number: parsed_line.line_number,
123                    pattern: pattern.clone(),
124                    owners: owners.clone(),
125                });
126            }
127        }
128    }
129
130    matching_rule
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_check_file_ownership() {
139        let content = "*.rs @rust-team\n/src/ @src-team\n/src/main.rs @main-owner";
140        let result = check_file_ownership(content, "src/main.rs").unwrap();
141        assert_eq!(result.pattern, "/src/main.rs");
142        assert_eq!(result.owners, vec!["@main-owner"]);
143    }
144
145    #[test]
146    fn test_check_file_no_owner() {
147        let content = "*.rs @rust-team";
148        let result = check_file_ownership(content, "README.md");
149        assert!(result.is_none());
150    }
151
152    #[test]
153    fn test_check_file_ownership_last_match_wins() {
154        let content = "* @default\n*.rs @rust";
155        let result = check_file_ownership(content, "main.rs").unwrap();
156        assert_eq!(result.pattern, "*.rs");
157        assert_eq!(result.owners, vec!["@rust"]);
158    }
159
160    #[test]
161    fn test_check_file_ownership_strips_leading_dot_slash() {
162        let content = "*.rs @rust";
163        let result = check_file_ownership(content, "./src/main.rs").unwrap();
164        assert_eq!(result.owners, vec!["@rust"]);
165    }
166
167    #[test]
168    fn test_get_repo_root_github_subdir() {
169        let path = PathBuf::from("/project/.github/CODEOWNERS");
170        let fallback = PathBuf::from("/project");
171        assert_eq!(get_repo_root(&path, &fallback), PathBuf::from("/project"));
172    }
173
174    #[test]
175    fn test_get_repo_root_top_level() {
176        let path = PathBuf::from("/project/CODEOWNERS");
177        let fallback = PathBuf::from("/project");
178        assert_eq!(get_repo_root(&path, &fallback), PathBuf::from("/project"));
179    }
180
181    #[test]
182    fn test_get_repo_root_docs_directory() {
183        let path = PathBuf::from("/project/docs/CODEOWNERS");
184        let fallback = PathBuf::from("/project");
185        assert_eq!(get_repo_root(&path, &fallback), PathBuf::from("/project"));
186    }
187
188    #[test]
189    fn test_get_repo_root_fallback() {
190        let path = PathBuf::from("/CODEOWNERS");
191        let fallback = PathBuf::from("/fallback");
192        assert_eq!(get_repo_root(&path, &fallback), PathBuf::from("/"));
193    }
194}