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