#![no_std]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
extern crate alloc;
use alloc::borrow::ToOwned;
use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
mod pattern;
pub use pattern::{Pattern, PatternError};
pub const SEARCH_PATHS: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
pub const MAX_FILE_SIZE: usize = 3 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Owner {
User(String),
Team {
org: String,
team: String,
},
Email(String),
}
impl Owner {
#[must_use]
pub fn user(name: &str) -> Self {
Self::User(name.to_owned())
}
#[must_use]
pub fn team(org: &str, team: &str) -> Self {
Self::Team {
org: org.to_owned(),
team: team.to_owned(),
}
}
#[must_use]
pub fn email(addr: &str) -> Self {
Self::Email(addr.to_owned())
}
#[must_use]
pub fn parse(token: &str) -> Option<Self> {
if let Some(rest) = token.strip_prefix('@') {
if rest.is_empty() {
return None;
}
return match rest.split_once('/') {
Some((org, team)) => {
if is_login(org) && is_slug(team) {
Some(Self::team(org, team))
} else {
None
}
}
None if is_login(rest) => Some(Self::User(rest.to_owned())),
None => None,
};
}
let (local, domain) = token.split_once('@')?;
if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
return None;
}
Some(Self::Email(token.to_owned()))
}
}
fn is_login(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 39
&& !s.starts_with('-')
&& !s.ends_with('-')
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn is_slug(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
impl fmt::Display for Owner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::User(u) => write!(f, "@{u}"),
Self::Team { org, team } => write!(f, "@{org}/{team}"),
Self::Email(e) => f.write_str(e),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub pattern: Pattern,
pub owners: Vec<Owner>,
pub line: usize,
}
impl Rule {
#[must_use]
pub fn is_unowned(&self) -> bool {
self.owners.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
BadPattern(PatternError),
BadOwner(String),
}
impl fmt::Display for ErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadPattern(e) => write!(f, "{e}"),
Self::BadOwner(t) => write!(f, "`{t}` is not a valid owner"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub line: usize,
pub kind: ErrorKind,
pub text: String,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.kind)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodeOwners {
rules: Vec<Rule>,
errors: Vec<ParseError>,
}
impl CodeOwners {
#[must_use]
pub fn parse(text: &str) -> Self {
let mut rules = Vec::new();
let mut errors = Vec::new();
for (idx, raw_line) in text.lines().enumerate() {
let line = idx + 1;
let content = raw_line.split('#').next().unwrap_or("").trim();
if content.is_empty() {
continue;
}
let mut tokens = content.split_whitespace();
let Some(pattern_str) = tokens.next() else {
continue;
};
let pattern = match Pattern::new(pattern_str) {
Ok(p) => p,
Err(e) => {
errors.push(ParseError {
line,
kind: ErrorKind::BadPattern(e),
text: content.to_owned(),
});
continue;
}
};
let mut owners = Vec::new();
let mut bad = None;
for token in tokens {
match Owner::parse(token) {
Some(o) => owners.push(o),
None => {
bad = Some(token.to_owned());
break;
}
}
}
if let Some(token) = bad {
errors.push(ParseError {
line,
kind: ErrorKind::BadOwner(token),
text: content.to_owned(),
});
continue;
}
rules.push(Rule {
pattern,
owners,
line,
});
}
Self { rules, errors }
}
#[must_use]
pub fn of(&self, path: &str) -> Option<&[Owner]> {
self.rule_for(path).map(|r| r.owners.as_slice())
}
#[must_use]
pub fn rule_for(&self, path: &str) -> Option<&Rule> {
self.rules.iter().rev().find(|r| r.pattern.matches(path))
}
#[must_use]
pub fn all_matching(&self, path: &str) -> Vec<&Rule> {
self.rules
.iter()
.filter(|r| r.pattern.matches(path))
.collect()
}
#[must_use]
pub fn shadowed(&self) -> Vec<&Rule> {
self.rules
.iter()
.enumerate()
.filter(|(i, rule)| {
self.rules[i + 1..]
.iter()
.any(|later| later.pattern.as_str() == rule.pattern.as_str())
})
.map(|(_, rule)| rule)
.collect()
}
#[must_use]
pub fn rules(&self) -> &[Rule] {
&self.rules
}
#[must_use]
pub fn errors(&self) -> &[ParseError] {
&self.errors
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}