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