use std::path::Path;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
#[derive(Debug)]
pub struct CodeOwners {
owners: Vec<String>,
owner_counts: Vec<u32>,
patterns: Vec<String>,
is_negation: Vec<bool>,
sections: Vec<Option<String>>,
section_owners: Vec<Vec<String>>,
has_sections: bool,
globs: GlobSet,
glob_rules: Vec<usize>,
}
const PROBE_PATHS: &[&str] = &[
"CODEOWNERS",
".github/CODEOWNERS",
".gitlab/CODEOWNERS",
"docs/CODEOWNERS",
];
pub const UNOWNED_LABEL: &str = "(unowned)";
pub const NO_SECTION_LABEL: &str = "(no section)";
impl CodeOwners {
pub fn from_file(path: &Path) -> Result<Self, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?;
Self::parse(&content)
}
pub fn discover(root: &Path) -> Result<Self, String> {
for probe in PROBE_PATHS {
let path = root.join(probe);
if path.is_file() {
return Self::from_file(&path);
}
}
Err(format!(
"no CODEOWNERS file found (looked for: {}). \
Create one of these files or use --group-by directory instead",
PROBE_PATHS.join(", ")
))
}
pub fn load(root: &Path, config_path: Option<&str>) -> Result<Self, String> {
if let Some(p) = config_path {
let path = root.join(p);
Self::from_file(&path)
} else {
Self::discover(root)
}
}
pub fn parse(content: &str) -> Result<Self, String> {
let mut parser = CodeOwnersParser::new();
for line in content.lines() {
parser.parse_line(line)?;
}
parser.finish()
}
fn last_matching_rule(&self, relative_path: &Path) -> Option<usize> {
self.globs
.matches(relative_path)
.into_iter()
.map(|glob_idx| self.glob_rules[glob_idx])
.max()
}
pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
self.last_matching_rule(relative_path).and_then(|idx| {
if self.is_negation[idx] {
None
} else {
Some(self.owners[idx].as_str())
}
})
}
pub fn owner_count_of(&self, relative_path: &Path) -> Option<u32> {
self.last_matching_rule(relative_path).map(|idx| {
if self.is_negation[idx] {
0
} else {
self.owner_counts[idx]
}
})
}
pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
self.last_matching_rule(relative_path).and_then(|idx| {
if self.is_negation[idx] {
None
} else {
Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
}
})
}
#[allow(
clippy::option_option,
reason = "three distinct states: no match, matched pre-section, matched in named section"
)]
pub fn section_of(&self, relative_path: &Path) -> Option<Option<&str>> {
self.last_matching_rule(relative_path).and_then(|idx| {
if self.is_negation[idx] {
None
} else {
Some(self.sections[idx].as_deref())
}
})
}
pub fn section_and_owners_of(&self, relative_path: &Path) -> Option<(Option<&str>, &[String])> {
self.last_matching_rule(relative_path).and_then(|idx| {
if self.is_negation[idx] {
None
} else {
Some((
self.sections[idx].as_deref(),
self.section_owners[idx].as_slice(),
))
}
})
}
pub fn section_owners_and_rule_of(
&self,
relative_path: &Path,
) -> Option<(Option<&str>, &[String], &str)> {
self.last_matching_rule(relative_path).and_then(|idx| {
if self.is_negation[idx] {
None
} else {
Some((
self.sections[idx].as_deref(),
self.section_owners[idx].as_slice(),
self.patterns[idx].as_str(),
))
}
})
}
pub fn has_sections(&self) -> bool {
self.has_sections
}
}
struct CodeOwnersParser {
builder: GlobSetBuilder,
glob_rules: Vec<usize>,
owners: Vec<String>,
owner_counts: Vec<u32>,
patterns: Vec<String>,
is_negation: Vec<bool>,
sections: Vec<Option<String>>,
section_owners: Vec<Vec<String>>,
current_section: Option<String>,
current_section_owners: Vec<String>,
has_sections: bool,
}
impl CodeOwnersParser {
fn new() -> Self {
Self {
builder: GlobSetBuilder::new(),
glob_rules: Vec::new(),
owners: Vec::new(),
owner_counts: Vec::new(),
patterns: Vec::new(),
is_negation: Vec::new(),
sections: Vec::new(),
section_owners: Vec::new(),
current_section: None,
current_section_owners: Vec::new(),
has_sections: false,
}
}
fn parse_line(&mut self, line: &str) -> Result<(), String> {
match parse_codeowners_line(line, &self.current_section_owners) {
ParsedCodeOwnersLine::Skip => Ok(()),
ParsedCodeOwnersLine::Section { name, defaults } => {
self.current_section = Some(name);
self.current_section_owners = defaults;
self.has_sections = true;
Ok(())
}
ParsedCodeOwnersLine::Rule {
pattern,
owner,
owner_count,
negate,
} => self.add_rule(pattern, owner, owner_count, negate),
}
}
fn add_rule(
&mut self,
pattern: String,
owner: String,
owner_count: u32,
negate: bool,
) -> Result<(), String> {
let rule_idx = self.owners.len();
for glob_pattern in translate_pattern(&pattern) {
let glob = GlobBuilder::new(&glob_pattern)
.literal_separator(true)
.build()
.map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
self.builder.add(glob);
self.glob_rules.push(rule_idx);
}
self.owners.push(owner);
self.owner_counts.push(owner_count);
self.patterns.push(if negate {
format!("!{pattern}")
} else {
pattern
});
self.is_negation.push(negate);
self.sections.push(self.current_section.clone());
self.section_owners
.push(self.current_section_owners.clone());
Ok(())
}
fn finish(self) -> Result<CodeOwners, String> {
let globs = self
.builder
.build()
.map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
Ok(CodeOwners {
owners: self.owners,
owner_counts: self.owner_counts,
patterns: self.patterns,
is_negation: self.is_negation,
sections: self.sections,
section_owners: self.section_owners,
has_sections: self.has_sections,
globs,
glob_rules: self.glob_rules,
})
}
}
enum ParsedCodeOwnersLine {
Skip,
Section {
name: String,
defaults: Vec<String>,
},
Rule {
pattern: String,
owner: String,
owner_count: u32,
negate: bool,
},
}
fn parse_codeowners_line(line: &str, current_section_owners: &[String]) -> ParsedCodeOwnersLine {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return ParsedCodeOwnersLine::Skip;
}
if let Some((name, defaults)) = parse_section_header(line) {
return ParsedCodeOwnersLine::Section { name, defaults };
}
let (negate, rest) = if let Some(after) = line.strip_prefix('!') {
(true, after.trim_start())
} else {
(false, line)
};
let mut parts = rest.split_whitespace();
let Some(pattern) = parts.next() else {
return ParsedCodeOwnersLine::Skip;
};
let inline_owners = parts.collect::<Vec<_>>();
let (owner, owner_count) = if negate {
(String::new(), 0)
} else if let Some(owner) = inline_owners.first() {
(
(*owner).to_string(),
u32::try_from(inline_owners.len()).unwrap_or(u32::MAX),
)
} else if let Some(owner) = current_section_owners.first() {
(
owner.clone(),
u32::try_from(current_section_owners.len()).unwrap_or(u32::MAX),
)
} else {
return ParsedCodeOwnersLine::Skip;
};
ParsedCodeOwnersLine::Rule {
pattern: pattern.to_string(),
owner,
owner_count,
negate,
}
}
fn parse_section_header(line: &str) -> Option<(String, Vec<String>)> {
let rest = line.strip_prefix('^').unwrap_or(line);
let rest = rest.strip_prefix('[')?;
let close = rest.find(']')?;
let name = &rest[..close];
if name.is_empty() {
return None;
}
let mut after = &rest[close + 1..];
if let Some(inner) = after.strip_prefix('[') {
let n_close = inner.find(']')?;
let count = &inner[..n_close];
if count.is_empty() || !count.chars().all(|c| c.is_ascii_digit()) {
return None;
}
after = &inner[n_close + 1..];
}
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
return None;
}
Some((
name.to_string(),
after.split_whitespace().map(String::from).collect(),
))
}
fn translate_pattern(pattern: &str) -> Vec<String> {
let (anchored, rest) = match pattern.strip_prefix('/') {
Some(p) => (true, p),
None => (false, pattern),
};
let (directory_only, body) = match rest.strip_suffix('/') {
Some(p) => (true, p),
None => (false, rest),
};
if body.is_empty() {
return vec!["**".to_string()];
}
let base = if !anchored && !body.contains('/') {
format!("**/{body}")
} else {
body.to_string()
};
let contents = format!("{base}/**");
if directory_only {
return vec![contents];
}
let last_segment = base.rsplit('/').next().unwrap_or(&base);
if matches!(last_segment, "*" | "**") {
vec![base]
} else {
vec![base, contents]
}
}
pub fn directory_group(relative_path: &Path) -> &str {
let s = relative_path.to_str().unwrap_or("");
let s = if s.contains('\\') {
return s.split(['/', '\\']).next().unwrap_or(s);
} else {
s
};
match s.find('/') {
Some(pos) => &s[..pos],
None => s, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translate_bare_glob() {
assert_eq!(translate_pattern("*.js"), ["**/*.js", "**/*.js/**"]);
}
#[test]
fn translate_rooted_pattern() {
assert_eq!(translate_pattern("/docs/*"), ["docs/*"]);
}
#[test]
fn translate_directory_pattern() {
assert_eq!(translate_pattern("docs/"), ["**/docs/**"]);
}
#[test]
fn translate_rooted_directory() {
assert_eq!(translate_pattern("/src/app/"), ["src/app/**"]);
}
#[test]
fn translate_rooted_path_without_trailing_slash() {
assert_eq!(translate_pattern("/docs"), ["docs", "docs/**"]);
}
#[test]
fn translate_path_with_slash() {
assert_eq!(
translate_pattern("src/utils/*.ts"),
["src/utils/*.ts", "src/utils/*.ts/**"]
);
}
#[test]
fn translate_double_star() {
assert_eq!(
translate_pattern("**/test_*.py"),
["**/test_*.py", "**/test_*.py/**"]
);
assert_eq!(translate_pattern("/build/**"), ["build/**"]);
}
#[test]
fn translate_single_file() {
assert_eq!(
translate_pattern("Makefile"),
["**/Makefile", "**/Makefile/**"]
);
}
#[test]
fn translate_catch_all() {
assert_eq!(translate_pattern("*"), ["**/*"]);
}
#[test]
fn parse_simple_codeowners() {
let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 3);
}
#[test]
fn parse_skips_comments_and_blanks() {
let content = "# Comment\n\n* @owner\n # Indented comment\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
}
#[test]
fn parse_multi_owner_takes_first() {
let content = "*.ts @team-a @team-b @team-c\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners[0], "@team-a");
}
#[test]
fn parse_skips_pattern_without_owner() {
let content = "*.ts\n*.js @owner\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
assert_eq!(co.owners[0], "@owner");
}
#[test]
fn parse_empty_content() {
let co = CodeOwners::parse("").unwrap();
assert_eq!(co.owner_of(Path::new("anything.ts")), None);
}
#[test]
fn owner_of_last_match_wins() {
let content = "* @default\n/src/ @frontend\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
}
#[test]
fn owner_of_falls_back_to_catch_all() {
let content = "* @default\n/src/ @frontend\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
}
#[test]
fn owner_of_no_match_returns_none() {
let content = "/src/ @frontend\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("README.md")), None);
}
#[test]
fn owner_of_extension_glob() {
let content = "*.rs @rust-team\n*.ts @ts-team\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("src/lib.rs")), Some("@rust-team"));
assert_eq!(
co.owner_of(Path::new("packages/ui/Button.ts")),
Some("@ts-team")
);
}
#[test]
fn owner_of_nested_directory() {
let content = "* @default\n/packages/auth/ @auth-team\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.owner_of(Path::new("packages/auth/src/login.ts")),
Some("@auth-team")
);
assert_eq!(
co.owner_of(Path::new("packages/ui/Button.ts")),
Some("@default")
);
}
#[test]
fn owner_of_specific_overrides_general() {
let content = "\
* @default\n\
/src/ @frontend\n\
/src/api/ @backend\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.owner_of(Path::new("src/api/routes.ts")),
Some("@backend")
);
assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend"));
}
#[test]
fn owner_and_rule_of_returns_owner_and_pattern() {
let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.owner_and_rule_of(Path::new("src/app.ts")),
Some(("@frontend", "/src/"))
);
assert_eq!(
co.owner_and_rule_of(Path::new("src/lib.rs")),
Some(("@rust-team", "*.rs"))
);
assert_eq!(
co.owner_and_rule_of(Path::new("README.md")),
Some(("@default", "*"))
);
}
#[test]
fn owner_and_rule_of_no_match() {
let content = "/src/ @frontend\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None);
}
#[test]
fn directory_group_simple() {
assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src");
}
#[test]
fn directory_group_root_file() {
assert_eq!(directory_group(Path::new("index.ts")), "index.ts");
}
#[test]
fn directory_group_monorepo() {
assert_eq!(
directory_group(Path::new("packages/auth/src/login.ts")),
"packages"
);
}
#[test]
fn discover_nonexistent_root() {
let result = CodeOwners::discover(Path::new("/nonexistent/path"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("no CODEOWNERS file found"));
assert!(err.contains("--group-by directory"));
}
#[test]
fn from_file_nonexistent() {
let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS"));
assert!(result.is_err());
}
#[test]
fn from_file_real_codeowners() {
let root = std::path::PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
)
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let path = root.join(".github/CODEOWNERS");
if path.exists() {
let co = CodeOwners::from_file(&path).unwrap();
assert_eq!(
co.owner_of(Path::new(".claude/rules/detection.md")),
Some("@bartwaardenburg")
);
}
}
#[test]
fn email_owner() {
let content = "*.js user@example.com\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("index.js")), Some("user@example.com"));
}
#[test]
fn team_owner() {
let content = "*.ts @org/frontend-team\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team"));
}
#[test]
fn gitlab_section_header_skipped_as_rule() {
let content = "[Section Name]\n*.ts @owner\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
assert_eq!(co.owner_of(Path::new("app.ts")), Some("@owner"));
}
#[test]
fn gitlab_optional_section_header_skipped() {
let content = "^[Optional Section]\n*.ts @owner\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
}
#[test]
fn gitlab_section_header_with_approval_count_skipped() {
let content = "[Section Name][2]\n*.ts @owner\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
}
#[test]
fn gitlab_optional_section_with_approval_count_skipped() {
let content = "^[Section Name][3] @fallback-team\nfoo/\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
assert_eq!(co.owner_of(Path::new("foo/bar.ts")), Some("@fallback-team"));
}
#[test]
fn gitlab_section_default_owners_inherited() {
let content = "\
[Utilities] @utils-team\n\
src/utils/\n\
[UI Components] @ui-team\n\
src/components/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 2);
assert_eq!(
co.owner_of(Path::new("src/utils/greet.ts")),
Some("@utils-team")
);
assert_eq!(
co.owner_of(Path::new("src/components/button.ts")),
Some("@ui-team")
);
}
#[test]
fn gitlab_inline_owner_overrides_section_default() {
let content = "\
[Section] @section-owner\n\
src/generic/\n\
src/special/ @special-owner\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.owner_of(Path::new("src/generic/a.ts")),
Some("@section-owner")
);
assert_eq!(
co.owner_of(Path::new("src/special/a.ts")),
Some("@special-owner")
);
}
#[test]
fn gitlab_section_defaults_reset_between_sections() {
let content = "\
[Section1] @team-a\n\
foo/\n\
[Section2]\n\
bar/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
assert_eq!(co.owner_of(Path::new("foo/x.ts")), Some("@team-a"));
assert_eq!(co.owner_of(Path::new("bar/x.ts")), None);
}
#[test]
fn gitlab_section_header_multiple_default_owners_uses_first() {
let content = "[Section] @first @second\nfoo/\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("foo/a.ts")), Some("@first"));
}
#[test]
fn gitlab_rules_before_first_section_retain_inline_owners() {
let content = "\
* @default-owner\n\
[Utilities] @utils-team\n\
src/utils/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
assert_eq!(
co.owner_of(Path::new("src/utils/greet.ts")),
Some("@utils-team")
);
}
#[test]
fn gitlab_issue_127_reproduction() {
let content = "\
# Default section (no header, rules before first section)
* @default-owner
[Utilities] @utils-team
src/utils/
[UI Components] @ui-team
src/components/
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("README.md")), Some("@default-owner"));
assert_eq!(
co.owner_of(Path::new("src/utils/greet.ts")),
Some("@utils-team")
);
assert_eq!(
co.owner_of(Path::new("src/components/button.ts")),
Some("@ui-team")
);
}
#[test]
fn gitlab_negation_last_match_clears_ownership() {
let content = "\
* @default\n\
!src/generated/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("README.md")), Some("@default"));
assert_eq!(co.owner_of(Path::new("src/generated/bundle.js")), None);
}
#[test]
fn gitlab_negation_only_clears_when_last_match() {
let content = "\
* @default\n\
!src/\n\
/src/special/ @special\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("src/foo.ts")), None);
assert_eq!(co.owner_of(Path::new("src/special/a.ts")), Some("@special"));
}
#[test]
fn gitlab_negation_owner_and_rule_returns_none() {
let content = "* @default\n!src/vendor/\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.owner_and_rule_of(Path::new("README.md")),
Some(("@default", "*"))
);
assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None);
}
#[test]
fn parse_section_header_variants() {
assert_eq!(
parse_section_header("[Section]"),
Some(("Section".into(), vec![]))
);
assert_eq!(
parse_section_header("^[Section]"),
Some(("Section".into(), vec![]))
);
assert_eq!(
parse_section_header("[Section][2]"),
Some(("Section".into(), vec![]))
);
assert_eq!(
parse_section_header("^[Section][2]"),
Some(("Section".into(), vec![]))
);
assert_eq!(
parse_section_header("[Section] @a @b"),
Some(("Section".into(), vec!["@a".into(), "@b".into()]))
);
assert_eq!(
parse_section_header("[Section][2] @a"),
Some(("Section".into(), vec!["@a".into()]))
);
}
#[test]
fn parse_section_header_rejects_malformed() {
assert_eq!(parse_section_header("[unclosed"), None);
assert_eq!(parse_section_header("[]"), None);
assert_eq!(parse_section_header("[abc]def @owner"), None);
assert_eq!(parse_section_header("[Section][] @owner"), None);
assert_eq!(parse_section_header("[Section][abc] @owner"), None);
}
#[test]
fn has_sections_false_without_headers() {
let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
assert!(!co.has_sections());
}
#[test]
fn has_sections_true_with_headers() {
let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
assert!(co.has_sections());
}
#[test]
fn section_of_returns_named_section() {
let content = "\
[Billing] @billing-team\n\
src/billing/\n\
[Search] @search-team\n\
src/search/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.section_of(Path::new("src/billing/invoice.ts")),
Some(Some("Billing"))
);
assert_eq!(
co.section_of(Path::new("src/search/indexer.ts")),
Some(Some("Search"))
);
}
#[test]
fn section_of_returns_some_none_for_pre_section_rule() {
let content = "\
* @default\n\
[Billing] @billing-team\n\
src/billing/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.section_of(Path::new("README.md")), Some(None));
assert_eq!(
co.section_of(Path::new("src/billing/invoice.ts")),
Some(Some("Billing"))
);
}
#[test]
fn section_of_returns_none_for_unmatched_path() {
let content = "[Billing] @billing-team\nsrc/billing/\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.section_of(Path::new("src/other/x.ts")), None);
}
#[test]
fn section_of_returns_none_for_negation_last_match() {
let content = "\
[Billing] @billing-team\n\
src/billing/\n\
!src/billing/vendor/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.section_of(Path::new("src/billing/invoice.ts")),
Some(Some("Billing"))
);
assert_eq!(co.section_of(Path::new("src/billing/vendor/lib.js")), None);
}
#[test]
fn section_and_owners_of_returns_section_defaults() {
let content = "\
[Billing] @core-reviewers @alice\n\
src/billing/\n\
";
let co = CodeOwners::parse(content).unwrap();
let (section, owners) = co
.section_and_owners_of(Path::new("src/billing/invoice.ts"))
.unwrap();
assert_eq!(section, Some("Billing"));
assert_eq!(
owners,
&["@core-reviewers".to_string(), "@alice".to_string()]
);
}
#[test]
fn section_and_owners_of_same_owners_distinct_sections() {
let content = "\
[billing] @core-reviewers @alice @bob\n\
src/billing/\n\
[notifications] @core-reviewers @alice @bob\n\
src/notifications/\n\
";
let co = CodeOwners::parse(content).unwrap();
let (billing_sec, _) = co
.section_and_owners_of(Path::new("src/billing/invoice.ts"))
.unwrap();
let (notifications_sec, _) = co
.section_and_owners_of(Path::new("src/notifications/email.ts"))
.unwrap();
assert_eq!(billing_sec, Some("billing"));
assert_eq!(notifications_sec, Some("notifications"));
}
#[test]
fn section_and_owners_of_empty_owners_for_pre_section_rule() {
let content = "* @default\n[Billing]\nsrc/billing/ @billing\n";
let co = CodeOwners::parse(content).unwrap();
let (section, owners) = co.section_and_owners_of(Path::new("README.md")).unwrap();
assert_eq!(section, None);
assert!(owners.is_empty());
}
#[test]
fn owner_count_of_counts_all_matched_owners() {
let content = "\
* @default\n\
src/api/ @backend @payments @security\n\
[Frontend] @ui @design\n\
src/ui/\n\
!src/generated/\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_count_of(Path::new("src/api/payments.ts")), Some(3));
assert_eq!(co.owner_count_of(Path::new("src/ui/button.tsx")), Some(2));
assert_eq!(co.owner_count_of(Path::new("README.md")), Some(1));
assert_eq!(
co.owner_count_of(Path::new("src/generated/types.ts")),
Some(0)
);
assert_eq!(
co.owner_count_of(Path::new("other/generated/types.ts")),
Some(1)
);
}
#[test]
fn non_section_bracket_pattern_parses_as_rule() {
let content = "[abc]def @owner\n";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owners.len(), 1);
assert_eq!(co.owner_of(Path::new("adef")), Some("@owner"));
}
#[test]
fn pattern_without_trailing_slash_owns_directory_contents() {
let content = "\
src/platform/packages/shared/kbn-try-in-console @elastic/search\n\
/docs @docs-team\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.owner_of(Path::new(
"src/platform/packages/shared/kbn-try-in-console/index.ts"
)),
Some("@elastic/search")
);
assert_eq!(
co.owner_of(Path::new(
"src/platform/packages/shared/kbn-try-in-console/src/components/a.tsx"
)),
Some("@elastic/search")
);
assert_eq!(
co.owner_of(Path::new("docs/guide/intro.md")),
Some("@docs-team")
);
assert_eq!(
co.owner_and_rule_of(Path::new("docs/guide/intro.md")),
Some(("@docs-team", "/docs"))
);
assert_eq!(co.owner_count_of(Path::new("docs/a.md")), Some(1));
}
#[test]
fn pattern_without_trailing_slash_still_matches_a_file() {
let co = CodeOwners::parse("/package.json @root\nMakefile @build\n").unwrap();
assert_eq!(co.owner_of(Path::new("package.json")), Some("@root"));
assert_eq!(co.owner_of(Path::new("tools/Makefile")), Some("@build"));
}
#[test]
fn pattern_without_trailing_slash_does_not_match_sibling_prefix() {
let co = CodeOwners::parse("/docs @docs-team\n").unwrap();
assert_eq!(co.owner_of(Path::new("docs-old/a.md")), None);
assert_eq!(co.owner_of(Path::new("docs.md")), None);
}
#[test]
fn directory_pattern_keeps_last_match_wins_order() {
let content = "\
/src @general\n\
/src/lib @lib\n\
/src/lib/legacy.ts @legacy\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("src/main.ts")), Some("@general"));
assert_eq!(co.owner_of(Path::new("src/lib/a/b.ts")), Some("@lib"));
assert_eq!(co.owner_of(Path::new("src/lib/legacy.ts")), Some("@legacy"));
let reversed = "\
/src/lib @lib\n\
/src @general\n\
";
let co = CodeOwners::parse(reversed).unwrap();
assert_eq!(co.owner_of(Path::new("src/lib/a/b.ts")), Some("@general"));
}
#[test]
fn trailing_star_matches_direct_children_only() {
let co = CodeOwners::parse("* @default\ndocs/* @docs-team\n").unwrap();
assert_eq!(
co.owner_of(Path::new("docs/getting-started.md")),
Some("@docs-team")
);
assert_eq!(
co.owner_of(Path::new("docs/build-app/troubleshooting.md")),
Some("@default")
);
}
#[test]
fn root_star_owns_root_level_files_only() {
let co = CodeOwners::parse("/* @root\n").unwrap();
assert_eq!(co.owner_of(Path::new("package.json")), Some("@root"));
assert_eq!(co.owner_of(Path::new("src/index.ts")), None);
let co = CodeOwners::parse("* @all\n").unwrap();
assert_eq!(co.owner_of(Path::new("src/deep/index.ts")), Some("@all"));
}
#[test]
fn single_star_does_not_cross_directories() {
let co = CodeOwners::parse("* @default\n/src/*.ts @ts\n").unwrap();
assert_eq!(co.owner_of(Path::new("src/index.ts")), Some("@ts"));
assert_eq!(co.owner_of(Path::new("src/nested/a.ts")), Some("@default"));
}
#[test]
fn wildcard_name_owns_matching_directory_contents() {
let co = CodeOwners::parse("/fixtures/date_* @data\n").unwrap();
assert_eq!(
co.owner_of(Path::new("fixtures/date_nanos/data.json")),
Some("@data")
);
}
#[test]
fn trailing_double_star_keeps_its_meaning() {
let co = CodeOwners::parse("/build/logs/** @ops\n").unwrap();
assert_eq!(co.owner_of(Path::new("build/logs/a/b.log")), Some("@ops"));
assert_eq!(co.owner_of(Path::new("build/other.log")), None);
}
#[test]
fn unanchored_directory_pattern_matches_at_any_depth() {
let co = CodeOwners::parse("apps/ @apps-team\n").unwrap();
assert_eq!(co.owner_of(Path::new("apps/web/a.ts")), Some("@apps-team"));
assert_eq!(
co.owner_of(Path::new("packages/x/apps/web/a.ts")),
Some("@apps-team")
);
}
#[test]
fn negation_without_trailing_slash_clears_directory_contents() {
let content = "\
* @default\n\
!/src/generated\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(co.owner_of(Path::new("src/generated/types.ts")), None);
assert_eq!(
co.owner_count_of(Path::new("src/generated/a/b.ts")),
Some(0)
);
assert_eq!(co.owner_of(Path::new("src/main.ts")), Some("@default"));
}
#[test]
fn gitlab_section_pattern_without_trailing_slash_owns_directory_contents() {
let content = "\
[Frontend] @ui\n\
/src/ui\n\
[Backend] @api\n\
/src/api @api-lead\n\
";
let co = CodeOwners::parse(content).unwrap();
assert_eq!(
co.section_owners_and_rule_of(Path::new("src/ui/button/a.tsx")),
Some((Some("Frontend"), ["@ui".to_string()].as_slice(), "/src/ui"))
);
assert_eq!(
co.section_of(Path::new("src/api/users.ts")),
Some(Some("Backend"))
);
assert_eq!(
co.owner_of(Path::new("src/api/users.ts")),
Some("@api-lead")
);
}
}