#![allow(missing_docs)]
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
extern crate glob;
#[macro_use]
extern crate lazy_static;
extern crate regex;
use glob::Pattern;
use regex::Regex;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, Read};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::str::FromStr;
const CODEOWNERS: &str = "CODEOWNERS";
#[derive(Debug, PartialEq)]
pub enum Owner {
Username(String),
Team(String),
Email(String),
}
impl fmt::Display for Owner {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let inner = match self {
&Owner::Username(ref u) => u,
&Owner::Team(ref t) => t,
&Owner::Email(ref e) => e,
};
f.write_str(inner.as_str())
}
}
impl FromStr for Owner {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
lazy_static! {
static ref TEAM: Regex = Regex::new(r"^@\S+/\S+").unwrap();
static ref USERNAME: Regex = Regex::new(r"^@\S+").unwrap();
static ref EMAIL: Regex = Regex::new(r"^\S+@\S+").unwrap();
}
if TEAM.is_match(s) {
Ok(Owner::Team(s.into()))
} else if USERNAME.is_match(s) {
Ok(Owner::Username(s.into()))
} else if EMAIL.is_match(s) {
Ok(Owner::Email(s.into()))
} else {
Err(String::from("not an owner"))
}
}
}
#[derive(Debug, PartialEq)]
pub struct Owners {
paths: Vec<(Pattern, Vec<Owner>)>,
}
impl Owners {
pub fn of<P>(&self, path: P) -> Option<&Vec<Owner>>
where
P: AsRef<Path>,
{
self.paths
.iter()
.filter_map(|mapping| {
let &(ref pattern, ref owners) = mapping;
let opts = glob::MatchOptions {
case_sensitive: false,
require_literal_separator: pattern.as_str().contains("/"),
require_literal_leading_dot: false,
};
if pattern.matches_path_with(path.as_ref(), &opts) {
Some(owners)
} else {
if pattern.as_str().ends_with("/*") {
return None;
}
let mut p = path.as_ref();
while let Some(parent) = p.parent() {
if pattern.matches_path_with(parent.as_ref(), &opts) {
return Some(owners);
} else {
p = parent;
}
}
None
}
})
.next()
}
}
pub fn locate<P>(ctx: P) -> Option<PathBuf>
where
P: AsRef<Path>,
{
let root = ctx.as_ref().join(CODEOWNERS);
let github = ctx.as_ref().join(".github").join(CODEOWNERS);
let docs = ctx.as_ref().join("docs").join(CODEOWNERS);
if root.exists() {
Some(root)
} else if github.exists() {
Some(github)
} else if docs.exists() {
Some(docs)
} else {
None
}
}
pub fn from_path<P>(path: P) -> Owners
where
P: AsRef<Path>,
{
::from_reader(File::open(path).unwrap())
}
pub fn from_reader<R>(read: R) -> Owners
where
R: Read,
{
let mut paths = BufReader::new(read)
.lines()
.filter_map(Result::ok)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.fold(Vec::new(), |mut paths, line| {
let mut elements = line.split_whitespace();
if let Some(path) = elements.next() {
let owners = elements.fold(Vec::new(), |mut result, owner| {
if let Ok(owner) = owner.parse() {
result.push(owner)
}
result
});
paths.push((pattern(path), owners))
}
paths
});
paths.reverse();
Owners { paths: paths }
}
fn pattern(path: &str) -> Pattern {
let prefixed = if path.starts_with("*") || path.starts_with("/") {
path.to_owned()
} else {
format!("**/{}", path)
};
let mut normalized = prefixed.trim_left_matches("/").to_string();
if normalized.ends_with("/") {
normalized.push_str("**");
}
Pattern::new(&normalized).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r"# This is a comment.
# Each line is a file pattern followed by one or more owners.
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
# @global-owner1 and @global-owner2 will be requested for
# review when someone opens a pull request.
* @global-owner1 @global-owner2
# Order is important; the last matching pattern takes the most
# precedence. When someone opens a pull request that only
# modifies JS files, only @js-owner and not the global
# owner(s) will be requested for a review.
*.js @js-owner
# You can also use email addresses if you prefer. They'll be
# used to look up users just like we do for commit author
# emails.
*.go docs@example.com
# In this example, @doctocat owns any files in the build/logs
# directory at the root of the repository and any of its
# subdirectories.
/build/logs/ @doctocat
# The `docs/*` pattern will match files like
# `docs/getting-started.md` but not further nested files like
# `docs/build-app/troubleshooting.md`.
docs/* docs@example.com
# In this example, @octocat owns any file in an apps directory
# anywhere in your repository.
apps/ @octocat
# In this example, @doctocat owns any file in the `/docs`
# directory in the root of your repository.
/docs/ @doctocat
";
#[test]
fn owner_parses() {
assert!("@user".parse() == Ok(Owner::Username("@user".into())));
assert!("@org/team".parse() == Ok(Owner::Team("@org/team".into())));
assert!(
"user@domain.com".parse() == Ok(Owner::Email("user@domain.com".into()))
);
assert!("bogus".parse::<Owner>() == Err("not an owner".into()));
}
#[test]
fn owner_displays() {
assert!(Owner::Username("@user".into()).to_string() == "@user");
assert!(Owner::Team("@org/team".into()).to_string() == "@org/team");
assert!(
Owner::Email("user@domain.com".into()).to_string() == "user@domain.com"
);
}
#[test]
fn from_reader_parses() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners,
Owners {
paths: vec![
(
Pattern::new("docs/**").unwrap(),
vec![Owner::Username("@doctocat".into())]
),
(
Pattern::new("**/apps/**").unwrap(),
vec![Owner::Username("@octocat".into())]
),
(
Pattern::new("**/docs/*").unwrap(),
vec![Owner::Email("docs@example.com".into())]
),
(
Pattern::new("build/logs/**").unwrap(),
vec![Owner::Username("@doctocat".into())]
),
(
Pattern::new("*.go").unwrap(),
vec![Owner::Email("docs@example.com".into())]
),
(
Pattern::new("*.js").unwrap(),
vec![Owner::Username("@js-owner".into())]
),
(
Pattern::new("*").unwrap(),
vec![
Owner::Username("@global-owner1".into()),
Owner::Username("@global-owner2".into()),
]
),
],
}
)
}
#[test]
fn owners_owns_wildcard() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("foo.txt"),
Some(&vec![
Owner::Username("@global-owner1".into()),
Owner::Username("@global-owner2".into()),
])
);
assert_eq!(
owners.of("foo/bar.txt"),
Some(&vec![
Owner::Username("@global-owner1".into()),
Owner::Username("@global-owner2".into()),
])
)
}
#[test]
fn owners_owns_js_extention() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("foo.js"),
Some(&vec![Owner::Username("@js-owner".into())])
);
assert_eq!(
owners.of("foo/bar.js"),
Some(&vec![Owner::Username("@js-owner".into())])
)
}
#[test]
fn owners_owns_go_extention() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("foo.go"),
Some(&vec![Owner::Email("docs@example.com".into())])
);
assert_eq!(
owners.of("foo/bar.go"),
Some(&vec![Owner::Email("docs@example.com".into())])
)
}
#[test]
fn owners_owns_anchored_build_logs() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("build/logs/foo.go"),
Some(&vec![Owner::Username("@doctocat".into())])
);
assert_eq!(
owners.of("build/logs/foo/bar.go"),
Some(&vec![Owner::Username("@doctocat".into())])
);
assert_eq!(
owners.of("foo/build/logs/foo.go"),
Some(&vec![Owner::Email("docs@example.com".into())])
)
}
#[test]
fn owners_owns_unanchored_docs() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("foo/docs/foo.js"),
Some(&vec![Owner::Email("docs@example.com".into())])
);
assert_eq!(
owners.of("foo/bar/docs/foo.js"),
Some(&vec![Owner::Email("docs@example.com".into())])
);
assert_eq!(
owners.of("foo/bar/docs/foo/foo.js"),
Some(&vec![Owner::Username("@js-owner".into())])
)
}
#[test]
fn owners_owns_unanchored_apps() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("foo/apps/foo.js"),
Some(&vec![Owner::Username("@octocat".into())])
)
}
#[test]
fn owners_owns_anchored_docs() {
let owners = from_reader(EXAMPLE.as_bytes());
assert_eq!(
owners.of("docs/foo.js"),
Some(&vec![Owner::Username("@doctocat".into())])
)
}
#[test]
fn implied_children_owners() {
let owners = from_reader("foo/bar @doug".as_bytes());
assert_eq!(
owners.of("foo/bar/baz.rs"),
Some(&vec![Owner::Username("@doug".into())])
)
}
}