1use std::path::Path;
17
18use globset::{Glob, GlobSet, GlobSetBuilder};
19
20#[derive(Debug)]
22pub struct CodeOwners {
23 owners: Vec<String>,
25 patterns: Vec<String>,
27 globs: GlobSet,
29}
30
31const PROBE_PATHS: &[&str] = &[
35 "CODEOWNERS",
36 ".github/CODEOWNERS",
37 ".gitlab/CODEOWNERS",
38 "docs/CODEOWNERS",
39];
40
41pub const UNOWNED_LABEL: &str = "(unowned)";
43
44impl CodeOwners {
45 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 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 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 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; };
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 pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
124 let matches = self.globs.matches(relative_path);
125 matches.iter().max().map(|&idx| self.owners[idx].as_str())
127 }
128
129 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
143fn translate_pattern(pattern: &str) -> String {
151 let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
153 (true, p)
154 } else {
155 (false, pattern)
156 };
157
158 let expanded = if let Some(p) = rest.strip_suffix('/') {
160 format!("{p}/**")
161 } else {
162 rest.to_string()
163 };
164
165 if !anchored && !expanded.contains('/') {
167 format!("**/{expanded}")
168 } else {
169 expanded
170 }
171}
172
173pub fn directory_group(relative_path: &Path) -> &str {
178 let s = relative_path.to_str().unwrap_or("");
179 let s = if s.contains('\\') {
181 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, }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::path::PathBuf;
197
198 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 assert_eq!(
417 co.owner_of(Path::new("src/anything.ts")),
418 Some("@bartwaardenburg")
419 );
420 }
421 }
422
423 #[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}