1use std::path::Path;
31
32use globset::{Glob, GlobSet, GlobSetBuilder};
33
34#[derive(Debug)]
36pub struct CodeOwners {
37 owners: Vec<String>,
40 owner_counts: Vec<u32>,
43 patterns: Vec<String>,
46 is_negation: Vec<bool>,
49 sections: Vec<Option<String>>,
52 section_owners: Vec<Vec<String>>,
56 has_sections: bool,
58 globs: GlobSet,
60}
61
62const PROBE_PATHS: &[&str] = &[
66 "CODEOWNERS",
67 ".github/CODEOWNERS",
68 ".gitlab/CODEOWNERS",
69 "docs/CODEOWNERS",
70];
71
72pub const UNOWNED_LABEL: &str = "(unowned)";
74
75pub const NO_SECTION_LABEL: &str = "(no section)";
80
81impl CodeOwners {
82 pub fn from_file(path: &Path) -> Result<Self, String> {
84 let content = std::fs::read_to_string(path)
85 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
86 Self::parse(&content)
87 }
88
89 pub fn discover(root: &Path) -> Result<Self, String> {
93 for probe in PROBE_PATHS {
94 let path = root.join(probe);
95 if path.is_file() {
96 return Self::from_file(&path);
97 }
98 }
99 Err(format!(
100 "no CODEOWNERS file found (looked for: {}). \
101 Create one of these files or use --group-by directory instead",
102 PROBE_PATHS.join(", ")
103 ))
104 }
105
106 pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
108 if let Some(p) = config_path {
109 let path = root.join(p);
110 Self::from_file(&path)
111 } else {
112 Self::discover(root)
113 }
114 }
115
116 pub(crate) fn parse(content: &str) -> Result<Self, String> {
118 let mut builder = GlobSetBuilder::new();
119 let mut owners = Vec::new();
120 let mut owner_counts = Vec::new();
121 let mut patterns = Vec::new();
122 let mut is_negation = Vec::new();
123 let mut sections: Vec<Option<String>> = Vec::new();
124 let mut section_owners: Vec<Vec<String>> = Vec::new();
125 let mut current_section: Option<String> = None;
126 let mut current_section_owners: Vec<String> = Vec::new();
127 let mut has_sections = false;
128
129 for line in content.lines() {
130 let line = line.trim();
131 if line.is_empty() || line.starts_with('#') {
132 continue;
133 }
134
135 if let Some((name, defaults)) = parse_section_header(line) {
136 current_section = Some(name);
137 current_section_owners = defaults;
138 has_sections = true;
139 continue;
140 }
141
142 let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
143 (true, after.trim_start())
144 } else {
145 (false, line)
146 };
147
148 let mut parts = rest.split_whitespace();
149 let Some(pattern) = parts.next() else {
150 continue;
151 };
152 let inline_owners = parts.collect::<Vec<_>>();
153
154 let (effective_owner, owner_count): (&str, u32) = if negate {
155 ("", 0)
156 } else if let Some(owner) = inline_owners.first() {
157 (
158 owner,
159 u32::try_from(inline_owners.len()).unwrap_or(u32::MAX),
160 )
161 } else if let Some(owner) = current_section_owners.first() {
162 (
163 owner.as_str(),
164 u32::try_from(current_section_owners.len()).unwrap_or(u32::MAX),
165 )
166 } else {
167 continue;
168 };
169
170 let glob_pattern = translate_pattern(pattern);
171 let glob = Glob::new(&glob_pattern)
172 .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
173
174 builder.add(glob);
175 owners.push(effective_owner.to_string());
176 owner_counts.push(owner_count);
177 patterns.push(if negate {
178 format!("!{pattern}")
179 } else {
180 pattern.to_string()
181 });
182 is_negation.push(negate);
183 sections.push(current_section.clone());
184 section_owners.push(current_section_owners.clone());
185 }
186
187 let globs = builder
188 .build()
189 .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
190
191 Ok(Self {
192 owners,
193 owner_counts,
194 patterns,
195 is_negation,
196 sections,
197 section_owners,
198 has_sections,
199 globs,
200 })
201 }
202
203 pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
209 let matches = self.globs.matches(relative_path);
210 matches.iter().max().and_then(|&idx| {
211 if self.is_negation[idx] {
212 None
213 } else {
214 Some(self.owners[idx].as_str())
215 }
216 })
217 }
218
219 pub fn owner_count_of(&self, relative_path: &Path) -> Option<u32> {
224 let matches = self.globs.matches(relative_path);
225 matches.iter().max().map(|&idx| {
226 if self.is_negation[idx] {
227 0
228 } else {
229 self.owner_counts[idx]
230 }
231 })
232 }
233
234 pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
241 let matches = self.globs.matches(relative_path);
242 matches.iter().max().and_then(|&idx| {
243 if self.is_negation[idx] {
244 None
245 } else {
246 Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
247 }
248 })
249 }
250
251 #[allow(
258 clippy::option_option,
259 reason = "three distinct states: no match, matched pre-section, matched in named section"
260 )]
261 pub fn section_of(&self, relative_path: &Path) -> Option<Option<&str>> {
262 let matches = self.globs.matches(relative_path);
263 matches.iter().max().and_then(|&idx| {
264 if self.is_negation[idx] {
265 None
266 } else {
267 Some(self.sections[idx].as_deref())
268 }
269 })
270 }
271
272 pub fn section_and_owners_of(&self, relative_path: &Path) -> Option<(Option<&str>, &[String])> {
279 let matches = self.globs.matches(relative_path);
280 matches.iter().max().and_then(|&idx| {
281 if self.is_negation[idx] {
282 None
283 } else {
284 Some((
285 self.sections[idx].as_deref(),
286 self.section_owners[idx].as_slice(),
287 ))
288 }
289 })
290 }
291
292 pub fn section_owners_and_rule_of(
298 &self,
299 relative_path: &Path,
300 ) -> Option<(Option<&str>, &[String], &str)> {
301 let matches = self.globs.matches(relative_path);
302 matches.iter().max().and_then(|&idx| {
303 if self.is_negation[idx] {
304 None
305 } else {
306 Some((
307 self.sections[idx].as_deref(),
308 self.section_owners[idx].as_slice(),
309 self.patterns[idx].as_str(),
310 ))
311 }
312 })
313 }
314
315 pub fn has_sections(&self) -> bool {
320 self.has_sections
321 }
322}
323
324fn parse_section_header(line: &str) -> Option<(String, Vec<String>)> {
340 let rest = line.strip_prefix('^').unwrap_or(line);
341 let rest = rest.strip_prefix('[')?;
342 let close = rest.find(']')?;
343 let name = &rest[..close];
344 if name.is_empty() {
345 return None;
346 }
347 let mut after = &rest[close + 1..];
348
349 if let Some(inner) = after.strip_prefix('[') {
350 let n_close = inner.find(']')?;
351 let count = &inner[..n_close];
352 if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
353 return None;
354 }
355 after = &inner[n_close + 1..];
356 }
357
358 if !after.is_empty() && !after.starts_with(char::is_whitespace) {
359 return None;
360 }
361
362 Some((
363 name.to_string(),
364 after.split_whitespace().map(String::from).collect(),
365 ))
366}
367
368fn translate_pattern(pattern: &str) -> String {
376 let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
377 (true, p)
378 } else {
379 (false, pattern)
380 };
381
382 let expanded = if let Some(p) = rest.strip_suffix('/') {
383 format!("{p}/**")
384 } else {
385 rest.to_string()
386 };
387
388 if !anchored && !expanded.contains('/') {
389 format!("**/{expanded}")
390 } else {
391 expanded
392 }
393}
394
395pub fn directory_group(relative_path: &Path) -> &str {
400 let s = relative_path.to_str().unwrap_or("");
401 let s = if s.contains('\\') {
402 return s.split(['/', '\\']).next().unwrap_or(s);
403 } else {
404 s
405 };
406
407 match s.find('/') {
408 Some(pos) => &s[..pos],
409 None => s, }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use std::path::PathBuf;
417
418 #[test]
419 fn translate_bare_glob() {
420 assert_eq!(translate_pattern("*.js"), "**/*.js");
421 }
422
423 #[test]
424 fn translate_rooted_pattern() {
425 assert_eq!(translate_pattern("/docs/*"), "docs/*");
426 }
427
428 #[test]
429 fn translate_directory_pattern() {
430 assert_eq!(translate_pattern("docs/"), "docs/**");
431 }
432
433 #[test]
434 fn translate_rooted_directory() {
435 assert_eq!(translate_pattern("/src/app/"), "src/app/**");
436 }
437
438 #[test]
439 fn translate_path_with_slash() {
440 assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
441 }
442
443 #[test]
444 fn translate_double_star() {
445 assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
446 }
447
448 #[test]
449 fn translate_single_file() {
450 assert_eq!(translate_pattern("Makefile"), "**/Makefile");
451 }
452
453 #[test]
454 fn parse_simple_codeowners() {
455 let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
456 let co = CodeOwners::parse(content).unwrap();
457 assert_eq!(co.owners.len(), 3);
458 }
459
460 #[test]
461 fn parse_skips_comments_and_blanks() {
462 let content = "# Comment\n\n* @owner\n # Indented comment\n";
463 let co = CodeOwners::parse(content).unwrap();
464 assert_eq!(co.owners.len(), 1);
465 }
466
467 #[test]
468 fn parse_multi_owner_takes_first() {
469 let content = "*.ts @team-a @team-b @team-c\n";
470 let co = CodeOwners::parse(content).unwrap();
471 assert_eq!(co.owners[0], "@team-a");
472 }
473
474 #[test]
475 fn parse_skips_pattern_without_owner() {
476 let content = "*.ts\n*.js @owner\n";
477 let co = CodeOwners::parse(content).unwrap();
478 assert_eq!(co.owners.len(), 1);
479 assert_eq!(co.owners[0], "@owner");
480 }
481
482 #[test]
483 fn parse_empty_content() {
484 let co = CodeOwners::parse("").unwrap();
485 assert_eq!(co.owner_of(Path::new("anything.ts")), None);
486 }
487
488 #[test]
489 fn owner_of_last_match_wins() {
490 let content = "* @default\n/src/ @frontend\n";
491 let co = CodeOwners::parse(content).unwrap();
492 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
493 }
494
495 #[test]
496 fn owner_of_falls_back_to_catch_all() {
497 let content = "* @default\n/src/ @frontend\n";
498 let co = CodeOwners::parse(content).unwrap();
499 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
500 }
501
502 #[test]
503 fn owner_of_no_match_returns_none() {
504 let content = "/src/ @frontend\n";
505 let co = CodeOwners::parse(content).unwrap();
506 assert_eq!(co.owner_of(Path::new("README.md")), None);
507 }
508
509 #[test]
510 fn owner_of_extension_glob() {
511 let content = "*.rs @rust-team\n*.ts @ts-team\n";
512 let co = CodeOwners::parse(content).unwrap();
513 assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
514 assert_eq!(
515 co.owner_of(Path::new("packages/ui/Button.ts")),
516 Some("@ts-team")
517 );
518 }
519
520 #[test]
521 fn owner_of_nested_directory() {
522 let content = "* @default\n/packages/auth/ @auth-team\n";
523 let co = CodeOwners::parse(content).unwrap();
524 assert_eq!(
525 co.owner_of(Path::new("packages/auth/src/login.ts")),
526 Some("@auth-team")
527 );
528 assert_eq!(
529 co.owner_of(Path::new("packages/ui/Button.ts")),
530 Some("@default")
531 );
532 }
533
534 #[test]
535 fn owner_of_specific_overrides_general() {
536 let content = "\
537 * @default\n\
538 /src/ @frontend\n\
539 /src/api/ @backend\n\
540 ";
541 let co = CodeOwners::parse(content).unwrap();
542 assert_eq!(
543 co.owner_of(Path::new("src/api/routes.ts")),
544 Some("@backend")
545 );
546 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
547 }
548
549 #[test]
550 fn owner_and_rule_of_returns_owner_and_pattern() {
551 let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
552 let co = CodeOwners::parse(content).unwrap();
553 assert_eq!(
554 co.owner_and_rule_of(Path::new("src/app.ts")),
555 Some(("@frontend", "/src/"))
556 );
557 assert_eq!(
558 co.owner_and_rule_of(Path::new("src/lib.rs")),
559 Some(("@rust-team", "*.rs"))
560 );
561 assert_eq!(
562 co.owner_and_rule_of(Path::new("README.md")),
563 Some(("@default", "*"))
564 );
565 }
566
567 #[test]
568 fn owner_and_rule_of_no_match() {
569 let content = "/src/ @frontend\n";
570 let co = CodeOwners::parse(content).unwrap();
571 assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
572 }
573
574 #[test]
575 fn directory_group_simple() {
576 assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
577 }
578
579 #[test]
580 fn directory_group_root_file() {
581 assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
582 }
583
584 #[test]
585 fn directory_group_monorepo() {
586 assert_eq!(
587 directory_group(Path::new("packages/auth/src/login.ts")),
588 "packages"
589 );
590 }
591
592 #[test]
593 fn discover_nonexistent_root() {
594 let result = CodeOwners::discover(Path::new("/nonexistent/path"));
595 assert!(result.is_err());
596 let err = result.unwrap_err();
597 assert!(err.contains("no CODEOWNERS file found"));
598 assert!(err.contains("--group-by directory"));
599 }
600
601 #[test]
602 fn from_file_nonexistent() {
603 let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
604 assert!(result.is_err());
605 }
606
607 #[test]
608 fn from_file_real_codeowners() {
609 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
610 .parent()
611 .unwrap()
612 .parent()
613 .unwrap()
614 .to_path_buf();
615 let path = root.join(".github/CODEOWNERS");
616 if path.exists() {
617 let co = CodeOwners::from_file(&path).unwrap();
618 assert_eq!(
619 co.owner_of(Path::new("src/anything.ts")),
620 Some("@bartwaardenburg")
621 );
622 }
623 }
624
625 #[test]
626 fn email_owner() {
627 let content = "*.js user@example.com\n";
628 let co = CodeOwners::parse(content).unwrap();
629 assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
630 }
631
632 #[test]
633 fn team_owner() {
634 let content = "*.ts @org/frontend-team\n";
635 let co = CodeOwners::parse(content).unwrap();
636 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
637 }
638
639 #[test]
640 fn gitlab_section_header_skipped_as_rule() {
641 let content = "[Section Name]\n*.ts @owner\n";
642 let co = CodeOwners::parse(content).unwrap();
643 assert_eq!(co.owners.len(), 1);
644 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
645 }
646
647 #[test]
648 fn gitlab_optional_section_header_skipped() {
649 let content = "^[Optional Section]\n*.ts @owner\n";
650 let co = CodeOwners::parse(content).unwrap();
651 assert_eq!(co.owners.len(), 1);
652 }
653
654 #[test]
655 fn gitlab_section_header_with_approval_count_skipped() {
656 let content = "[Section Name][2]\n*.ts @owner\n";
657 let co = CodeOwners::parse(content).unwrap();
658 assert_eq!(co.owners.len(), 1);
659 }
660
661 #[test]
662 fn gitlab_optional_section_with_approval_count_skipped() {
663 let content = "^[Section Name][3] @fallback-team\nfoo/\n";
664 let co = CodeOwners::parse(content).unwrap();
665 assert_eq!(co.owners.len(), 1);
666 assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
667 }
668
669 #[test]
670 fn gitlab_section_default_owners_inherited() {
671 let content = "\
672 [Utilities] @utils-team\n\
673 src/utils/\n\
674 [UI Components] @ui-team\n\
675 src/components/\n\
676 ";
677 let co = CodeOwners::parse(content).unwrap();
678 assert_eq!(co.owners.len(), 2);
679 assert_eq!(
680 co.owner_of(Path::new("src/utils/greet.ts")),
681 Some("@utils-team")
682 );
683 assert_eq!(
684 co.owner_of(Path::new("src/components/button.ts")),
685 Some("@ui-team")
686 );
687 }
688
689 #[test]
690 fn gitlab_inline_owner_overrides_section_default() {
691 let content = "\
692 [Section] @section-owner\n\
693 src/generic/\n\
694 src/special/ @special-owner\n\
695 ";
696 let co = CodeOwners::parse(content).unwrap();
697 assert_eq!(
698 co.owner_of(Path::new("src/generic/a.ts")),
699 Some("@section-owner")
700 );
701 assert_eq!(
702 co.owner_of(Path::new("src/special/a.ts")),
703 Some("@special-owner")
704 );
705 }
706
707 #[test]
708 fn gitlab_section_defaults_reset_between_sections() {
709 let content = "\
710 [Section1] @team-a\n\
711 foo/\n\
712 [Section2]\n\
713 bar/\n\
714 ";
715 let co = CodeOwners::parse(content).unwrap();
716 assert_eq!(co.owners.len(), 1);
717 assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
718 assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
719 }
720
721 #[test]
722 fn gitlab_section_header_multiple_default_owners_uses_first() {
723 let content = "[Section] @first @second\nfoo/\n";
724 let co = CodeOwners::parse(content).unwrap();
725 assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
726 }
727
728 #[test]
729 fn gitlab_rules_before_first_section_retain_inline_owners() {
730 let content = "\
731 * @default-owner\n\
732 [Utilities] @utils-team\n\
733 src/utils/\n\
734 ";
735 let co = CodeOwners::parse(content).unwrap();
736 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
737 assert_eq!(
738 co.owner_of(Path::new("src/utils/greet.ts")),
739 Some("@utils-team")
740 );
741 }
742
743 #[test]
744 fn gitlab_issue_127_reproduction() {
745 let content = "\
746# Default section (no header, rules before first section)
747* @default-owner
748
749[Utilities] @utils-team
750src/utils/
751
752[UI Components] @ui-team
753src/components/
754";
755 let co = CodeOwners::parse(content).unwrap();
756 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
757 assert_eq!(
758 co.owner_of(Path::new("src/utils/greet.ts")),
759 Some("@utils-team")
760 );
761 assert_eq!(
762 co.owner_of(Path::new("src/components/button.ts")),
763 Some("@ui-team")
764 );
765 }
766
767 #[test]
768 fn gitlab_negation_last_match_clears_ownership() {
769 let content = "\
770 * @default\n\
771 !src/generated/\n\
772 ";
773 let co = CodeOwners::parse(content).unwrap();
774 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
775 assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
776 }
777
778 #[test]
779 fn gitlab_negation_only_clears_when_last_match() {
780 let content = "\
781 * @default\n\
782 !src/\n\
783 /src/special/ @special\n\
784 ";
785 let co = CodeOwners::parse(content).unwrap();
786 assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
787 assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
788 }
789
790 #[test]
791 fn gitlab_negation_owner_and_rule_returns_none() {
792 let content = "* @default\n!src/vendor/\n";
793 let co = CodeOwners::parse(content).unwrap();
794 assert_eq!(
795 co.owner_and_rule_of(Path::new("README.md")),
796 Some(("@default", "*"))
797 );
798 assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
799 }
800
801 #[test]
802 fn parse_section_header_variants() {
803 assert_eq!(
804 parse_section_header("[Section]"),
805 Some(("Section".into(), vec![]))
806 );
807 assert_eq!(
808 parse_section_header("^[Section]"),
809 Some(("Section".into(), vec![]))
810 );
811 assert_eq!(
812 parse_section_header("[Section][2]"),
813 Some(("Section".into(), vec![]))
814 );
815 assert_eq!(
816 parse_section_header("^[Section][2]"),
817 Some(("Section".into(), vec![]))
818 );
819 assert_eq!(
820 parse_section_header("[Section] @a @b"),
821 Some(("Section".into(), vec!["@a".into(), "@b".into()]))
822 );
823 assert_eq!(
824 parse_section_header("[Section][2] @a"),
825 Some(("Section".into(), vec!["@a".into()]))
826 );
827 }
828
829 #[test]
830 fn parse_section_header_rejects_malformed() {
831 assert_eq!(parse_section_header("[unclosed"), None);
832 assert_eq!(parse_section_header("[]"), None);
833 assert_eq!(parse_section_header("[abc]def @owner"), None);
834 assert_eq!(parse_section_header("[Section][] @owner"), None);
835 assert_eq!(parse_section_header("[Section][abc] @owner"), None);
836 }
837
838 #[test]
839 fn has_sections_false_without_headers() {
840 let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
841 assert!(!co.has_sections());
842 }
843
844 #[test]
845 fn has_sections_true_with_headers() {
846 let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
847 assert!(co.has_sections());
848 }
849
850 #[test]
851 fn section_of_returns_named_section() {
852 let content = "\
853 [Billing] @billing-team\n\
854 src/billing/\n\
855 [Search] @search-team\n\
856 src/search/\n\
857 ";
858 let co = CodeOwners::parse(content).unwrap();
859 assert_eq!(
860 co.section_of(Path::new("src/billing/invoice.ts")),
861 Some(Some("Billing"))
862 );
863 assert_eq!(
864 co.section_of(Path::new("src/search/indexer.ts")),
865 Some(Some("Search"))
866 );
867 }
868
869 #[test]
870 fn section_of_returns_some_none_for_pre_section_rule() {
871 let content = "\
872 * @default\n\
873 [Billing] @billing-team\n\
874 src/billing/\n\
875 ";
876 let co = CodeOwners::parse(content).unwrap();
877 assert_eq!(co.section_of(Path::new("README.md")), Some(None));
878 assert_eq!(
879 co.section_of(Path::new("src/billing/invoice.ts")),
880 Some(Some("Billing"))
881 );
882 }
883
884 #[test]
885 fn section_of_returns_none_for_unmatched_path() {
886 let content = "[Billing] @billing-team\nsrc/billing/\n";
887 let co = CodeOwners::parse(content).unwrap();
888 assert_eq!(co.section_of(Path::new("src/other/x.ts")), None);
889 }
890
891 #[test]
892 fn section_of_returns_none_for_negation_last_match() {
893 let content = "\
894 [Billing] @billing-team\n\
895 src/billing/\n\
896 !src/billing/vendor/\n\
897 ";
898 let co = CodeOwners::parse(content).unwrap();
899 assert_eq!(
900 co.section_of(Path::new("src/billing/invoice.ts")),
901 Some(Some("Billing"))
902 );
903 assert_eq!(co.section_of(Path::new("src/billing/vendor/lib.js")), None);
904 }
905
906 #[test]
907 fn section_and_owners_of_returns_section_defaults() {
908 let content = "\
909 [Billing] @core-reviewers @alice\n\
910 src/billing/\n\
911 ";
912 let co = CodeOwners::parse(content).unwrap();
913 let (section, owners) = co
914 .section_and_owners_of(Path::new("src/billing/invoice.ts"))
915 .unwrap();
916 assert_eq!(section, Some("Billing"));
917 assert_eq!(
918 owners,
919 &["@core-reviewers".to_string(), "@alice".to_string()]
920 );
921 }
922
923 #[test]
924 fn section_and_owners_of_same_owners_distinct_sections() {
925 let content = "\
926 [billing] @core-reviewers @alice @bob\n\
927 src/billing/\n\
928 [notifications] @core-reviewers @alice @bob\n\
929 src/notifications/\n\
930 ";
931 let co = CodeOwners::parse(content).unwrap();
932 let (billing_sec, _) = co
933 .section_and_owners_of(Path::new("src/billing/invoice.ts"))
934 .unwrap();
935 let (notifications_sec, _) = co
936 .section_and_owners_of(Path::new("src/notifications/email.ts"))
937 .unwrap();
938 assert_eq!(billing_sec, Some("billing"));
939 assert_eq!(notifications_sec, Some("notifications"));
940 }
941
942 #[test]
943 fn section_and_owners_of_empty_owners_for_pre_section_rule() {
944 let content = "* @default\n[Billing]\nsrc/billing/ @billing\n";
945 let co = CodeOwners::parse(content).unwrap();
946 let (section, owners) = co.section_and_owners_of(Path::new("README.md")).unwrap();
947 assert_eq!(section, None);
948 assert!(owners.is_empty());
949 }
950
951 #[test]
952 fn owner_count_of_counts_all_matched_owners() {
953 let content = "\
954 * @default\n\
955 src/api/ @backend @payments @security\n\
956 [Frontend] @ui @design\n\
957 src/ui/\n\
958 !src/generated/\n\
959 ";
960 let co = CodeOwners::parse(content).unwrap();
961 assert_eq!(co.owner_count_of(Path::new("src/api/payments.ts")), Some(3));
962 assert_eq!(co.owner_count_of(Path::new("src/ui/button.tsx")), Some(2));
963 assert_eq!(co.owner_count_of(Path::new("README.md")), Some(1));
964 assert_eq!(
965 co.owner_count_of(Path::new("src/generated/types.ts")),
966 Some(0)
967 );
968 assert_eq!(
969 co.owner_count_of(Path::new("other/generated/types.ts")),
970 Some(1)
971 );
972 }
973
974 #[test]
975 fn non_section_bracket_pattern_parses_as_rule() {
976 let content = "[abc]def @owner\n";
977 let co = CodeOwners::parse(content).unwrap();
978 assert_eq!(co.owners.len(), 1);
979 assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
980 }
981}