use std::path::Path;
use globset::{Glob, GlobSet, GlobSetBuilder};
#[derive(Debug)]
pub struct CodeOwners {
owners: Vec<String>,
patterns: Vec<String>,
is_negation: Vec<bool>,
globs: GlobSet,
}
const PROBE_PATHS: &[&str] = &[
"CODEOWNERS",
".github/CODEOWNERS",
".gitlab/CODEOWNERS",
"docs/CODEOWNERS",
];
pub const UNOWNED_LABEL: &str = "(unowned)";
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(crate) fn parse(content: &str) -> Result<Self, String> {
let mut builder = GlobSetBuilder::new();
let mut owners = Vec::new();
let mut patterns = Vec::new();
let mut is_negation = Vec::new();
let mut section_default_owners: Vec<String> = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(defaults) = parse_section_header(line) {
section_default_owners = defaults;
continue;
}
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 {
continue;
};
let first_inline_owner = parts.next();
let effective_owner: &str = if negate {
""
} else if let Some(o) = first_inline_owner {
o
} else if let Some(o) = section_default_owners.first() {
o.as_str()
} else {
continue;
};
let glob_pattern = translate_pattern(pattern);
let glob = Glob::new(&glob_pattern)
.map_err(|e| format!("invalid CODEOWNERS pattern '{pattern}': {e}"))?;
builder.add(glob);
owners.push(effective_owner.to_string());
patterns.push(if negate {
format!("!{pattern}")
} else {
pattern.to_string()
});
is_negation.push(negate);
}
let globs = builder
.build()
.map_err(|e| format!("failed to compile CODEOWNERS patterns: {e}"))?;
Ok(Self {
owners,
patterns,
is_negation,
globs,
})
}
pub fn owner_of(&self, relative_path: &Path) -> Option<&str> {
let matches = self.globs.matches(relative_path);
matches.iter().max().and_then(|&idx| {
if self.is_negation[idx] {
None
} else {
Some(self.owners[idx].as_str())
}
})
}
pub fn owner_and_rule_of(&self, relative_path: &Path) -> Option<(&str, &str)> {
let matches = self.globs.matches(relative_path);
matches.iter().max().and_then(|&idx| {
if self.is_negation[idx] {
None
} else {
Some((self.owners[idx].as_str(), self.patterns[idx].as_str()))
}
})
}
}
fn parse_section_header(line: &str) -> Option<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(after.split_whitespace().map(String::from).collect())
}
fn translate_pattern(pattern: &str) -> String {
let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') {
(true, p)
} else {
(false, pattern)
};
let expanded = if let Some(p) = rest.strip_suffix('/') {
format!("{p}/**")
} else {
rest.to_string()
};
if !anchored && !expanded.contains('/') {
format!("**/{expanded}")
} else {
expanded
}
}
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::*;
use std::path::PathBuf;
#[test]
fn translate_bare_glob() {
assert_eq!(translate_pattern("*.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_path_with_slash() {
assert_eq!(translate_pattern("src/utils/*.ts"), "src/utils/*.ts");
}
#[test]
fn translate_double_star() {
assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py");
}
#[test]
fn translate_single_file() {
assert_eq!(translate_pattern("Makefile"), "**/Makefile");
}
#[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 = PathBuf::from(env!("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("src/anything.ts")),
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(vec![]));
assert_eq!(parse_section_header("^[Section]"), Some(vec![]));
assert_eq!(parse_section_header("[Section][2]"), Some(vec![]));
assert_eq!(parse_section_header("^[Section][2]"), Some(vec![]));
assert_eq!(
parse_section_header("[Section] @a @b"),
Some(vec!["@a".into(), "@b".into()])
);
assert_eq!(
parse_section_header("[Section][2] @a"),
Some(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 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"));
}
}