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) {
139 current_section = Some(name);
140 current_section_owners = defaults;
141 has_sections = true;
142 continue;
143 }
144
145 let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
147 (true, after.trim_start())
148 } else {
149 (false, line)
150 };
151
152 let mut parts = rest.split_whitespace();
153 let Some(pattern) = parts.next() else {
154 continue;
155 };
156 let inline_owners = parts.collect::<Vec<_>>();
157
158 let (effective_owner, owner_count): (&str, u32) = if negate {
159 ("", 0)
162 } else if let Some(owner) = inline_owners.first() {
163 (
164 owner,
165 u32::try_from(inline_owners.len()).unwrap_or(u32::MAX),
166 )
167 } else if let Some(owner) = current_section_owners.first() {
168 (
169 owner.as_str(),
170 u32::try_from(current_section_owners.len()).unwrap_or(u32::MAX),
171 )
172 } else {
173 continue;
175 };
176
177 let glob_pattern = translate_pattern(pattern);
178 let glob = Glob::new(&glob_pattern)
179 .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
180
181 builder.add(glob);
182 owners.push(effective_owner.to_string());
183 owner_counts.push(owner_count);
184 patterns.push(if negate {
185 format!("!{pattern}")
186 } else {
187 pattern.to_string()
188 });
189 is_negation.push(negate);
190 sections.push(current_section.clone());
191 section_owners.push(current_section_owners.clone());
192 }
193
194 let globs = builder
195 .build()
196 .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
197
198 Ok(Self {
199 owners,
200 owner_counts,
201 patterns,
202 is_negation,
203 sections,
204 section_owners,
205 has_sections,
206 globs,
207 })
208 }
209
210 pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
216 let matches = self.globs.matches(relative_path);
217 matches.iter().max().and_then(|&idx| {
219 if self.is_negation[idx] {
220 None
221 } else {
222 Some(self.owners[idx].as_str())
223 }
224 })
225 }
226
227 pub fn owner_count_of(&self, relative_path: &Path) -> Option<u32> {
232 let matches = self.globs.matches(relative_path);
233 matches.iter().max().map(|&idx| {
234 if self.is_negation[idx] {
235 0
236 } else {
237 self.owner_counts[idx]
238 }
239 })
240 }
241
242 pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
249 let matches = self.globs.matches(relative_path);
250 matches.iter().max().and_then(|&idx| {
251 if self.is_negation[idx] {
252 None
253 } else {
254 Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
255 }
256 })
257 }
258
259 #[allow(
266 clippy::option_option,
267 reason = "three distinct states: no match, matched pre-section, matched in named section"
268 )]
269 pub fn section_of(&self, relative_path: &Path) -> Option<Option<&str>> {
270 let matches = self.globs.matches(relative_path);
271 matches.iter().max().and_then(|&idx| {
272 if self.is_negation[idx] {
273 None
274 } else {
275 Some(self.sections[idx].as_deref())
276 }
277 })
278 }
279
280 pub fn section_and_owners_of(&self, relative_path: &Path) -> Option<(Option<&str>, &[String])> {
287 let matches = self.globs.matches(relative_path);
288 matches.iter().max().and_then(|&idx| {
289 if self.is_negation[idx] {
290 None
291 } else {
292 Some((
293 self.sections[idx].as_deref(),
294 self.section_owners[idx].as_slice(),
295 ))
296 }
297 })
298 }
299
300 pub fn section_owners_and_rule_of(
306 &self,
307 relative_path: &Path,
308 ) -> Option<(Option<&str>, &[String], &str)> {
309 let matches = self.globs.matches(relative_path);
310 matches.iter().max().and_then(|&idx| {
311 if self.is_negation[idx] {
312 None
313 } else {
314 Some((
315 self.sections[idx].as_deref(),
316 self.section_owners[idx].as_slice(),
317 self.patterns[idx].as_str(),
318 ))
319 }
320 })
321 }
322
323 pub fn has_sections(&self) -> bool {
328 self.has_sections
329 }
330}
331
332fn parse_section_header(line: &str) -> Option<(String, Vec<String>)> {
348 let rest = line.strip_prefix('^').unwrap_or(line);
349 let rest = rest.strip_prefix('[')?;
350 let close = rest.find(']')?;
351 let name = &rest[..close];
352 if name.is_empty() {
353 return None;
354 }
355 let mut after = &rest[close + 1..];
356
357 if let Some(inner) = after.strip_prefix('[') {
359 let n_close = inner.find(']')?;
360 let count = &inner[..n_close];
361 if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
362 return None;
363 }
364 after = &inner[n_close + 1..];
365 }
366
367 if !after.is_empty() && !after.starts_with(char::is_whitespace) {
370 return None;
371 }
372
373 Some((
374 name.to_string(),
375 after.split_whitespace().map(String::from).collect(),
376 ))
377}
378
379fn translate_pattern(pattern: &str) -> String {
387 let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
389 (true, p)
390 } else {
391 (false, pattern)
392 };
393
394 let expanded = if let Some(p) = rest.strip_suffix('/') {
396 format!("{p}/**")
397 } else {
398 rest.to_string()
399 };
400
401 if !anchored && !expanded.contains('/') {
403 format!("**/{expanded}")
404 } else {
405 expanded
406 }
407}
408
409pub fn directory_group(relative_path: &Path) -> &str {
414 let s = relative_path.to_str().unwrap_or("");
415 let s = if s.contains('\\') {
417 return s.split(['/', '\\']).next().unwrap_or(s);
419 } else {
420 s
421 };
422
423 match s.find('/') {
424 Some(pos) => &s[..pos],
425 None => s, }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use std::path::PathBuf;
433
434 #[test]
437 fn translate_bare_glob() {
438 assert_eq!(translate_pattern("*.js"), "**/*.js");
439 }
440
441 #[test]
442 fn translate_rooted_pattern() {
443 assert_eq!(translate_pattern("/docs/*"), "docs/*");
444 }
445
446 #[test]
447 fn translate_directory_pattern() {
448 assert_eq!(translate_pattern("docs/"), "docs/**");
449 }
450
451 #[test]
452 fn translate_rooted_directory() {
453 assert_eq!(translate_pattern("/src/app/"), "src/app/**");
454 }
455
456 #[test]
457 fn translate_path_with_slash() {
458 assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
459 }
460
461 #[test]
462 fn translate_double_star() {
463 assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
465 }
466
467 #[test]
468 fn translate_single_file() {
469 assert_eq!(translate_pattern("Makefile"), "**/Makefile");
470 }
471
472 #[test]
475 fn parse_simple_codeowners() {
476 let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
477 let co = CodeOwners::parse(content).unwrap();
478 assert_eq!(co.owners.len(), 3);
479 }
480
481 #[test]
482 fn parse_skips_comments_and_blanks() {
483 let content = "# Comment\n\n* @owner\n # Indented comment\n";
484 let co = CodeOwners::parse(content).unwrap();
485 assert_eq!(co.owners.len(), 1);
486 }
487
488 #[test]
489 fn parse_multi_owner_takes_first() {
490 let content = "*.ts @team-a @team-b @team-c\n";
491 let co = CodeOwners::parse(content).unwrap();
492 assert_eq!(co.owners[0], "@team-a");
493 }
494
495 #[test]
496 fn parse_skips_pattern_without_owner() {
497 let content = "*.ts\n*.js @owner\n";
498 let co = CodeOwners::parse(content).unwrap();
499 assert_eq!(co.owners.len(), 1);
500 assert_eq!(co.owners[0], "@owner");
501 }
502
503 #[test]
504 fn parse_empty_content() {
505 let co = CodeOwners::parse("").unwrap();
506 assert_eq!(co.owner_of(Path::new("anything.ts")), None);
507 }
508
509 #[test]
512 fn owner_of_last_match_wins() {
513 let content = "* @default\n/src/ @frontend\n";
514 let co = CodeOwners::parse(content).unwrap();
515 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
516 }
517
518 #[test]
519 fn owner_of_falls_back_to_catch_all() {
520 let content = "* @default\n/src/ @frontend\n";
521 let co = CodeOwners::parse(content).unwrap();
522 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
523 }
524
525 #[test]
526 fn owner_of_no_match_returns_none() {
527 let content = "/src/ @frontend\n";
528 let co = CodeOwners::parse(content).unwrap();
529 assert_eq!(co.owner_of(Path::new("README.md")), None);
530 }
531
532 #[test]
533 fn owner_of_extension_glob() {
534 let content = "*.rs @rust-team\n*.ts @ts-team\n";
535 let co = CodeOwners::parse(content).unwrap();
536 assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
537 assert_eq!(
538 co.owner_of(Path::new("packages/ui/Button.ts")),
539 Some("@ts-team")
540 );
541 }
542
543 #[test]
544 fn owner_of_nested_directory() {
545 let content = "* @default\n/packages/auth/ @auth-team\n";
546 let co = CodeOwners::parse(content).unwrap();
547 assert_eq!(
548 co.owner_of(Path::new("packages/auth/src/login.ts")),
549 Some("@auth-team")
550 );
551 assert_eq!(
552 co.owner_of(Path::new("packages/ui/Button.ts")),
553 Some("@default")
554 );
555 }
556
557 #[test]
558 fn owner_of_specific_overrides_general() {
559 let content = "\
561 * @default\n\
562 /src/ @frontend\n\
563 /src/api/ @backend\n\
564 ";
565 let co = CodeOwners::parse(content).unwrap();
566 assert_eq!(
567 co.owner_of(Path::new("src/api/routes.ts")),
568 Some("@backend")
569 );
570 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
571 }
572
573 #[test]
576 fn owner_and_rule_of_returns_owner_and_pattern() {
577 let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
578 let co = CodeOwners::parse(content).unwrap();
579 assert_eq!(
580 co.owner_and_rule_of(Path::new("src/app.ts")),
581 Some(("@frontend", "/src/"))
582 );
583 assert_eq!(
584 co.owner_and_rule_of(Path::new("src/lib.rs")),
585 Some(("@rust-team", "*.rs"))
586 );
587 assert_eq!(
588 co.owner_and_rule_of(Path::new("README.md")),
589 Some(("@default", "*"))
590 );
591 }
592
593 #[test]
594 fn owner_and_rule_of_no_match() {
595 let content = "/src/ @frontend\n";
596 let co = CodeOwners::parse(content).unwrap();
597 assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
598 }
599
600 #[test]
603 fn directory_group_simple() {
604 assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
605 }
606
607 #[test]
608 fn directory_group_root_file() {
609 assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
610 }
611
612 #[test]
613 fn directory_group_monorepo() {
614 assert_eq!(
615 directory_group(Path::new("packages/auth/src/login.ts")),
616 "packages"
617 );
618 }
619
620 #[test]
623 fn discover_nonexistent_root() {
624 let result = CodeOwners::discover(Path::new("/nonexistent/path"));
625 assert!(result.is_err());
626 let err = result.unwrap_err();
627 assert!(err.contains("no CODEOWNERS file found"));
628 assert!(err.contains("--group-by directory"));
629 }
630
631 #[test]
634 fn from_file_nonexistent() {
635 let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
636 assert!(result.is_err());
637 }
638
639 #[test]
640 fn from_file_real_codeowners() {
641 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
643 .parent()
644 .unwrap()
645 .parent()
646 .unwrap()
647 .to_path_buf();
648 let path = root.join(".github/CODEOWNERS");
649 if path.exists() {
650 let co = CodeOwners::from_file(&path).unwrap();
651 assert_eq!(
653 co.owner_of(Path::new("src/anything.ts")),
654 Some("@bartwaardenburg")
655 );
656 }
657 }
658
659 #[test]
662 fn email_owner() {
663 let content = "*.js user@example.com\n";
664 let co = CodeOwners::parse(content).unwrap();
665 assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
666 }
667
668 #[test]
669 fn team_owner() {
670 let content = "*.ts @org/frontend-team\n";
671 let co = CodeOwners::parse(content).unwrap();
672 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
673 }
674
675 #[test]
678 fn gitlab_section_header_skipped_as_rule() {
679 let content = "[Section Name]\n*.ts @owner\n";
681 let co = CodeOwners::parse(content).unwrap();
682 assert_eq!(co.owners.len(), 1);
683 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
684 }
685
686 #[test]
687 fn gitlab_optional_section_header_skipped() {
688 let content = "^[Optional Section]\n*.ts @owner\n";
689 let co = CodeOwners::parse(content).unwrap();
690 assert_eq!(co.owners.len(), 1);
691 }
692
693 #[test]
694 fn gitlab_section_header_with_approval_count_skipped() {
695 let content = "[Section Name][2]\n*.ts @owner\n";
696 let co = CodeOwners::parse(content).unwrap();
697 assert_eq!(co.owners.len(), 1);
698 }
699
700 #[test]
701 fn gitlab_optional_section_with_approval_count_skipped() {
702 let content = "^[Section Name][3] @fallback-team\nfoo/\n";
703 let co = CodeOwners::parse(content).unwrap();
704 assert_eq!(co.owners.len(), 1);
705 assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
706 }
707
708 #[test]
709 fn gitlab_section_default_owners_inherited() {
710 let content = "\
711 [Utilities] @utils-team\n\
712 src/utils/\n\
713 [UI Components] @ui-team\n\
714 src/components/\n\
715 ";
716 let co = CodeOwners::parse(content).unwrap();
717 assert_eq!(co.owners.len(), 2);
718 assert_eq!(
719 co.owner_of(Path::new("src/utils/greet.ts")),
720 Some("@utils-team")
721 );
722 assert_eq!(
723 co.owner_of(Path::new("src/components/button.ts")),
724 Some("@ui-team")
725 );
726 }
727
728 #[test]
729 fn gitlab_inline_owner_overrides_section_default() {
730 let content = "\
731 [Section] @section-owner\n\
732 src/generic/\n\
733 src/special/ @special-owner\n\
734 ";
735 let co = CodeOwners::parse(content).unwrap();
736 assert_eq!(
737 co.owner_of(Path::new("src/generic/a.ts")),
738 Some("@section-owner")
739 );
740 assert_eq!(
741 co.owner_of(Path::new("src/special/a.ts")),
742 Some("@special-owner")
743 );
744 }
745
746 #[test]
747 fn gitlab_section_defaults_reset_between_sections() {
748 let content = "\
751 [Section1] @team-a\n\
752 foo/\n\
753 [Section2]\n\
754 bar/\n\
755 ";
756 let co = CodeOwners::parse(content).unwrap();
757 assert_eq!(co.owners.len(), 1);
758 assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
759 assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
760 }
761
762 #[test]
763 fn gitlab_section_header_multiple_default_owners_uses_first() {
764 let content = "[Section] @first @second\nfoo/\n";
765 let co = CodeOwners::parse(content).unwrap();
766 assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
767 }
768
769 #[test]
770 fn gitlab_rules_before_first_section_retain_inline_owners() {
771 let content = "\
774 * @default-owner\n\
775 [Utilities] @utils-team\n\
776 src/utils/\n\
777 ";
778 let co = CodeOwners::parse(content).unwrap();
779 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
780 assert_eq!(
781 co.owner_of(Path::new("src/utils/greet.ts")),
782 Some("@utils-team")
783 );
784 }
785
786 #[test]
787 fn gitlab_issue_127_reproduction() {
788 let content = "\
790# Default section (no header, rules before first section)
791* @default-owner
792
793[Utilities] @utils-team
794src/utils/
795
796[UI Components] @ui-team
797src/components/
798";
799 let co = CodeOwners::parse(content).unwrap();
800 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
801 assert_eq!(
802 co.owner_of(Path::new("src/utils/greet.ts")),
803 Some("@utils-team")
804 );
805 assert_eq!(
806 co.owner_of(Path::new("src/components/button.ts")),
807 Some("@ui-team")
808 );
809 }
810
811 #[test]
814 fn gitlab_negation_last_match_clears_ownership() {
815 let content = "\
816 * @default\n\
817 !src/generated/\n\
818 ";
819 let co = CodeOwners::parse(content).unwrap();
820 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
821 assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
822 }
823
824 #[test]
825 fn gitlab_negation_only_clears_when_last_match() {
826 let content = "\
828 * @default\n\
829 !src/\n\
830 /src/special/ @special\n\
831 ";
832 let co = CodeOwners::parse(content).unwrap();
833 assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
834 assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
835 }
836
837 #[test]
838 fn gitlab_negation_owner_and_rule_returns_none() {
839 let content = "* @default\n!src/vendor/\n";
840 let co = CodeOwners::parse(content).unwrap();
841 assert_eq!(
842 co.owner_and_rule_of(Path::new("README.md")),
843 Some(("@default", "*"))
844 );
845 assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
846 }
847
848 #[test]
851 fn parse_section_header_variants() {
852 assert_eq!(
853 parse_section_header("[Section]"),
854 Some(("Section".into(), vec![]))
855 );
856 assert_eq!(
857 parse_section_header("^[Section]"),
858 Some(("Section".into(), vec![]))
859 );
860 assert_eq!(
861 parse_section_header("[Section][2]"),
862 Some(("Section".into(), vec![]))
863 );
864 assert_eq!(
865 parse_section_header("^[Section][2]"),
866 Some(("Section".into(), vec![]))
867 );
868 assert_eq!(
869 parse_section_header("[Section] @a @b"),
870 Some(("Section".into(), vec!["@a".into(), "@b".into()]))
871 );
872 assert_eq!(
873 parse_section_header("[Section][2] @a"),
874 Some(("Section".into(), vec!["@a".into()]))
875 );
876 }
877
878 #[test]
879 fn parse_section_header_rejects_malformed() {
880 assert_eq!(parse_section_header("[unclosed"), None);
882 assert_eq!(parse_section_header("[]"), None);
883 assert_eq!(parse_section_header("[abc]def @owner"), None);
884 assert_eq!(parse_section_header("[Section][] @owner"), None);
885 assert_eq!(parse_section_header("[Section][abc] @owner"), None);
886 }
887
888 #[test]
891 fn has_sections_false_without_headers() {
892 let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
893 assert!(!co.has_sections());
894 }
895
896 #[test]
897 fn has_sections_true_with_headers() {
898 let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
899 assert!(co.has_sections());
900 }
901
902 #[test]
903 fn section_of_returns_named_section() {
904 let content = "\
905 [Billing] @billing-team\n\
906 src/billing/\n\
907 [Search] @search-team\n\
908 src/search/\n\
909 ";
910 let co = CodeOwners::parse(content).unwrap();
911 assert_eq!(
912 co.section_of(Path::new("src/billing/invoice.ts")),
913 Some(Some("Billing"))
914 );
915 assert_eq!(
916 co.section_of(Path::new("src/search/indexer.ts")),
917 Some(Some("Search"))
918 );
919 }
920
921 #[test]
922 fn section_of_returns_some_none_for_pre_section_rule() {
923 let content = "\
925 * @default\n\
926 [Billing] @billing-team\n\
927 src/billing/\n\
928 ";
929 let co = CodeOwners::parse(content).unwrap();
930 assert_eq!(co.section_of(Path::new("README.md")), Some(None));
931 assert_eq!(
932 co.section_of(Path::new("src/billing/invoice.ts")),
933 Some(Some("Billing"))
934 );
935 }
936
937 #[test]
938 fn section_of_returns_none_for_unmatched_path() {
939 let content = "[Billing] @billing-team\nsrc/billing/\n";
940 let co = CodeOwners::parse(content).unwrap();
941 assert_eq!(co.section_of(Path::new("src/other/x.ts")), None);
942 }
943
944 #[test]
945 fn section_of_returns_none_for_negation_last_match() {
946 let content = "\
947 [Billing] @billing-team\n\
948 src/billing/\n\
949 !src/billing/vendor/\n\
950 ";
951 let co = CodeOwners::parse(content).unwrap();
952 assert_eq!(
953 co.section_of(Path::new("src/billing/invoice.ts")),
954 Some(Some("Billing"))
955 );
956 assert_eq!(co.section_of(Path::new("src/billing/vendor/lib.js")), None);
957 }
958
959 #[test]
960 fn section_and_owners_of_returns_section_defaults() {
961 let content = "\
962 [Billing] @core-reviewers @alice\n\
963 src/billing/\n\
964 ";
965 let co = CodeOwners::parse(content).unwrap();
966 let (section, owners) = co
967 .section_and_owners_of(Path::new("src/billing/invoice.ts"))
968 .unwrap();
969 assert_eq!(section, Some("Billing"));
970 assert_eq!(
971 owners,
972 &["@core-reviewers".to_string(), "@alice".to_string()]
973 );
974 }
975
976 #[test]
977 fn section_and_owners_of_same_owners_distinct_sections() {
978 let content = "\
981 [billing] @core-reviewers @alice @bob\n\
982 src/billing/\n\
983 [notifications] @core-reviewers @alice @bob\n\
984 src/notifications/\n\
985 ";
986 let co = CodeOwners::parse(content).unwrap();
987 let (billing_sec, _) = co
988 .section_and_owners_of(Path::new("src/billing/invoice.ts"))
989 .unwrap();
990 let (notifications_sec, _) = co
991 .section_and_owners_of(Path::new("src/notifications/email.ts"))
992 .unwrap();
993 assert_eq!(billing_sec, Some("billing"));
994 assert_eq!(notifications_sec, Some("notifications"));
995 }
996
997 #[test]
998 fn section_and_owners_of_empty_owners_for_pre_section_rule() {
999 let content = "* @default\n[Billing]\nsrc/billing/ @billing\n";
1000 let co = CodeOwners::parse(content).unwrap();
1001 let (section, owners) = co.section_and_owners_of(Path::new("README.md")).unwrap();
1002 assert_eq!(section, None);
1003 assert!(owners.is_empty());
1004 }
1005
1006 #[test]
1007 fn owner_count_of_counts_all_matched_owners() {
1008 let content = "\
1009 * @default\n\
1010 src/api/ @backend @payments @security\n\
1011 [Frontend] @ui @design\n\
1012 src/ui/\n\
1013 !src/generated/\n\
1014 ";
1015 let co = CodeOwners::parse(content).unwrap();
1016 assert_eq!(co.owner_count_of(Path::new("src/api/payments.ts")), Some(3));
1017 assert_eq!(co.owner_count_of(Path::new("src/ui/button.tsx")), Some(2));
1018 assert_eq!(co.owner_count_of(Path::new("README.md")), Some(1));
1019 assert_eq!(
1020 co.owner_count_of(Path::new("src/generated/types.ts")),
1021 Some(0)
1022 );
1023 assert_eq!(
1024 co.owner_count_of(Path::new("other/generated/types.ts")),
1025 Some(1)
1026 );
1027 }
1028
1029 #[test]
1030 fn non_section_bracket_pattern_parses_as_rule() {
1031 let content = "[abc]def @owner\n";
1034 let co = CodeOwners::parse(content).unwrap();
1035 assert_eq!(co.owners.len(), 1);
1036 assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
1037 }
1038}