1use std::path::Path;
31
32use globset::{Glob, GlobSet, GlobSetBuilder};
33
34#[derive(Debug)]
36pub struct CodeOwners {
37 owners: Vec<String>,
40 patterns: Vec<String>,
43 is_negation: Vec<bool>,
46 globs: GlobSet,
48}
49
50const PROBE_PATHS: &[&str] = &[
54 "CODEOWNERS",
55 ".github/CODEOWNERS",
56 ".gitlab/CODEOWNERS",
57 "docs/CODEOWNERS",
58];
59
60pub const UNOWNED_LABEL: &str = "(unowned)";
62
63impl CodeOwners {
64 pub fn from_file(path: &Path) -> Result<Self, String> {
66 let content = std::fs::read_to_string(path)
67 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
68 Self::parse(&content)
69 }
70
71 pub fn discover(root: &Path) -> Result<Self, String> {
75 for probe in PROBE_PATHS {
76 let path = root.join(probe);
77 if path.is_file() {
78 return Self::from_file(&path);
79 }
80 }
81 Err(format!(
82 "no CODEOWNERS file found (looked for: {}). \
83 Create one of these files or use --group-by directory instead",
84 PROBE_PATHS.join(", ")
85 ))
86 }
87
88 pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
90 if let Some(p) = config_path {
91 let path = root.join(p);
92 Self::from_file(&path)
93 } else {
94 Self::discover(root)
95 }
96 }
97
98 pub(crate) fn parse(content: &str) -> Result<Self, String> {
100 let mut builder = GlobSetBuilder::new();
101 let mut owners = Vec::new();
102 let mut patterns = Vec::new();
103 let mut is_negation = Vec::new();
104 let mut section_default_owners: Vec<String> = Vec::new();
105
106 for line in content.lines() {
107 let line = line.trim();
108 if line.is_empty() || line.starts_with('#') {
109 continue;
110 }
111
112 if let Some(defaults) = parse_section_header(line) {
116 section_default_owners = defaults;
117 continue;
118 }
119
120 let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
122 (true, after.trim_start())
123 } else {
124 (false, line)
125 };
126
127 let mut parts = rest.split_whitespace();
128 let Some(pattern) = parts.next() else {
129 continue;
130 };
131 let first_inline_owner = parts.next();
132
133 let effective_owner: &str = if negate {
134 ""
137 } else if let Some(o) = first_inline_owner {
138 o
139 } else if let Some(o) = section_default_owners.first() {
140 o.as_str()
141 } else {
142 continue;
144 };
145
146 let glob_pattern = translate_pattern(pattern);
147 let glob = Glob::new(&glob_pattern)
148 .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
149
150 builder.add(glob);
151 owners.push(effective_owner.to_string());
152 patterns.push(if negate {
153 format!("!{pattern}")
154 } else {
155 pattern.to_string()
156 });
157 is_negation.push(negate);
158 }
159
160 let globs = builder
161 .build()
162 .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
163
164 Ok(Self {
165 owners,
166 patterns,
167 is_negation,
168 globs,
169 })
170 }
171
172 pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
178 let matches = self.globs.matches(relative_path);
179 matches.iter().max().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 pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
196 let matches = self.globs.matches(relative_path);
197 matches.iter().max().and_then(|&idx| {
198 if self.is_negation[idx] {
199 None
200 } else {
201 Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
202 }
203 })
204 }
205}
206
207fn parse_section_header(line: &str) -> Option<Vec<String>> {
222 let rest = line.strip_prefix('^').unwrap_or(line);
223 let rest = rest.strip_prefix('[')?;
224 let close = rest.find(']')?;
225 let name = &rest[..close];
226 if name.is_empty() {
227 return None;
228 }
229 let mut after = &rest[close + 1..];
230
231 if let Some(inner) = after.strip_prefix('[') {
233 let n_close = inner.find(']')?;
234 let count = &inner[..n_close];
235 if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
236 return None;
237 }
238 after = &inner[n_close + 1..];
239 }
240
241 if !after.is_empty() && !after.starts_with(char::is_whitespace) {
244 return None;
245 }
246
247 Some(after.split_whitespace().map(String::from).collect())
248}
249
250fn translate_pattern(pattern: &str) -> String {
258 let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
260 (true, p)
261 } else {
262 (false, pattern)
263 };
264
265 let expanded = if let Some(p) = rest.strip_suffix('/') {
267 format!("{p}/**")
268 } else {
269 rest.to_string()
270 };
271
272 if !anchored && !expanded.contains('/') {
274 format!("**/{expanded}")
275 } else {
276 expanded
277 }
278}
279
280pub fn directory_group(relative_path: &Path) -> &str {
285 let s = relative_path.to_str().unwrap_or("");
286 let s = if s.contains('\\') {
288 return s.split(['/', '\\']).next().unwrap_or(s);
290 } else {
291 s
292 };
293
294 match s.find('/') {
295 Some(pos) => &s[..pos],
296 None => s, }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::path::PathBuf;
304
305 #[test]
308 fn translate_bare_glob() {
309 assert_eq!(translate_pattern("*.js"), "**/*.js");
310 }
311
312 #[test]
313 fn translate_rooted_pattern() {
314 assert_eq!(translate_pattern("/docs/*"), "docs/*");
315 }
316
317 #[test]
318 fn translate_directory_pattern() {
319 assert_eq!(translate_pattern("docs/"), "docs/**");
320 }
321
322 #[test]
323 fn translate_rooted_directory() {
324 assert_eq!(translate_pattern("/src/app/"), "src/app/**");
325 }
326
327 #[test]
328 fn translate_path_with_slash() {
329 assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
330 }
331
332 #[test]
333 fn translate_double_star() {
334 assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
336 }
337
338 #[test]
339 fn translate_single_file() {
340 assert_eq!(translate_pattern("Makefile"), "**/Makefile");
341 }
342
343 #[test]
346 fn parse_simple_codeowners() {
347 let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
348 let co = CodeOwners::parse(content).unwrap();
349 assert_eq!(co.owners.len(), 3);
350 }
351
352 #[test]
353 fn parse_skips_comments_and_blanks() {
354 let content = "# Comment\n\n* @owner\n # Indented comment\n";
355 let co = CodeOwners::parse(content).unwrap();
356 assert_eq!(co.owners.len(), 1);
357 }
358
359 #[test]
360 fn parse_multi_owner_takes_first() {
361 let content = "*.ts @team-a @team-b @team-c\n";
362 let co = CodeOwners::parse(content).unwrap();
363 assert_eq!(co.owners[0], "@team-a");
364 }
365
366 #[test]
367 fn parse_skips_pattern_without_owner() {
368 let content = "*.ts\n*.js @owner\n";
369 let co = CodeOwners::parse(content).unwrap();
370 assert_eq!(co.owners.len(), 1);
371 assert_eq!(co.owners[0], "@owner");
372 }
373
374 #[test]
375 fn parse_empty_content() {
376 let co = CodeOwners::parse("").unwrap();
377 assert_eq!(co.owner_of(Path::new("anything.ts")), None);
378 }
379
380 #[test]
383 fn owner_of_last_match_wins() {
384 let content = "* @default\n/src/ @frontend\n";
385 let co = CodeOwners::parse(content).unwrap();
386 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
387 }
388
389 #[test]
390 fn owner_of_falls_back_to_catch_all() {
391 let content = "* @default\n/src/ @frontend\n";
392 let co = CodeOwners::parse(content).unwrap();
393 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
394 }
395
396 #[test]
397 fn owner_of_no_match_returns_none() {
398 let content = "/src/ @frontend\n";
399 let co = CodeOwners::parse(content).unwrap();
400 assert_eq!(co.owner_of(Path::new("README.md")), None);
401 }
402
403 #[test]
404 fn owner_of_extension_glob() {
405 let content = "*.rs @rust-team\n*.ts @ts-team\n";
406 let co = CodeOwners::parse(content).unwrap();
407 assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
408 assert_eq!(
409 co.owner_of(Path::new("packages/ui/Button.ts")),
410 Some("@ts-team")
411 );
412 }
413
414 #[test]
415 fn owner_of_nested_directory() {
416 let content = "* @default\n/packages/auth/ @auth-team\n";
417 let co = CodeOwners::parse(content).unwrap();
418 assert_eq!(
419 co.owner_of(Path::new("packages/auth/src/login.ts")),
420 Some("@auth-team")
421 );
422 assert_eq!(
423 co.owner_of(Path::new("packages/ui/Button.ts")),
424 Some("@default")
425 );
426 }
427
428 #[test]
429 fn owner_of_specific_overrides_general() {
430 let content = "\
432 * @default\n\
433 /src/ @frontend\n\
434 /src/api/ @backend\n\
435 ";
436 let co = CodeOwners::parse(content).unwrap();
437 assert_eq!(
438 co.owner_of(Path::new("src/api/routes.ts")),
439 Some("@backend")
440 );
441 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
442 }
443
444 #[test]
447 fn owner_and_rule_of_returns_owner_and_pattern() {
448 let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
449 let co = CodeOwners::parse(content).unwrap();
450 assert_eq!(
451 co.owner_and_rule_of(Path::new("src/app.ts")),
452 Some(("@frontend", "/src/"))
453 );
454 assert_eq!(
455 co.owner_and_rule_of(Path::new("src/lib.rs")),
456 Some(("@rust-team", "*.rs"))
457 );
458 assert_eq!(
459 co.owner_and_rule_of(Path::new("README.md")),
460 Some(("@default", "*"))
461 );
462 }
463
464 #[test]
465 fn owner_and_rule_of_no_match() {
466 let content = "/src/ @frontend\n";
467 let co = CodeOwners::parse(content).unwrap();
468 assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
469 }
470
471 #[test]
474 fn directory_group_simple() {
475 assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
476 }
477
478 #[test]
479 fn directory_group_root_file() {
480 assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
481 }
482
483 #[test]
484 fn directory_group_monorepo() {
485 assert_eq!(
486 directory_group(Path::new("packages/auth/src/login.ts")),
487 "packages"
488 );
489 }
490
491 #[test]
494 fn discover_nonexistent_root() {
495 let result = CodeOwners::discover(Path::new("/nonexistent/path"));
496 assert!(result.is_err());
497 let err = result.unwrap_err();
498 assert!(err.contains("no CODEOWNERS file found"));
499 assert!(err.contains("--group-by directory"));
500 }
501
502 #[test]
505 fn from_file_nonexistent() {
506 let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
507 assert!(result.is_err());
508 }
509
510 #[test]
511 fn from_file_real_codeowners() {
512 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
514 .parent()
515 .unwrap()
516 .parent()
517 .unwrap()
518 .to_path_buf();
519 let path = root.join(".github/CODEOWNERS");
520 if path.exists() {
521 let co = CodeOwners::from_file(&path).unwrap();
522 assert_eq!(
524 co.owner_of(Path::new("src/anything.ts")),
525 Some("@bartwaardenburg")
526 );
527 }
528 }
529
530 #[test]
533 fn email_owner() {
534 let content = "*.js user@example.com\n";
535 let co = CodeOwners::parse(content).unwrap();
536 assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
537 }
538
539 #[test]
540 fn team_owner() {
541 let content = "*.ts @org/frontend-team\n";
542 let co = CodeOwners::parse(content).unwrap();
543 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
544 }
545
546 #[test]
549 fn gitlab_section_header_skipped_as_rule() {
550 let content = "[Section Name]\n*.ts @owner\n";
552 let co = CodeOwners::parse(content).unwrap();
553 assert_eq!(co.owners.len(), 1);
554 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
555 }
556
557 #[test]
558 fn gitlab_optional_section_header_skipped() {
559 let content = "^[Optional Section]\n*.ts @owner\n";
560 let co = CodeOwners::parse(content).unwrap();
561 assert_eq!(co.owners.len(), 1);
562 }
563
564 #[test]
565 fn gitlab_section_header_with_approval_count_skipped() {
566 let content = "[Section Name][2]\n*.ts @owner\n";
567 let co = CodeOwners::parse(content).unwrap();
568 assert_eq!(co.owners.len(), 1);
569 }
570
571 #[test]
572 fn gitlab_optional_section_with_approval_count_skipped() {
573 let content = "^[Section Name][3] @fallback-team\nfoo/\n";
574 let co = CodeOwners::parse(content).unwrap();
575 assert_eq!(co.owners.len(), 1);
576 assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
577 }
578
579 #[test]
580 fn gitlab_section_default_owners_inherited() {
581 let content = "\
582 [Utilities] @utils-team\n\
583 src/utils/\n\
584 [UI Components] @ui-team\n\
585 src/components/\n\
586 ";
587 let co = CodeOwners::parse(content).unwrap();
588 assert_eq!(co.owners.len(), 2);
589 assert_eq!(
590 co.owner_of(Path::new("src/utils/greet.ts")),
591 Some("@utils-team")
592 );
593 assert_eq!(
594 co.owner_of(Path::new("src/components/button.ts")),
595 Some("@ui-team")
596 );
597 }
598
599 #[test]
600 fn gitlab_inline_owner_overrides_section_default() {
601 let content = "\
602 [Section] @section-owner\n\
603 src/generic/\n\
604 src/special/ @special-owner\n\
605 ";
606 let co = CodeOwners::parse(content).unwrap();
607 assert_eq!(
608 co.owner_of(Path::new("src/generic/a.ts")),
609 Some("@section-owner")
610 );
611 assert_eq!(
612 co.owner_of(Path::new("src/special/a.ts")),
613 Some("@special-owner")
614 );
615 }
616
617 #[test]
618 fn gitlab_section_defaults_reset_between_sections() {
619 let content = "\
622 [Section1] @team-a\n\
623 foo/\n\
624 [Section2]\n\
625 bar/\n\
626 ";
627 let co = CodeOwners::parse(content).unwrap();
628 assert_eq!(co.owners.len(), 1);
629 assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
630 assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
631 }
632
633 #[test]
634 fn gitlab_section_header_multiple_default_owners_uses_first() {
635 let content = "[Section] @first @second\nfoo/\n";
636 let co = CodeOwners::parse(content).unwrap();
637 assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
638 }
639
640 #[test]
641 fn gitlab_rules_before_first_section_retain_inline_owners() {
642 let content = "\
645 * @default-owner\n\
646 [Utilities] @utils-team\n\
647 src/utils/\n\
648 ";
649 let co = CodeOwners::parse(content).unwrap();
650 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
651 assert_eq!(
652 co.owner_of(Path::new("src/utils/greet.ts")),
653 Some("@utils-team")
654 );
655 }
656
657 #[test]
658 fn gitlab_issue_127_reproduction() {
659 let content = "\
661# Default section (no header, rules before first section)
662* @default-owner
663
664[Utilities] @utils-team
665src/utils/
666
667[UI Components] @ui-team
668src/components/
669";
670 let co = CodeOwners::parse(content).unwrap();
671 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
672 assert_eq!(
673 co.owner_of(Path::new("src/utils/greet.ts")),
674 Some("@utils-team")
675 );
676 assert_eq!(
677 co.owner_of(Path::new("src/components/button.ts")),
678 Some("@ui-team")
679 );
680 }
681
682 #[test]
685 fn gitlab_negation_last_match_clears_ownership() {
686 let content = "\
687 * @default\n\
688 !src/generated/\n\
689 ";
690 let co = CodeOwners::parse(content).unwrap();
691 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
692 assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
693 }
694
695 #[test]
696 fn gitlab_negation_only_clears_when_last_match() {
697 let content = "\
699 * @default\n\
700 !src/\n\
701 /src/special/ @special\n\
702 ";
703 let co = CodeOwners::parse(content).unwrap();
704 assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
705 assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
706 }
707
708 #[test]
709 fn gitlab_negation_owner_and_rule_returns_none() {
710 let content = "* @default\n!src/vendor/\n";
711 let co = CodeOwners::parse(content).unwrap();
712 assert_eq!(
713 co.owner_and_rule_of(Path::new("README.md")),
714 Some(("@default", "*"))
715 );
716 assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
717 }
718
719 #[test]
722 fn parse_section_header_variants() {
723 assert_eq!(parse_section_header("[Section]"), Some(vec![]));
724 assert_eq!(parse_section_header("^[Section]"), Some(vec![]));
725 assert_eq!(parse_section_header("[Section][2]"), Some(vec![]));
726 assert_eq!(parse_section_header("^[Section][2]"), Some(vec![]));
727 assert_eq!(
728 parse_section_header("[Section] @a @b"),
729 Some(vec!["@a".into(), "@b".into()])
730 );
731 assert_eq!(
732 parse_section_header("[Section][2] @a"),
733 Some(vec!["@a".into()])
734 );
735 }
736
737 #[test]
738 fn parse_section_header_rejects_malformed() {
739 assert_eq!(parse_section_header("[unclosed"), None);
741 assert_eq!(parse_section_header("[]"), None);
742 assert_eq!(parse_section_header("[abc]def @owner"), None);
743 assert_eq!(parse_section_header("[Section][] @owner"), None);
744 assert_eq!(parse_section_header("[Section][abc] @owner"), None);
745 }
746
747 #[test]
748 fn non_section_bracket_pattern_parses_as_rule() {
749 let content = "[abc]def @owner\n";
752 let co = CodeOwners::parse(content).unwrap();
753 assert_eq!(co.owners.len(), 1);
754 assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
755 }
756}