diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c9c9efe..ebc3c39e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Update version run: | export VERSION=${{ github.event.inputs.release_tag }} - sed -i "s/0.0.0/$VERSION/g" Cargo.toml + sed -i "s/0.0.0/$VERSION/g" ./cli/Cargo.toml - name: Build ${{ matrix.target }} target uses: ./.github/actions/build_target @@ -70,7 +70,7 @@ jobs: - name: Update version run: | export VERSION=${{ github.event.inputs.release_tag }} - sed -i '' "s/0.0.0/$VERSION/g" Cargo.toml + sed -i '' "s/0.0.0/$VERSION/g" ./cli/Cargo.toml - name: Add target shell: bash diff --git a/Cargo.lock b/Cargo.lock index 3180afa4..939fa4e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" -[[package]] -name = "aho-corasick" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81ce3d38065e618af2d7b77e10c5ad9a069859b4be3c2250f674af3840d9c8a5" -dependencies = [ - "memchr", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -50,6 +41,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.14" @@ -265,7 +265,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -283,12 +283,12 @@ checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" [[package]] name = "codeowners" version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665cbdc903baba1521d6fca5e024f1faeaebd0e02a555c13564aab010d07ebf" dependencies = [ - "glob 0.2.11", - "lazy_static 0.2.11", - "regex 0.2.11", + "anyhow", + "glob", + "lazy_static", + "pretty_assertions", + "regex", ] [[package]] @@ -322,6 +322,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "debugid" version = "0.8.0" @@ -341,6 +351,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + [[package]] name = "dunce" version = "1.0.4" @@ -413,7 +429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" dependencies = [ "cc", - "lazy_static 1.5.0", + "lazy_static", "libc", "winapi", ] @@ -488,7 +504,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -785,7 +801,7 @@ checksum = "999ce923619f88194171a67fb3e6d613653b8d4d6078b529b15a765da0edcc17" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -1020,12 +1036,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "glob" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be18de09a56b60ed0edf84bc9df007e30040691af7acd1c41874faac5895bfb" - [[package]] name = "glob" version = "0.3.1" @@ -1274,12 +1284,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "lazy_static" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f033c7ad61445c5b347c7382dd1237847eb1bce590fe50365dcb33d546be73" - [[package]] name = "lazy_static" version = "1.5.0" @@ -1493,7 +1497,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -1535,6 +1539,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -1581,7 +1594,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -1614,6 +1627,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_assertions" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f81e1644e1b54f5a68959a29aa86cde704219254669da328ecfdf6a1f09d427" +dependencies = [ + "ansi_term", + "ctor", + "difference", + "output_vt100", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -1742,29 +1767,16 @@ dependencies = [ "bitflags 2.6.0", ] -[[package]] -name = "regex" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9329abc99e39129fcceabd24cf5d85b4671ef7c29c50e972bc5afe32438ec384" -dependencies = [ - "aho-corasick 0.6.10", - "memchr", - "regex-syntax 0.5.6", - "thread_local", - "utf8-ranges", -] - [[package]] name = "regex" version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ - "aho-corasick 1.1.3", + "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.4", + "regex-syntax", ] [[package]] @@ -1773,18 +1785,9 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ - "aho-corasick 1.1.3", + "aho-corasick", "memchr", - "regex-syntax 0.8.4", -] - -[[package]] -name = "regex-syntax" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d707a4fa2637f2dca2ef9fd02225ec7661fe01a53623c1e6515b6916511f7a7" -dependencies = [ - "ucd-util", + "regex-syntax", ] [[package]] @@ -2039,7 +2042,7 @@ checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" dependencies = [ "backtrace", "once_cell", - "regex 1.10.5", + "regex", "sentry-core", ] @@ -2137,7 +2140,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -2212,6 +2215,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.68" @@ -2282,16 +2296,7 @@ checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6b53e329000edc2b34dbe8545fd20e55a333362d0a321909685a19bd28c3f1b" -dependencies = [ - "lazy_static 1.5.0", + "syn 2.0.68", ] [[package]] @@ -2367,7 +2372,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -2461,7 +2466,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", ] [[package]] @@ -2495,11 +2500,11 @@ dependencies = [ "exitcode", "git2", "gix", - "glob 0.3.1", + "glob", "junit-parser", "log", "openssl", - "regex 1.10.5", + "regex", "reqwest", "sentry", "serde", @@ -2518,12 +2523,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ucd-util" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd2fc5d32b590614af8b0a20d837f32eca055edd0bbead59a9cfe80858be003" - [[package]] name = "uname" version = "0.1.1" @@ -2591,12 +2590,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - [[package]] name = "utf8parse" version = "0.2.2" @@ -2633,7 +2626,7 @@ dependencies = [ "anyhow", "cargo_metadata", "cfg-if", - "regex 1.10.5", + "regex", "rustc_version", "rustversion", "sysinfo", @@ -2686,7 +2679,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.68", "wasm-bindgen-shared", ] @@ -2720,7 +2713,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 700c83a5..21f2dce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,58 +1,6 @@ -[package] -name = "trunk-analytics-cli" -edition = "2021" -version = "0.0.0" - -[[bin]] -name = "trunk-analytics-cli" -path = "src/main.rs" - -[lib] -name = "trunk_analytics_cli" -path = "src/lib.rs" - -[dependencies] -anyhow = "1.0.44" -chrono = { version = "0.4.33", default-features = false, features = ["clock"] } -clap = { version = "4.4.18", features = ["derive", "env"] } -env_logger = { version = "0.11.0", default-features = false } -log = "0.4.14" -exitcode = "1.1.1" -tokio = { version = "*", default-features = false, features = [ - "rt-multi-thread", - "macros", -] } -tempfile = "3.2.0" -tokio-retry = { version = "0.3", default-features = false } -gix = { version = "0.63.0", default-features = false, features = [] } -glob = "0.3.0" -regex = { version = "1.10.3", default-features = false, features = ["std"] } -reqwest = { version = "0.12.5", default-features = false, features = [ - "rustls-tls-native-roots", - "stream", - "json", -] } -serde = { version = "1.0.130", default-features = false, features = ["derive"] } -serde_json = "1.0.68" -zstd = { version = "0.13.0", default-features = false } -tar = { version = "0.4.30", default-features = false } -junit-parser = "1.1.0" -codeowners = "0.1.3" -sentry = { version = "0.34.0", features = ["debug-images"] } -openssl = { version = "0.10.66", features = ["vendored"] } - -[dev-dependencies] -git2 = "0.19.0" # Used for creating test repos with libgit2 - -[build-dependencies] -vergen = { version = "8.3.1", features = [ - "build", - "cargo", - "git", - "gitcl", - "rustc", - "si", -] } +[workspace] +members = ["cli", "codeowners"] +resolver = "2" [profile.release] strip = true diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 00000000..22a0bcb8 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "trunk-analytics-cli" +edition = "2021" +version = "0.0.0" + +[[bin]] +name = "trunk-analytics-cli" +path = "src/main.rs" + +[lib] +name = "trunk_analytics_cli" +path = "src/lib.rs" + +[dependencies] +anyhow = "1.0.44" +chrono = { version = "0.4.33", default-features = false, features = ["clock"] } +clap = { version = "4.4.18", features = ["derive", "env"] } +env_logger = { version = "0.11.0", default-features = false } +log = "0.4.14" +exitcode = "1.1.1" +tokio = { version = "*", default-features = false, features = [ + "rt-multi-thread", + "macros", +] } +tempfile = "3.2.0" +tokio-retry = { version = "0.3", default-features = false } +gix = { version = "0.63.0", default-features = false, features = [] } +glob = "0.3.0" +regex = { version = "1.10.3", default-features = false, features = ["std"] } +reqwest = { version = "0.12.5", default-features = false, features = [ + "rustls-tls-native-roots", + "stream", + "json", +] } +serde = { version = "1.0.130", default-features = false, features = ["derive"] } +serde_json = "1.0.68" +zstd = { version = "0.13.0", default-features = false } +tar = { version = "0.4.30", default-features = false } +junit-parser = "1.1.0" +codeowners = { path = "../codeowners" } +sentry = { version = "0.34.0", features = ["debug-images"] } +openssl = { version = "0.10.66", features = ["vendored"] } + +[dev-dependencies] +git2 = "0.19.0" # Used for creating test repos with libgit2 + +[build-dependencies] +vergen = { version = "8.3.1", features = [ + "build", + "cargo", + "git", + "gitcl", + "rustc", + "si", +] } diff --git a/build.rs b/cli/build.rs similarity index 100% rename from build.rs rename to cli/build.rs diff --git a/src/bundler.rs b/cli/src/bundler.rs similarity index 100% rename from src/bundler.rs rename to cli/src/bundler.rs diff --git a/src/clients.rs b/cli/src/clients.rs similarity index 100% rename from src/clients.rs rename to cli/src/clients.rs diff --git a/src/constants.rs b/cli/src/constants.rs similarity index 99% rename from src/constants.rs rename to cli/src/constants.rs index 422d3389..e210e65c 100644 --- a/src/constants.rs +++ b/cli/src/constants.rs @@ -19,7 +19,7 @@ pub const EXIT_FAILURE: i32 = 1; pub const CODEOWNERS_LOCATIONS: &[&str] = &[".github", ".bitbucket", ".", "docs", ".gitlab"]; pub const TRUNK_PUBLIC_API_ADDRESS_ENV: &str = "TRUNK_PUBLIC_API_ADDRESS"; -pub const ENVS_TO_GET: &'static [&'static str] = &[ +pub const ENVS_TO_GET: &[&str] = &[ "CI", "GIT_BRANCH", "GIT_COMMIT", diff --git a/src/lib.rs b/cli/src/lib.rs similarity index 100% rename from src/lib.rs rename to cli/src/lib.rs diff --git a/src/main.rs b/cli/src/main.rs similarity index 100% rename from src/main.rs rename to cli/src/main.rs diff --git a/src/runner.rs b/cli/src/runner.rs similarity index 100% rename from src/runner.rs rename to cli/src/runner.rs diff --git a/src/scanner.rs b/cli/src/scanner.rs similarity index 97% rename from src/scanner.rs rename to cli/src/scanner.rs index c80606f8..d6eede99 100644 --- a/src/scanner.rs +++ b/cli/src/scanner.rs @@ -73,7 +73,18 @@ impl FileSet { } } } - let codeowners = codeowners_file.map(|path| codeowners::from_path(path.as_path())); + let codeowners = + codeowners_file.and_then(|path| match codeowners::from_path(path.as_path()) { + Ok(owners) => Some(owners), + Err(err) => { + log::error!( + "Found CODEOWNERS file `{}`, but couldn't parse it: {}", + path.to_string_lossy(), + err + ); + None + } + }); let mut files = Vec::new(); @@ -127,7 +138,7 @@ impl FileSet { .metadata()? .modified()? .duration_since(std::time::UNIX_EPOCH)? - .as_nanos() as u128, + .as_nanos(), owners, team: team.clone(), }); @@ -151,7 +162,7 @@ where U: AsRef, { let file = repo_root.as_ref().join(location).join(CODEOWNERS); - if file.exists() { + if file.is_file() { Some(file) } else { None @@ -201,7 +212,7 @@ impl BundleRepo { // Read git repo. log::info!("Reading git repo at {:?}", &repo_root); - let git_repo = gix::open(&repo_root)?; + let git_repo = gix::open(repo_root)?; let git_url = git_repo .config_snapshot() .string_by_key(GIT_REMOTE_ORIGIN_URL_CONFIG) diff --git a/src/types.rs b/cli/src/types.rs similarity index 100% rename from src/types.rs rename to cli/src/types.rs diff --git a/src/utils.rs b/cli/src/utils.rs similarity index 93% rename from src/utils.rs rename to cli/src/utils.rs index 46dc37e3..0c9c0959 100644 --- a/src/utils.rs +++ b/cli/src/utils.rs @@ -29,7 +29,7 @@ pub fn from_non_empty_or_default R>( from_non_empty: F, ) -> R { if let Some(s) = s { - if s.trim().len() > 0 { + if !s.trim().is_empty() { return from_non_empty(s); } } @@ -38,7 +38,7 @@ pub fn from_non_empty_or_default R>( pub fn parse_custom_tags(tags: &[String]) -> anyhow::Result> { let parsed = tags.iter() - .filter(|tag_str| tag_str.trim().len() > 0) + .filter(|tag_str| !tag_str.trim().is_empty()) .map(|tag_str| { let parts = tag_str.split('=').collect::>(); if parts.len() != 2 { @@ -52,7 +52,7 @@ pub fn parse_custom_tags(tags: &[String]) -> anyhow::Result> { let key = parts[0].trim().to_owned(); let value = parts[1].trim().to_owned(); - if key.len() == 0 || value.len() == 0 { + if key.is_empty() || value.is_empty() { return Err(anyhow::anyhow!( "Invalid custom tag format. Key/Value is empty: {:?}", tags @@ -72,5 +72,5 @@ pub fn parse_custom_tags(tags: &[String]) -> anyhow::Result> { }) .collect::>>()?; - return Ok(parsed); + Ok(parsed) } diff --git a/codeowners/Cargo.toml b/codeowners/Cargo.toml new file mode 100644 index 00000000..9ebc3472 --- /dev/null +++ b/codeowners/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "codeowners" +edition = "2018" +version = "0.1.3" + +[dev-dependencies] +pretty_assertions = "0.6" + +[dependencies] +glob = "0.3" +regex = "1.2" +lazy_static = "1.4" +anyhow = "1.0.86" diff --git a/codeowners/LICENSE b/codeowners/LICENSE new file mode 100644 index 00000000..a96ce706 --- /dev/null +++ b/codeowners/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2017-2019 Doug Tangren + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/codeowners/src/lib.rs b/codeowners/src/lib.rs new file mode 100644 index 00000000..9efd85d8 --- /dev/null +++ b/codeowners/src/lib.rs @@ -0,0 +1,455 @@ +//! Codeowners provides interfaces for resolving owners of paths within code +//! repositories using +//! Github [CODEOWNERS](https://help.github.com/articles/about-codeowners/) +//! files +//! +//! # Examples +//! +//! Typical use involves resolving a CODEOWNERS file, parsing it, +//! then querying target paths +//! +//! ```no_run +//! extern crate codeowners; +//! use std::env; +//! +//! fn main() { +//! if let (Some(owners_file), Some(path)) = +//! (env::args().nth(1), env::args().nth(2)) { +//! let owners = codeowners::from_path(owners_file).unwrap(); +//! match owners.of(&path) { +//! None => println!("{} is up for adoption", path), +//! Some(owners) => { +//! for owner in owners { +//! println!("{}", owner); +//! } +//! } +//! } +//! } +//! } +//! ``` +#![allow(missing_docs)] + +#[cfg(test)] +#[macro_use] +extern crate pretty_assertions; + +use glob::Pattern; +use lazy_static::lazy_static; +use regex::Regex; +use std::{ + fmt, + fs::File, + io::{BufRead, BufReader, Read}, + path::{Path, PathBuf}, + str::FromStr, +}; + +const CODEOWNERS: &str = "CODEOWNERS"; + +/// Various types of owners +/// +/// Owners supports parsing from strings as well as displaying as strings +/// +/// # Examples +/// +/// ```rust +/// let raw = "@org/team"; +/// assert_eq!( +/// raw.parse::().unwrap().to_string(), +/// raw +/// ); +/// ``` +#[derive(Debug, PartialEq)] +pub enum Owner { + /// Owner in the form @username + Username(String), + /// Owner in the form @org/Team + Team(String), + /// Owner in the form user@domain.com + 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 { + 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")) + } + } +} + +/// Mappings of owners to path patterns +#[derive(Debug, PartialEq)] +pub struct Owners { + paths: Vec<(Pattern, Vec)>, +} + +impl Owners { + /// Resolve a list of owners matching a given path + pub fn of

(&self, path: P) -> Option<&Vec> + where + P: AsRef, + { + self.paths + .iter() + .filter_map(|mapping| { + let (pattern, 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 { + // this pattern is only meant to match + // direct children + if pattern.as_str().ends_with("/*") { + return None; + } + // case of implied owned children + // foo/bar @owner should indicate that foo/bar/baz.rs is + // owned by @owner + let mut p = path.as_ref(); + while let Some(parent) = p.parent() { + if pattern.matches_path_with(parent, opts) { + return Some(owners); + } else { + p = parent; + } + } + None + } + }) + .next() + } +} + +/// Attempts to locate CODEOWNERS file based on common locations relative to +/// a given git repo +/// +/// # Examples +/// +/// ```rust +/// match codeowners::locate(".") { +/// Some(ownersfile) => { +/// println!( +/// "{:#?}", +/// codeowners::from_path(ownersfile) +/// ) +/// }, +/// _ => println!("failed to find CODEOWNERS file") +/// } +/// ``` +pub fn locate

(ctx: P) -> Option +where + P: AsRef, +{ + 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 + } +} + +/// Parse a CODEOWNERS file existing at a given path +pub fn from_path

(path: P) -> anyhow::Result +where + P: AsRef, +{ + crate::from_reader(File::open(path)?) +} + +/// Parse a CODEOWNERS file from some readable source +/// This format is defined in +/// [Github's documentation](https://help.github.com/articles/about-codeowners/) +/// The syntax is uses gitgnore +/// [patterns](https://www.kernel.org/pub/software/scm/git/docs/gitignore.html#_pattern_format) +/// followed by an identifier for an owner. More information can be found +/// [here](https://help.github.com/articles/about-codeowners/#codeowners-syntax) +pub fn from_reader(read: R) -> anyhow::Result +where + R: Read, +{ + let mut paths = BufReader::new(read) + .lines() + /* trunk-ignore(clippy/lines_filter_map_ok) */ + .filter_map(Result::ok) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .try_fold(Vec::new(), |mut paths, line| -> anyhow::Result<_> { + 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)) + } + Ok(paths) + })?; + // last match takes precedence + paths.reverse(); + Ok(Owners { paths }) +} + +fn pattern(path: &str) -> anyhow::Result { + // if pattern starts with anchor or explicit wild card, it should + // match any prefix + let prefixed = if path.starts_with('*') || path.starts_with('/') { + path.to_owned() + } else { + format!("**/{}", path) + }; + // if pattern starts with anchor it should only match paths + // relative to root + let mut normalized = prefixed.trim_start_matches('/').to_string(); + // if pattern ends with /, it should match children of that directory + if normalized.ends_with('/') { + normalized.push_str("**"); + } + Pattern::new(&normalized).map_err(anyhow::Error::msg) +} + +#[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::() == 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()).unwrap(); + 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()).unwrap(); + 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()).unwrap(); + 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()).unwrap(); + 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()).unwrap(); + // relative to root + 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())]) + ); + // not relative to root + 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()).unwrap(); + // docs anywhere + 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())]) + ); + // but not nested + 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()).unwrap(); + 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()).unwrap(); + // relative to root + 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()).unwrap(); + assert_eq!( + owners.of("foo/bar/baz.rs"), + Some(&vec![Owner::Username("@doug".into())]) + ) + } +}