1use std::path::Path;
34
35use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
36
37#[derive(Debug)]
39pub struct CodeOwners {
40 owners: Vec<String>,
43 owner_counts: Vec<u32>,
46 patterns: Vec<String>,
49 is_negation: Vec<bool>,
52 sections: Vec<Option<String>>,
55 section_owners: Vec<Vec<String>>,
59 has_sections: bool,
61 globs: GlobSet,
64 glob_rules: Vec<usize>,
66}
67
68const PROBE_PATHS: &[&str] = &[
72 "CODEOWNERS",
73 ".github/CODEOWNERS",
74 ".gitlab/CODEOWNERS",
75 "docs/CODEOWNERS",
76];
77
78pub const UNOWNED_LABEL: &str = "(unowned)";
80
81pub const NO_SECTION_LABEL: &str = "(no section)";
86
87impl CodeOwners {
88 pub fn from_file(path: &Path) -> Result<Self, String> {
90 let content = std::fs::read_to_string(path)
91 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
92 Self::parse(&content)
93 }
94
95 pub fn discover(root: &Path) -> Result<Self, String> {
99 for probe in PROBE_PATHS {
100 let path = root.join(probe);
101 if path.is_file() {
102 return Self::from_file(&path);
103 }
104 }
105 Err(format!(
106 "no CODEOWNERS file found (looked for: {}). \
107 Create one of these files or use --group-by directory instead",
108 PROBE_PATHS.join(", ")
109 ))
110 }
111
112 pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
114 if let Some(p) = config_path {
115 let path = root.join(p);
116 Self::from_file(&path)
117 } else {
118 Self::discover(root)
119 }
120 }
121
122 pub fn parse(content: &str) -> Result<Self, String> {
124 let mut parser = CodeOwnersParser::new();
125 for line in content.lines() {
126 parser.parse_line(line)?;
127 }
128 parser.finish()
129 }
130
131 fn last_matching_rule(&self, relative_path: &Path) -> Option<usize> {
136 self.globs
137 .matches(relative_path)
138 .into_iter()
139 .map(|glob_idx| self.glob_rules[glob_idx])
140 .max()
141 }
142
143 pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
149 self.last_matching_rule(relative_path).and_then(|idx| {
150 if self.is_negation[idx] {
151 None
152 } else {
153 Some(self.owners[idx].as_str())
154 }
155 })
156 }
157
158 pub fn owner_count_of(&self, relative_path: &Path) -> Option<u32> {
163 self.last_matching_rule(relative_path).map(|idx| {
164 if self.is_negation[idx] {
165 0
166 } else {
167 self.owner_counts[idx]
168 }
169 })
170 }
171
172 pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
179 self.last_matching_rule(relative_path).and_then(|idx| {
180 if self.is_negation[idx] {
181 None
182 } else {
183 Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
184 }
185 })
186 }
187
188 #[allow(
195 clippy::option_option,
196 reason = "three distinct states: no match, matched pre-section, matched in named section"
197 )]
198 pub fn section_of(&self, relative_path: &Path) -> Option<Option<&str>> {
199 self.last_matching_rule(relative_path).and_then(|idx| {
200 if self.is_negation[idx] {
201 None
202 } else {
203 Some(self.sections[idx].as_deref())
204 }
205 })
206 }
207
208 pub fn section_and_owners_of(&self, relative_path: &Path) -> Option<(Option<&str>, &[String])> {
215 self.last_matching_rule(relative_path).and_then(|idx| {
216 if self.is_negation[idx] {
217 None
218 } else {
219 Some((
220 self.sections[idx].as_deref(),
221 self.section_owners[idx].as_slice(),
222 ))
223 }
224 })
225 }
226
227 pub fn section_owners_and_rule_of(
233 &self,
234 relative_path: &Path,
235 ) -> Option<(Option<&str>, &[String], &str)> {
236 self.last_matching_rule(relative_path).and_then(|idx| {
237 if self.is_negation[idx] {
238 None
239 } else {
240 Some((
241 self.sections[idx].as_deref(),
242 self.section_owners[idx].as_slice(),
243 self.patterns[idx].as_str(),
244 ))
245 }
246 })
247 }
248
249 pub fn has_sections(&self) -> bool {
254 self.has_sections
255 }
256}
257
258struct CodeOwnersParser {
259 builder: GlobSetBuilder,
260 glob_rules: Vec<usize>,
261 owners: Vec<String>,
262 owner_counts: Vec<u32>,
263 patterns: Vec<String>,
264 is_negation: Vec<bool>,
265 sections: Vec<Option<String>>,
266 section_owners: Vec<Vec<String>>,
267 current_section: Option<String>,
268 current_section_owners: Vec<String>,
269 has_sections: bool,
270}
271
272impl CodeOwnersParser {
273 fn new() -> Self {
274 Self {
275 builder: GlobSetBuilder::new(),
276 glob_rules: Vec::new(),
277 owners: Vec::new(),
278 owner_counts: Vec::new(),
279 patterns: Vec::new(),
280 is_negation: Vec::new(),
281 sections: Vec::new(),
282 section_owners: Vec::new(),
283 current_section: None,
284 current_section_owners: Vec::new(),
285 has_sections: false,
286 }
287 }
288
289 fn parse_line(&mut self, line: &str) -> Result<(), String> {
290 match parse_codeowners_line(line, &self.current_section_owners) {
291 ParsedCodeOwnersLine::Skip => Ok(()),
292 ParsedCodeOwnersLine::Section { name, defaults } => {
293 self.current_section = Some(name);
294 self.current_section_owners = defaults;
295 self.has_sections = true;
296 Ok(())
297 }
298 ParsedCodeOwnersLine::Rule {
299 pattern,
300 owner,
301 owner_count,
302 negate,
303 } => self.add_rule(pattern, owner, owner_count, negate),
304 }
305 }
306
307 fn add_rule(
308 &mut self,
309 pattern: String,
310 owner: String,
311 owner_count: u32,
312 negate: bool,
313 ) -> Result<(), String> {
314 let rule_idx = self.owners.len();
315 for glob_pattern in translate_pattern(&pattern) {
316 let glob = GlobBuilder::new(&glob_pattern)
317 .literal_separator(true)
318 .build()
319 .map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
320 self.builder.add(glob);
321 self.glob_rules.push(rule_idx);
322 }
323
324 self.owners.push(owner);
325 self.owner_counts.push(owner_count);
326 self.patterns.push(if negate {
327 format!("!{pattern}")
328 } else {
329 pattern
330 });
331 self.is_negation.push(negate);
332 self.sections.push(self.current_section.clone());
333 self.section_owners
334 .push(self.current_section_owners.clone());
335 Ok(())
336 }
337
338 fn finish(self) -> Result<CodeOwners, String> {
339 let globs = self
340 .builder
341 .build()
342 .map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
343
344 Ok(CodeOwners {
345 owners: self.owners,
346 owner_counts: self.owner_counts,
347 patterns: self.patterns,
348 is_negation: self.is_negation,
349 sections: self.sections,
350 section_owners: self.section_owners,
351 has_sections: self.has_sections,
352 globs,
353 glob_rules: self.glob_rules,
354 })
355 }
356}
357
358enum ParsedCodeOwnersLine {
359 Skip,
360 Section {
361 name: String,
362 defaults: Vec<String>,
363 },
364 Rule {
365 pattern: String,
366 owner: String,
367 owner_count: u32,
368 negate: bool,
369 },
370}
371
372fn parse_codeowners_line(line: &str, current_section_owners: &[String]) -> ParsedCodeOwnersLine {
373 let line = line.trim();
374 if line.is_empty() || line.starts_with('#') {
375 return ParsedCodeOwnersLine::Skip;
376 }
377
378 if let Some((name, defaults)) = parse_section_header(line) {
379 return ParsedCodeOwnersLine::Section { name, defaults };
380 }
381
382 let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
383 (true, after.trim_start())
384 } else {
385 (false, line)
386 };
387
388 let mut parts = rest.split_whitespace();
389 let Some(pattern) = parts.next() else {
390 return ParsedCodeOwnersLine::Skip;
391 };
392 let inline_owners = parts.collect::<Vec<_>>();
393
394 let (owner, owner_count) = if negate {
395 (String::new(), 0)
396 } else if let Some(owner) = inline_owners.first() {
397 (
398 (*owner).to_string(),
399 u32::try_from(inline_owners.len()).unwrap_or(u32::MAX),
400 )
401 } else if let Some(owner) = current_section_owners.first() {
402 (
403 owner.clone(),
404 u32::try_from(current_section_owners.len()).unwrap_or(u32::MAX),
405 )
406 } else {
407 return ParsedCodeOwnersLine::Skip;
408 };
409
410 ParsedCodeOwnersLine::Rule {
411 pattern: pattern.to_string(),
412 owner,
413 owner_count,
414 negate,
415 }
416}
417
418fn parse_section_header(line: &str) -> Option<(String, Vec<String>)> {
434 let rest = line.strip_prefix('^').unwrap_or(line);
435 let rest = rest.strip_prefix('[')?;
436 let close = rest.find(']')?;
437 let name = &rest[..close];
438 if name.is_empty() {
439 return None;
440 }
441 let mut after = &rest[close + 1..];
442
443 if let Some(inner) = after.strip_prefix('[') {
444 let n_close = inner.find(']')?;
445 let count = &inner[..n_close];
446 if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
447 return None;
448 }
449 after = &inner[n_close + 1..];
450 }
451
452 if !after.is_empty() && !after.starts_with(char::is_whitespace) {
453 return None;
454 }
455
456 Some((
457 name.to_string(),
458 after.split_whitespace().map(String::from).collect(),
459 ))
460}
461
462fn translate_pattern(pattern: &str) -> Vec<String> {
476 let (anchored, rest) = match pattern.strip_prefix('/') {
477 Some(p) => (true, p),
478 None => (false, pattern),
479 };
480 let (directory_only, body) = match rest.strip_suffix('/') {
481 Some(p) => (true, p),
482 None => (false, rest),
483 };
484 if body.is_empty() {
485 return vec!["**".to_string()];
486 }
487
488 let base = if !anchored && !body.contains('/') {
489 format!("**/{body}")
490 } else {
491 body.to_string()
492 };
493 let contents = format!("{base}/**");
494 if directory_only {
495 return vec![contents];
496 }
497
498 let last_segment = base.rsplit('/').next().unwrap_or(&base);
499 if matches!(last_segment, "*" | "**") {
500 vec![base]
501 } else {
502 vec![base, contents]
503 }
504}
505
506pub fn directory_group(relative_path: &Path) -> &str {
511 let s = relative_path.to_str().unwrap_or("");
512 let s = if s.contains('\\') {
513 return s.split(['/', '\\']).next().unwrap_or(s);
514 } else {
515 s
516 };
517
518 match s.find('/') {
519 Some(pos) => &s[..pos],
520 None => s, }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn translate_bare_glob() {
530 assert_eq!(translate_pattern("*.js"), ["**/*.js", "**/*.js/**"]);
531 }
532
533 #[test]
534 fn translate_rooted_pattern() {
535 assert_eq!(translate_pattern("/docs/*"), ["docs/*"]);
536 }
537
538 #[test]
539 fn translate_directory_pattern() {
540 assert_eq!(translate_pattern("docs/"), ["**/docs/**"]);
541 }
542
543 #[test]
544 fn translate_rooted_directory() {
545 assert_eq!(translate_pattern("/src/app/"), ["src/app/**"]);
546 }
547
548 #[test]
549 fn translate_rooted_path_without_trailing_slash() {
550 assert_eq!(translate_pattern("/docs"), ["docs", "docs/**"]);
551 }
552
553 #[test]
554 fn translate_path_with_slash() {
555 assert_eq!(
556 translate_pattern("src/utils/*.ts"),
557 ["src/utils/*.ts", "src/utils/*.ts/**"]
558 );
559 }
560
561 #[test]
562 fn translate_double_star() {
563 assert_eq!(
564 translate_pattern("**/test_*.py"),
565 ["**/test_*.py", "**/test_*.py/**"]
566 );
567 assert_eq!(translate_pattern("/build/**"), ["build/**"]);
568 }
569
570 #[test]
571 fn translate_single_file() {
572 assert_eq!(
573 translate_pattern("Makefile"),
574 ["**/Makefile", "**/Makefile/**"]
575 );
576 }
577
578 #[test]
579 fn translate_catch_all() {
580 assert_eq!(translate_pattern("*"), ["**/*"]);
581 }
582
583 #[test]
584 fn parse_simple_codeowners() {
585 let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
586 let co = CodeOwners::parse(content).unwrap();
587 assert_eq!(co.owners.len(), 3);
588 }
589
590 #[test]
591 fn parse_skips_comments_and_blanks() {
592 let content = "# Comment\n\n* @owner\n # Indented comment\n";
593 let co = CodeOwners::parse(content).unwrap();
594 assert_eq!(co.owners.len(), 1);
595 }
596
597 #[test]
598 fn parse_multi_owner_takes_first() {
599 let content = "*.ts @team-a @team-b @team-c\n";
600 let co = CodeOwners::parse(content).unwrap();
601 assert_eq!(co.owners[0], "@team-a");
602 }
603
604 #[test]
605 fn parse_skips_pattern_without_owner() {
606 let content = "*.ts\n*.js @owner\n";
607 let co = CodeOwners::parse(content).unwrap();
608 assert_eq!(co.owners.len(), 1);
609 assert_eq!(co.owners[0], "@owner");
610 }
611
612 #[test]
613 fn parse_empty_content() {
614 let co = CodeOwners::parse("").unwrap();
615 assert_eq!(co.owner_of(Path::new("anything.ts")), None);
616 }
617
618 #[test]
619 fn owner_of_last_match_wins() {
620 let content = "* @default\n/src/ @frontend\n";
621 let co = CodeOwners::parse(content).unwrap();
622 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
623 }
624
625 #[test]
626 fn owner_of_falls_back_to_catch_all() {
627 let content = "* @default\n/src/ @frontend\n";
628 let co = CodeOwners::parse(content).unwrap();
629 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
630 }
631
632 #[test]
633 fn owner_of_no_match_returns_none() {
634 let content = "/src/ @frontend\n";
635 let co = CodeOwners::parse(content).unwrap();
636 assert_eq!(co.owner_of(Path::new("README.md")), None);
637 }
638
639 #[test]
640 fn owner_of_extension_glob() {
641 let content = "*.rs @rust-team\n*.ts @ts-team\n";
642 let co = CodeOwners::parse(content).unwrap();
643 assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
644 assert_eq!(
645 co.owner_of(Path::new("packages/ui/Button.ts")),
646 Some("@ts-team")
647 );
648 }
649
650 #[test]
651 fn owner_of_nested_directory() {
652 let content = "* @default\n/packages/auth/ @auth-team\n";
653 let co = CodeOwners::parse(content).unwrap();
654 assert_eq!(
655 co.owner_of(Path::new("packages/auth/src/login.ts")),
656 Some("@auth-team")
657 );
658 assert_eq!(
659 co.owner_of(Path::new("packages/ui/Button.ts")),
660 Some("@default")
661 );
662 }
663
664 #[test]
665 fn owner_of_specific_overrides_general() {
666 let content = "\
667 * @default\n\
668 /src/ @frontend\n\
669 /src/api/ @backend\n\
670 ";
671 let co = CodeOwners::parse(content).unwrap();
672 assert_eq!(
673 co.owner_of(Path::new("src/api/routes.ts")),
674 Some("@backend")
675 );
676 assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
677 }
678
679 #[test]
680 fn owner_and_rule_of_returns_owner_and_pattern() {
681 let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
682 let co = CodeOwners::parse(content).unwrap();
683 assert_eq!(
684 co.owner_and_rule_of(Path::new("src/app.ts")),
685 Some(("@frontend", "/src/"))
686 );
687 assert_eq!(
688 co.owner_and_rule_of(Path::new("src/lib.rs")),
689 Some(("@rust-team", "*.rs"))
690 );
691 assert_eq!(
692 co.owner_and_rule_of(Path::new("README.md")),
693 Some(("@default", "*"))
694 );
695 }
696
697 #[test]
698 fn owner_and_rule_of_no_match() {
699 let content = "/src/ @frontend\n";
700 let co = CodeOwners::parse(content).unwrap();
701 assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
702 }
703
704 #[test]
705 fn directory_group_simple() {
706 assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
707 }
708
709 #[test]
710 fn directory_group_root_file() {
711 assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
712 }
713
714 #[test]
715 fn directory_group_monorepo() {
716 assert_eq!(
717 directory_group(Path::new("packages/auth/src/login.ts")),
718 "packages"
719 );
720 }
721
722 #[test]
723 fn discover_nonexistent_root() {
724 let result = CodeOwners::discover(Path::new("/nonexistent/path"));
725 assert!(result.is_err());
726 let err = result.unwrap_err();
727 assert!(err.contains("no CODEOWNERS file found"));
728 assert!(err.contains("--group-by directory"));
729 }
730
731 #[test]
732 fn from_file_nonexistent() {
733 let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
734 assert!(result.is_err());
735 }
736
737 #[test]
738 fn from_file_real_codeowners() {
739 let root = std::path::PathBuf::from(
740 std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
741 )
742 .parent()
743 .unwrap()
744 .parent()
745 .unwrap()
746 .to_path_buf();
747 let path = root.join(".github/CODEOWNERS");
748 if path.exists() {
749 let co = CodeOwners::from_file(&path).unwrap();
750 assert_eq!(
751 co.owner_of(Path::new(".claude/rules/detection.md")),
752 Some("@bartwaardenburg")
753 );
754 }
755 }
756
757 #[test]
758 fn email_owner() {
759 let content = "*.js user@example.com\n";
760 let co = CodeOwners::parse(content).unwrap();
761 assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
762 }
763
764 #[test]
765 fn team_owner() {
766 let content = "*.ts @org/frontend-team\n";
767 let co = CodeOwners::parse(content).unwrap();
768 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
769 }
770
771 #[test]
772 fn gitlab_section_header_skipped_as_rule() {
773 let content = "[Section Name]\n*.ts @owner\n";
774 let co = CodeOwners::parse(content).unwrap();
775 assert_eq!(co.owners.len(), 1);
776 assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
777 }
778
779 #[test]
780 fn gitlab_optional_section_header_skipped() {
781 let content = "^[Optional Section]\n*.ts @owner\n";
782 let co = CodeOwners::parse(content).unwrap();
783 assert_eq!(co.owners.len(), 1);
784 }
785
786 #[test]
787 fn gitlab_section_header_with_approval_count_skipped() {
788 let content = "[Section Name][2]\n*.ts @owner\n";
789 let co = CodeOwners::parse(content).unwrap();
790 assert_eq!(co.owners.len(), 1);
791 }
792
793 #[test]
794 fn gitlab_optional_section_with_approval_count_skipped() {
795 let content = "^[Section Name][3] @fallback-team\nfoo/\n";
796 let co = CodeOwners::parse(content).unwrap();
797 assert_eq!(co.owners.len(), 1);
798 assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
799 }
800
801 #[test]
802 fn gitlab_section_default_owners_inherited() {
803 let content = "\
804 [Utilities] @utils-team\n\
805 src/utils/\n\
806 [UI Components] @ui-team\n\
807 src/components/\n\
808 ";
809 let co = CodeOwners::parse(content).unwrap();
810 assert_eq!(co.owners.len(), 2);
811 assert_eq!(
812 co.owner_of(Path::new("src/utils/greet.ts")),
813 Some("@utils-team")
814 );
815 assert_eq!(
816 co.owner_of(Path::new("src/components/button.ts")),
817 Some("@ui-team")
818 );
819 }
820
821 #[test]
822 fn gitlab_inline_owner_overrides_section_default() {
823 let content = "\
824 [Section] @section-owner\n\
825 src/generic/\n\
826 src/special/ @special-owner\n\
827 ";
828 let co = CodeOwners::parse(content).unwrap();
829 assert_eq!(
830 co.owner_of(Path::new("src/generic/a.ts")),
831 Some("@section-owner")
832 );
833 assert_eq!(
834 co.owner_of(Path::new("src/special/a.ts")),
835 Some("@special-owner")
836 );
837 }
838
839 #[test]
840 fn gitlab_section_defaults_reset_between_sections() {
841 let content = "\
842 [Section1] @team-a\n\
843 foo/\n\
844 [Section2]\n\
845 bar/\n\
846 ";
847 let co = CodeOwners::parse(content).unwrap();
848 assert_eq!(co.owners.len(), 1);
849 assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
850 assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
851 }
852
853 #[test]
854 fn gitlab_section_header_multiple_default_owners_uses_first() {
855 let content = "[Section] @first @second\nfoo/\n";
856 let co = CodeOwners::parse(content).unwrap();
857 assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
858 }
859
860 #[test]
861 fn gitlab_rules_before_first_section_retain_inline_owners() {
862 let content = "\
863 * @default-owner\n\
864 [Utilities] @utils-team\n\
865 src/utils/\n\
866 ";
867 let co = CodeOwners::parse(content).unwrap();
868 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
869 assert_eq!(
870 co.owner_of(Path::new("src/utils/greet.ts")),
871 Some("@utils-team")
872 );
873 }
874
875 #[test]
876 fn gitlab_issue_127_reproduction() {
877 let content = "\
878# Default section (no header, rules before first section)
879* @default-owner
880
881[Utilities] @utils-team
882src/utils/
883
884[UI Components] @ui-team
885src/components/
886";
887 let co = CodeOwners::parse(content).unwrap();
888 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
889 assert_eq!(
890 co.owner_of(Path::new("src/utils/greet.ts")),
891 Some("@utils-team")
892 );
893 assert_eq!(
894 co.owner_of(Path::new("src/components/button.ts")),
895 Some("@ui-team")
896 );
897 }
898
899 #[test]
900 fn gitlab_negation_last_match_clears_ownership() {
901 let content = "\
902 * @default\n\
903 !src/generated/\n\
904 ";
905 let co = CodeOwners::parse(content).unwrap();
906 assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
907 assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
908 }
909
910 #[test]
911 fn gitlab_negation_only_clears_when_last_match() {
912 let content = "\
913 * @default\n\
914 !src/\n\
915 /src/special/ @special\n\
916 ";
917 let co = CodeOwners::parse(content).unwrap();
918 assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
919 assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
920 }
921
922 #[test]
923 fn gitlab_negation_owner_and_rule_returns_none() {
924 let content = "* @default\n!src/vendor/\n";
925 let co = CodeOwners::parse(content).unwrap();
926 assert_eq!(
927 co.owner_and_rule_of(Path::new("README.md")),
928 Some(("@default", "*"))
929 );
930 assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
931 }
932
933 #[test]
934 fn parse_section_header_variants() {
935 assert_eq!(
936 parse_section_header("[Section]"),
937 Some(("Section".into(), vec![]))
938 );
939 assert_eq!(
940 parse_section_header("^[Section]"),
941 Some(("Section".into(), vec![]))
942 );
943 assert_eq!(
944 parse_section_header("[Section][2]"),
945 Some(("Section".into(), vec![]))
946 );
947 assert_eq!(
948 parse_section_header("^[Section][2]"),
949 Some(("Section".into(), vec![]))
950 );
951 assert_eq!(
952 parse_section_header("[Section] @a @b"),
953 Some(("Section".into(), vec!["@a".into(), "@b".into()]))
954 );
955 assert_eq!(
956 parse_section_header("[Section][2] @a"),
957 Some(("Section".into(), vec!["@a".into()]))
958 );
959 }
960
961 #[test]
962 fn parse_section_header_rejects_malformed() {
963 assert_eq!(parse_section_header("[unclosed"), None);
964 assert_eq!(parse_section_header("[]"), None);
965 assert_eq!(parse_section_header("[abc]def @owner"), None);
966 assert_eq!(parse_section_header("[Section][] @owner"), None);
967 assert_eq!(parse_section_header("[Section][abc] @owner"), None);
968 }
969
970 #[test]
971 fn has_sections_false_without_headers() {
972 let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
973 assert!(!co.has_sections());
974 }
975
976 #[test]
977 fn has_sections_true_with_headers() {
978 let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
979 assert!(co.has_sections());
980 }
981
982 #[test]
983 fn section_of_returns_named_section() {
984 let content = "\
985 [Billing] @billing-team\n\
986 src/billing/\n\
987 [Search] @search-team\n\
988 src/search/\n\
989 ";
990 let co = CodeOwners::parse(content).unwrap();
991 assert_eq!(
992 co.section_of(Path::new("src/billing/invoice.ts")),
993 Some(Some("Billing"))
994 );
995 assert_eq!(
996 co.section_of(Path::new("src/search/indexer.ts")),
997 Some(Some("Search"))
998 );
999 }
1000
1001 #[test]
1002 fn section_of_returns_some_none_for_pre_section_rule() {
1003 let content = "\
1004 * @default\n\
1005 [Billing] @billing-team\n\
1006 src/billing/\n\
1007 ";
1008 let co = CodeOwners::parse(content).unwrap();
1009 assert_eq!(co.section_of(Path::new("README.md")), Some(None));
1010 assert_eq!(
1011 co.section_of(Path::new("src/billing/invoice.ts")),
1012 Some(Some("Billing"))
1013 );
1014 }
1015
1016 #[test]
1017 fn section_of_returns_none_for_unmatched_path() {
1018 let content = "[Billing] @billing-team\nsrc/billing/\n";
1019 let co = CodeOwners::parse(content).unwrap();
1020 assert_eq!(co.section_of(Path::new("src/other/x.ts")), None);
1021 }
1022
1023 #[test]
1024 fn section_of_returns_none_for_negation_last_match() {
1025 let content = "\
1026 [Billing] @billing-team\n\
1027 src/billing/\n\
1028 !src/billing/vendor/\n\
1029 ";
1030 let co = CodeOwners::parse(content).unwrap();
1031 assert_eq!(
1032 co.section_of(Path::new("src/billing/invoice.ts")),
1033 Some(Some("Billing"))
1034 );
1035 assert_eq!(co.section_of(Path::new("src/billing/vendor/lib.js")), None);
1036 }
1037
1038 #[test]
1039 fn section_and_owners_of_returns_section_defaults() {
1040 let content = "\
1041 [Billing] @core-reviewers @alice\n\
1042 src/billing/\n\
1043 ";
1044 let co = CodeOwners::parse(content).unwrap();
1045 let (section, owners) = co
1046 .section_and_owners_of(Path::new("src/billing/invoice.ts"))
1047 .unwrap();
1048 assert_eq!(section, Some("Billing"));
1049 assert_eq!(
1050 owners,
1051 &["@core-reviewers".to_string(), "@alice".to_string()]
1052 );
1053 }
1054
1055 #[test]
1056 fn section_and_owners_of_same_owners_distinct_sections() {
1057 let content = "\
1058 [billing] @core-reviewers @alice @bob\n\
1059 src/billing/\n\
1060 [notifications] @core-reviewers @alice @bob\n\
1061 src/notifications/\n\
1062 ";
1063 let co = CodeOwners::parse(content).unwrap();
1064 let (billing_sec, _) = co
1065 .section_and_owners_of(Path::new("src/billing/invoice.ts"))
1066 .unwrap();
1067 let (notifications_sec, _) = co
1068 .section_and_owners_of(Path::new("src/notifications/email.ts"))
1069 .unwrap();
1070 assert_eq!(billing_sec, Some("billing"));
1071 assert_eq!(notifications_sec, Some("notifications"));
1072 }
1073
1074 #[test]
1075 fn section_and_owners_of_empty_owners_for_pre_section_rule() {
1076 let content = "* @default\n[Billing]\nsrc/billing/ @billing\n";
1077 let co = CodeOwners::parse(content).unwrap();
1078 let (section, owners) = co.section_and_owners_of(Path::new("README.md")).unwrap();
1079 assert_eq!(section, None);
1080 assert!(owners.is_empty());
1081 }
1082
1083 #[test]
1084 fn owner_count_of_counts_all_matched_owners() {
1085 let content = "\
1086 * @default\n\
1087 src/api/ @backend @payments @security\n\
1088 [Frontend] @ui @design\n\
1089 src/ui/\n\
1090 !src/generated/\n\
1091 ";
1092 let co = CodeOwners::parse(content).unwrap();
1093 assert_eq!(co.owner_count_of(Path::new("src/api/payments.ts")), Some(3));
1094 assert_eq!(co.owner_count_of(Path::new("src/ui/button.tsx")), Some(2));
1095 assert_eq!(co.owner_count_of(Path::new("README.md")), Some(1));
1096 assert_eq!(
1097 co.owner_count_of(Path::new("src/generated/types.ts")),
1098 Some(0)
1099 );
1100 assert_eq!(
1101 co.owner_count_of(Path::new("other/generated/types.ts")),
1102 Some(1)
1103 );
1104 }
1105
1106 #[test]
1107 fn non_section_bracket_pattern_parses_as_rule() {
1108 let content = "[abc]def @owner\n";
1109 let co = CodeOwners::parse(content).unwrap();
1110 assert_eq!(co.owners.len(), 1);
1111 assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
1112 }
1113
1114 #[test]
1115 fn pattern_without_trailing_slash_owns_directory_contents() {
1116 let content = "\
1117 src/platform/packages/shared/kbn-try-in-console @elastic/search\n\
1118 /docs @docs-team\n\
1119 ";
1120 let co = CodeOwners::parse(content).unwrap();
1121 assert_eq!(
1122 co.owner_of(Path::new(
1123 "src/platform/packages/shared/kbn-try-in-console/index.ts"
1124 )),
1125 Some("@elastic/search")
1126 );
1127 assert_eq!(
1128 co.owner_of(Path::new(
1129 "src/platform/packages/shared/kbn-try-in-console/src/components/a.tsx"
1130 )),
1131 Some("@elastic/search")
1132 );
1133 assert_eq!(
1134 co.owner_of(Path::new("docs/guide/intro.md")),
1135 Some("@docs-team")
1136 );
1137 assert_eq!(
1138 co.owner_and_rule_of(Path::new("docs/guide/intro.md")),
1139 Some(("@docs-team", "/docs"))
1140 );
1141 assert_eq!(co.owner_count_of(Path::new("docs/a.md")), Some(1));
1142 }
1143
1144 #[test]
1145 fn pattern_without_trailing_slash_still_matches_a_file() {
1146 let co = CodeOwners::parse("/package.json @root\nMakefile @build\n").unwrap();
1147 assert_eq!(co.owner_of(Path::new("package.json")), Some("@root"));
1148 assert_eq!(co.owner_of(Path::new("tools/Makefile")), Some("@build"));
1149 }
1150
1151 #[test]
1152 fn pattern_without_trailing_slash_does_not_match_sibling_prefix() {
1153 let co = CodeOwners::parse("/docs @docs-team\n").unwrap();
1154 assert_eq!(co.owner_of(Path::new("docs-old/a.md")), None);
1155 assert_eq!(co.owner_of(Path::new("docs.md")), None);
1156 }
1157
1158 #[test]
1159 fn directory_pattern_keeps_last_match_wins_order() {
1160 let content = "\
1161 /src @general\n\
1162 /src/lib @lib\n\
1163 /src/lib/legacy.ts @legacy\n\
1164 ";
1165 let co = CodeOwners::parse(content).unwrap();
1166 assert_eq!(co.owner_of(Path::new("src/main.ts")), Some("@general"));
1167 assert_eq!(co.owner_of(Path::new("src/lib/a/b.ts")), Some("@lib"));
1168 assert_eq!(co.owner_of(Path::new("src/lib/legacy.ts")), Some("@legacy"));
1169
1170 let reversed = "\
1171 /src/lib @lib\n\
1172 /src @general\n\
1173 ";
1174 let co = CodeOwners::parse(reversed).unwrap();
1175 assert_eq!(co.owner_of(Path::new("src/lib/a/b.ts")), Some("@general"));
1176 }
1177
1178 #[test]
1179 fn trailing_star_matches_direct_children_only() {
1180 let co = CodeOwners::parse("* @default\ndocs/* @docs-team\n").unwrap();
1181 assert_eq!(
1182 co.owner_of(Path::new("docs/getting-started.md")),
1183 Some("@docs-team")
1184 );
1185 assert_eq!(
1186 co.owner_of(Path::new("docs/build-app/troubleshooting.md")),
1187 Some("@default")
1188 );
1189 }
1190
1191 #[test]
1192 fn root_star_owns_root_level_files_only() {
1193 let co = CodeOwners::parse("/* @root\n").unwrap();
1196 assert_eq!(co.owner_of(Path::new("package.json")), Some("@root"));
1197 assert_eq!(co.owner_of(Path::new("src/index.ts")), None);
1198
1199 let co = CodeOwners::parse("* @all\n").unwrap();
1200 assert_eq!(co.owner_of(Path::new("src/deep/index.ts")), Some("@all"));
1201 }
1202
1203 #[test]
1204 fn single_star_does_not_cross_directories() {
1205 let co = CodeOwners::parse("* @default\n/src/*.ts @ts\n").unwrap();
1206 assert_eq!(co.owner_of(Path::new("src/index.ts")), Some("@ts"));
1207 assert_eq!(co.owner_of(Path::new("src/nested/a.ts")), Some("@default"));
1208 }
1209
1210 #[test]
1211 fn wildcard_name_owns_matching_directory_contents() {
1212 let co = CodeOwners::parse("/fixtures/date_* @data\n").unwrap();
1213 assert_eq!(
1214 co.owner_of(Path::new("fixtures/date_nanos/data.json")),
1215 Some("@data")
1216 );
1217 }
1218
1219 #[test]
1220 fn trailing_double_star_keeps_its_meaning() {
1221 let co = CodeOwners::parse("/build/logs/** @ops\n").unwrap();
1222 assert_eq!(co.owner_of(Path::new("build/logs/a/b.log")), Some("@ops"));
1223 assert_eq!(co.owner_of(Path::new("build/other.log")), None);
1224 }
1225
1226 #[test]
1227 fn unanchored_directory_pattern_matches_at_any_depth() {
1228 let co = CodeOwners::parse("apps/ @apps-team\n").unwrap();
1229 assert_eq!(co.owner_of(Path::new("apps/web/a.ts")), Some("@apps-team"));
1230 assert_eq!(
1231 co.owner_of(Path::new("packages/x/apps/web/a.ts")),
1232 Some("@apps-team")
1233 );
1234 }
1235
1236 #[test]
1237 fn negation_without_trailing_slash_clears_directory_contents() {
1238 let content = "\
1239 * @default\n\
1240 !/src/generated\n\
1241 ";
1242 let co = CodeOwners::parse(content).unwrap();
1243 assert_eq!(co.owner_of(Path::new("src/generated/types.ts")), None);
1244 assert_eq!(
1245 co.owner_count_of(Path::new("src/generated/a/b.ts")),
1246 Some(0)
1247 );
1248 assert_eq!(co.owner_of(Path::new("src/main.ts")), Some("@default"));
1249 }
1250
1251 #[test]
1252 fn gitlab_section_pattern_without_trailing_slash_owns_directory_contents() {
1253 let content = "\
1254 [Frontend] @ui\n\
1255 /src/ui\n\
1256 [Backend] @api\n\
1257 /src/api @api-lead\n\
1258 ";
1259 let co = CodeOwners::parse(content).unwrap();
1260 assert_eq!(
1261 co.section_owners_and_rule_of(Path::new("src/ui/button/a.tsx")),
1262 Some((Some("Frontend"), ["@ui".to_string()].as_slice(), "/src/ui"))
1263 );
1264 assert_eq!(
1265 co.section_of(Path::new("src/api/users.ts")),
1266 Some(Some("Backend"))
1267 );
1268 assert_eq!(
1269 co.owner_of(Path::new("src/api/users.ts")),
1270 Some("@api-lead")
1271 );
1272 }
1273}