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