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