Skip to main content

fallow_cli/
codeowners.rs

1//! CODEOWNERS file parser and ownership lookup.
2//!
3//! Parses GitHub/GitLab-style CODEOWNERS files and matches file paths
4//! to their owners. Used by `--group-by owner` to group analysis output
5//! by team ownership.
6//!
7//! # Pattern semantics
8//!
9//! CODEOWNERS patterns follow gitignore-like rules:
10//! - `*.js` matches any `.js` file in any directory
11//! - `/docs/*` matches files directly in `docs/` (root-anchored)
12//! - `docs/` matches everything under `docs/`
13//! - Last matching rule wins
14//! - First owner on a multi-owner line is the primary owner
15
16use std::path::Path;
17
18use globset::{Glob, GlobSet, GlobSetBuilder};
19
20/// Parsed CODEOWNERS file for ownership lookup.
21#[derive(Debug)]
22pub struct CodeOwners {
23    /// Primary owner per rule, indexed by glob position in the `GlobSet`.
24    owners: Vec<String>,
25    /// Original CODEOWNERS pattern per rule (e.g. `/src/` or `*.ts`).
26    patterns: Vec<String>,
27    /// Compiled glob patterns for matching.
28    globs: GlobSet,
29}
30
31/// Standard locations to probe for a CODEOWNERS file, in priority order.
32///
33/// Order: root catch-all → GitHub → GitLab → GitHub legacy (`docs/`).
34const PROBE_PATHS: &[&str] = &[
35    "CODEOWNERS",
36    ".github/CODEOWNERS",
37    ".gitlab/CODEOWNERS",
38    "docs/CODEOWNERS",
39];
40
41/// Label for files that match no CODEOWNERS rule.
42pub const UNOWNED_LABEL: &str = "(unowned)";
43
44impl CodeOwners {
45    /// Load and parse a CODEOWNERS file from the given path.
46    pub fn from_file(path: &Path) -> Result<Self, String> {
47        let content = std::fs::read_to_string(path)
48            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
49        Self::parse(&content)
50    }
51
52    /// Auto-probe standard CODEOWNERS locations relative to the project root.
53    ///
54    /// Tries `CODEOWNERS`, `.github/CODEOWNERS`, `.gitlab/CODEOWNERS`, `docs/CODEOWNERS`.
55    pub fn discover(root: &Path) -> Result<Self, String> {
56        for probe in PROBE_PATHS {
57            let path = root.join(probe);
58            if path.is_file() {
59                return Self::from_file(&path);
60            }
61        }
62        Err(format!(
63            "no CODEOWNERS file found (looked for: {}). \
64             Create one of these files or use --group-by directory instead",
65            PROBE_PATHS.join(", ")
66        ))
67    }
68
69    /// Load from a config-specified path, or auto-discover.
70    pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
71        if let Some(p) = config_path {
72            let path = root.join(p);
73            Self::from_file(&path)
74        } else {
75            Self::discover(root)
76        }
77    }
78
79    /// Parse CODEOWNERS content into a lookup structure.
80    pub(crate) fn parse(content: &str) -> Result<Self, String> {
81        let mut builder = GlobSetBuilder::new();
82        let mut owners = Vec::new();
83        let mut patterns = Vec::new();
84
85        for line in content.lines() {
86            let line = line.trim();
87            if line.is_empty() || line.starts_with('#') {
88                continue;
89            }
90
91            let mut parts = line.split_whitespace();
92            let Some(pattern) = parts.next() else {
93                continue;
94            };
95            let Some(owner) = parts.next() else {
96                continue; // Pattern without owners — skip
97            };
98
99            let glob_pattern = translate_pattern(pattern);
100            let glob = Glob::new(&glob_pattern)
101                .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
102
103            builder.add(glob);
104            owners.push(owner.to_string());
105            patterns.push(pattern.to_string());
106        }
107
108        let globs = builder
109            .build()
110            .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
111
112        Ok(Self {
113            owners,
114            patterns,
115            globs,
116        })
117    }
118
119    /// Look up the primary owner of a file path (relative to project root).
120    ///
121    /// Returns the first owner from the last matching CODEOWNERS rule,
122    /// or `None` if no rule matches.
123    pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
124        let matches = self.globs.matches(relative_path);
125        // Last match wins: highest index = last rule in file order
126        matches.iter().max().map(|&idx| self.owners[idx].as_str())
127    }
128
129    /// Look up the primary owner and the original CODEOWNERS pattern for a path.
130    ///
131    /// Returns `(owner, pattern)` from the last matching rule, or `None` if
132    /// no rule matches. The pattern is the raw string from the CODEOWNERS file
133    /// (e.g. `/src/` or `*.ts`).
134    pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
135        let matches = self.globs.matches(relative_path);
136        matches
137            .iter()
138            .max()
139            .map(|&idx| (self.owners[idx].as_str(), self.patterns[idx].as_str()))
140    }
141}
142
143/// Translate a CODEOWNERS pattern to a `globset`-compatible glob pattern.
144///
145/// CODEOWNERS uses gitignore-like semantics:
146/// - Leading `/` anchors to root (stripped for globset)
147/// - Trailing `/` means directory contents (`dir/` → `dir/**`)
148/// - No `/` in pattern: matches in any directory (`*.js` → `**/*.js`)
149/// - Contains `/` (non-trailing): root-relative as-is
150fn translate_pattern(pattern: &str) -> String {
151    // Strip leading `/` — globset matches from root by default
152    let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
153        (true, p)
154    } else {
155        (false, pattern)
156    };
157
158    // Trailing `/` means directory contents
159    let expanded = if let Some(p) = rest.strip_suffix('/') {
160        format!("{p}/**")
161    } else {
162        rest.to_string()
163    };
164
165    // If not anchored and no directory separator, match in any directory
166    if !anchored && !expanded.contains('/') {
167        format!("**/{expanded}")
168    } else {
169        expanded
170    }
171}
172
173/// Extract the first path component for `--group-by directory` grouping.
174///
175/// Returns the first directory segment of a relative path.
176/// For monorepo structures (`packages/auth/...`), returns `packages`.
177pub fn directory_group(relative_path: &Path) -> &str {
178    let s = relative_path.to_str().unwrap_or("");
179    // Use forward-slash normalized path
180    let s = if s.contains('\\') {
181        // Windows paths: handled by caller normalizing, but be safe
182        return s.split(['/', '\\']).next().unwrap_or(s);
183    } else {
184        s
185    };
186
187    match s.find('/') {
188        Some(pos) => &s[..pos],
189        None => s, // Root-level file
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::path::PathBuf;
197
198    // ── translate_pattern ──────────────────────────────────────────
199
200    #[test]
201    fn translate_bare_glob() {
202        assert_eq!(translate_pattern("*.js"), "**/*.js");
203    }
204
205    #[test]
206    fn translate_rooted_pattern() {
207        assert_eq!(translate_pattern("/docs/*"), "docs/*");
208    }
209
210    #[test]
211    fn translate_directory_pattern() {
212        assert_eq!(translate_pattern("docs/"), "docs/**");
213    }
214
215    #[test]
216    fn translate_rooted_directory() {
217        assert_eq!(translate_pattern("/src/app/"), "src/app/**");
218    }
219
220    #[test]
221    fn translate_path_with_slash() {
222        assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
223    }
224
225    #[test]
226    fn translate_double_star() {
227        // Pattern already contains `/`, so it's root-relative — no extra prefix
228        assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
229    }
230
231    #[test]
232    fn translate_single_file() {
233        assert_eq!(translate_pattern("Makefile"), "**/Makefile");
234    }
235
236    // ── parse ──────────────────────────────────────────────────────
237
238    #[test]
239    fn parse_simple_codeowners() {
240        let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
241        let co = CodeOwners::parse(content).unwrap();
242        assert_eq!(co.owners.len(), 3);
243    }
244
245    #[test]
246    fn parse_skips_comments_and_blanks() {
247        let content = "# Comment\n\n* @owner\n  # Indented comment\n";
248        let co = CodeOwners::parse(content).unwrap();
249        assert_eq!(co.owners.len(), 1);
250    }
251
252    #[test]
253    fn parse_multi_owner_takes_first() {
254        let content = "*.ts @team-a @team-b @team-c\n";
255        let co = CodeOwners::parse(content).unwrap();
256        assert_eq!(co.owners[0], "@team-a");
257    }
258
259    #[test]
260    fn parse_skips_pattern_without_owner() {
261        let content = "*.ts\n*.js @owner\n";
262        let co = CodeOwners::parse(content).unwrap();
263        assert_eq!(co.owners.len(), 1);
264        assert_eq!(co.owners[0], "@owner");
265    }
266
267    #[test]
268    fn parse_empty_content() {
269        let co = CodeOwners::parse("").unwrap();
270        assert_eq!(co.owner_of(Path::new("anything.ts")), None);
271    }
272
273    // ── owner_of ───────────────────────────────────────────────────
274
275    #[test]
276    fn owner_of_last_match_wins() {
277        let content = "* @default\n/src/ @frontend\n";
278        let co = CodeOwners::parse(content).unwrap();
279        assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
280    }
281
282    #[test]
283    fn owner_of_falls_back_to_catch_all() {
284        let content = "* @default\n/src/ @frontend\n";
285        let co = CodeOwners::parse(content).unwrap();
286        assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
287    }
288
289    #[test]
290    fn owner_of_no_match_returns_none() {
291        let content = "/src/ @frontend\n";
292        let co = CodeOwners::parse(content).unwrap();
293        assert_eq!(co.owner_of(Path::new("README.md")), None);
294    }
295
296    #[test]
297    fn owner_of_extension_glob() {
298        let content = "*.rs @rust-team\n*.ts @ts-team\n";
299        let co = CodeOwners::parse(content).unwrap();
300        assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
301        assert_eq!(
302            co.owner_of(Path::new("packages/ui/Button.ts")),
303            Some("@ts-team")
304        );
305    }
306
307    #[test]
308    fn owner_of_nested_directory() {
309        let content = "* @default\n/packages/auth/ @auth-team\n";
310        let co = CodeOwners::parse(content).unwrap();
311        assert_eq!(
312            co.owner_of(Path::new("packages/auth/src/login.ts")),
313            Some("@auth-team")
314        );
315        assert_eq!(
316            co.owner_of(Path::new("packages/ui/Button.ts")),
317            Some("@default")
318        );
319    }
320
321    #[test]
322    fn owner_of_specific_overrides_general() {
323        // Later, more specific rule wins
324        let content = "\
325            * @default\n\
326            /src/ @frontend\n\
327            /src/api/ @backend\n\
328        ";
329        let co = CodeOwners::parse(content).unwrap();
330        assert_eq!(
331            co.owner_of(Path::new("src/api/routes.ts")),
332            Some("@backend")
333        );
334        assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
335    }
336
337    // ── owner_and_rule_of ──────────────────────────────────────────
338
339    #[test]
340    fn owner_and_rule_of_returns_owner_and_pattern() {
341        let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
342        let co = CodeOwners::parse(content).unwrap();
343        assert_eq!(
344            co.owner_and_rule_of(Path::new("src/app.ts")),
345            Some(("@frontend", "/src/"))
346        );
347        assert_eq!(
348            co.owner_and_rule_of(Path::new("src/lib.rs")),
349            Some(("@rust-team", "*.rs"))
350        );
351        assert_eq!(
352            co.owner_and_rule_of(Path::new("README.md")),
353            Some(("@default", "*"))
354        );
355    }
356
357    #[test]
358    fn owner_and_rule_of_no_match() {
359        let content = "/src/ @frontend\n";
360        let co = CodeOwners::parse(content).unwrap();
361        assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
362    }
363
364    // ── directory_group ────────────────────────────────────────────
365
366    #[test]
367    fn directory_group_simple() {
368        assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
369    }
370
371    #[test]
372    fn directory_group_root_file() {
373        assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
374    }
375
376    #[test]
377    fn directory_group_monorepo() {
378        assert_eq!(
379            directory_group(Path::new("packages/auth/src/login.ts")),
380            "packages"
381        );
382    }
383
384    // ── discover ───────────────────────────────────────────────────
385
386    #[test]
387    fn discover_nonexistent_root() {
388        let result = CodeOwners::discover(Path::new("/nonexistent/path"));
389        assert!(result.is_err());
390        let err = result.unwrap_err();
391        assert!(err.contains("no CODEOWNERS file found"));
392        assert!(err.contains("--group-by directory"));
393    }
394
395    // ── from_file ──────────────────────────────────────────────────
396
397    #[test]
398    fn from_file_nonexistent() {
399        let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
400        assert!(result.is_err());
401    }
402
403    #[test]
404    fn from_file_real_codeowners() {
405        // Use the project's own CODEOWNERS file
406        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
407            .parent()
408            .unwrap()
409            .parent()
410            .unwrap()
411            .to_path_buf();
412        let path = root.join(".github/CODEOWNERS");
413        if path.exists() {
414            let co = CodeOwners::from_file(&path).unwrap();
415            // Our CODEOWNERS has `* @bartwaardenburg`
416            assert_eq!(
417                co.owner_of(Path::new("src/anything.ts")),
418                Some("@bartwaardenburg")
419            );
420        }
421    }
422
423    // ── edge cases ─────────────────────────────────────────────────
424
425    #[test]
426    fn email_owner() {
427        let content = "*.js user@example.com\n";
428        let co = CodeOwners::parse(content).unwrap();
429        assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
430    }
431
432    #[test]
433    fn team_owner() {
434        let content = "*.ts @org/frontend-team\n";
435        let co = CodeOwners::parse(content).unwrap();
436        assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
437    }
438}