diff --git a/Cargo.lock b/Cargo.lock index 9c1317d..57a82a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "codeowners" -version = "0.2.15" +version = "0.2.16" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7c3c47c..a11c487 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeowners" -version = "0.2.15" +version = "0.2.16" edition = "2024" [profile.release] diff --git a/README.md b/README.md index 17b2fd1..8e0b880 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ # Codeowners -**Codeowners** is a fast, Rust-based CLI for generating and validating [GitHub `CODEOWNERS` files](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) in large repositories. +**Codeowners** is a fast, Rust-based CLI for generating and validating [GitHub `CODEOWNERS` files](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) in large repositories. -Note: For Ruby application, it's usually easier to use `codeowners-rs` via the [code_ownership](https://github.com/rubyatscale/code_ownership) gem. +Note: For Ruby applications, it's usually easier to use `codeowners-rs` via the [code_ownership](https://github.com/rubyatscale/code_ownership) gem. ## 🚀 Quick Start: Generate & Validate -The most common workflow is to **generate and validate your CODEOWNERS file** in a single step: +The most common workflow is to generate and validate in one step: ```sh codeowners gv ``` -- This command will: - - Generate a fresh `CODEOWNERS` file (by default at `.github/CODEOWNERS`) - - Validate that all files are properly owned and that the file is up to date - - Exit with a nonzero code and detailed errors if validation fails +- Generates a fresh `CODEOWNERS` file (default: `.github/CODEOWNERS`) +- Validates ownership and that the file is up to date +- Exits non-zero and prints detailed errors if validation fails ## Table of Contents -- [Quick Start: Generate & Validate](#-quick-start-generate--validate) +- [Quick Start: Generate & Validate](#quick-start-generate--validate) +- [Installation](#installation) - [Getting Started](#getting-started) - [Declaring Ownership](#declaring-ownership) - [Directory-Based Ownership](#1-directory-based-ownership) @@ -27,25 +27,37 @@ codeowners gv - [Package-Based Ownership](#3-package-based-ownership) - [Glob-Based Ownership](#4-glob-based-ownership) - [JavaScript Package Ownership](#5-javascript-package-ownership) -- [Other CLI Commands](#other-cli-commands) +- [CLI Reference](#cli-reference) + - [Global Flags](#global-flags) + - [Commands](#commands) - [Examples](#examples) - - [Find the owner of a file](#find-the-owner-of-a-file) - - [Ownership report for a team](#ownership-report-for-a-team) +- [Configuration](#configuration) +- [Cache](#cache) - [Validation](#validation) +- [Library Usage](#library-usage) - [Development](#development) -- [Configuration](#configuration) -## Getting Started +## Installation + +You can run `codeowners` without installing a platform-specific binary by using DotSlash, or install from source with Cargo. + +### Option A: DotSlash (recommended) -1. **Install DotSlash** - [Install DotSlash](https://dotslash-cli.com/docs/installation/) - Releases include a DotSlash text file that will automatically download and run the correct binary for your system. +1. Install DotSlash: see [https://dotslash-cli.com/docs/installation/](https://dotslash-cli.com/docs/installation/) +2. Download the latest DotSlash text file from a release, for example [https://github.com/rubyatscale/codeowners-rs/releases](https://github.com/rubyatscale/codeowners-rs/releases). +3. Execute the downloaded file with DotSlash; it will fetch and run the correct binary. -2. **Download the Latest DotSlash Text File** - Releases contain a DotSlash text file. Example: [codeowners release v0.2.4](https://github.com/rubyatscale/codeowners-rs/releases/download/v0.2.4/codeowners). - Running this file with DotSlash installed will execute `codeowners`. +### Option B: From source with Cargo -3. **Configure Ownership** +Requires Rust toolchain. + +```sh +cargo install --git https://github.com/rubyatscale/codeowners-rs codeowners +``` + +## Getting Started + +1. **Configure Ownership** Create a `config/code_ownership.yml` file. Example: ```yaml @@ -58,7 +70,7 @@ codeowners gv - frontend/javascripts/**/__generated__/**/* ``` -4. **Declare Teams** +2. **Declare Teams** Example: `config/teams/operations.yml` ```yaml @@ -67,7 +79,7 @@ codeowners gv team: '@my-org/operations-team' ``` -5. **Run the Main Workflow** +3. **Run the Main Workflow** ```sh codeowners gv @@ -92,7 +104,15 @@ Add an annotation at the top of a file: ```ruby # @team MyTeam ``` - +```typescript +// @team MyTeam +``` +```html + +``` +```erb +<%# @team: Foo %> +``` ### 3. Package-Based Ownership In `package.yml` (for Ruby Packwerk): @@ -133,29 +153,27 @@ js_package_paths: - frontend/javascripts/packages/* ``` +## CLI Reference -## Other CLI Commands +### Global Flags -While `codeowners gv` is the main workflow, the CLI also supports: +- `--codeowners-file-path `: Path for the CODEOWNERS file. Default: `./.github/CODEOWNERS` +- `--config-path `: Path to `code_ownership.yml`. Default: `./config/code_ownership.yml` +- `--project-root `: Project root. Default: `.` +- `--no-cache`: Disable on-disk caching (useful in CI) +- `-V, --version`, `-h, --help` -```text -Usage: codeowners [OPTIONS] - -Commands: - for-file Finds the owner of a given file. [aliases: f] - for-team Finds code ownership information for a given team [aliases: t] - generate Generate the CODEOWNERS file [aliases: g] - validate Validate the CODEOWNERS file [aliases: v] - generate-and-validate Chains both `generate` and `validate` [aliases: gv] - help Print this message or the help of the given subcommand(s) +### Commands -Options: - --codeowners-file-path [default: ./.github/CODEOWNERS] - --config-path [default: ./config/code_ownership.yml] - --project-root [default: .] - -h, --help - -V, --version -``` +- `generate` (`g`): Generate the CODEOWNERS file and write it to `--codeowners-file-path`. + - Flags: `--skip-stage, -s` to avoid `git add` after writing +- `validate` (`v`): Validate the CODEOWNERS file and configuration. +- `generate-and-validate` (`gv`): Run `generate` then `validate`. + - Flags: `--skip-stage, -s` +- `for-file ` (`f`): Print the owner of a file. + - Flags: `--from-codeowners` to resolve using only the CODEOWNERS rules +- `for-team ` (`t`): Print ownership report for a team. +- `delete-cache` (`d`): Delete the persisted cache. ### Examples @@ -171,14 +189,83 @@ codeowners for-file path/to/file.rb codeowners for-team Payroll ``` +#### Generate but do not stage the file + +```sh +codeowners generate --skip-stage +``` + +#### Run without using the cache + +```sh +codeowners gv --no-cache +``` + +## Configuration + +`config/code_ownership.yml` keys and defaults: + +- `owned_globs` (required): Glob patterns that must be owned. +- `ruby_package_paths` (default: `['packs/**/*', 'components/**']`) +- `js_package_paths` / `javascript_package_paths` (default: `['frontend/**/*']`) +- `team_file_glob` (default: `['config/teams/**/*.yml']`) +- `unowned_globs` (default: `['frontend/**/node_modules/**/*', 'frontend/**/__generated__/**/*']`) +- `vendored_gems_path` (default: `'vendored/'`) +- `cache_directory` (default: `'tmp/cache/codeowners'`) +- `ignore_dirs` (default includes: `.git`, `node_modules`, `tmp`, etc.) + +See examples in `tests/fixtures/**/config/` for reference setups. + +## Cache + +By default, cache is stored under `tmp/cache/codeowners` relative to the project root. This speeds up repeated runs. + +- Disable cache for a run: add the global flag `--no-cache` +- Clear all cache: `codeowners delete-cache` + ## Validation `codeowners validate` (or `codeowners gv`) ensures: 1. Only one mechanism defines ownership for any file. 2. All referenced teams are valid. -3. All files in `owned_globs` are owned, unless in `unowned_globs`. -4. The `CODEOWNERS` file is up to date. +3. All files in `owned_globs` are owned, unless matched by `unowned_globs`. +4. The generated `CODEOWNERS` file is up to date. + +Exit status is non-zero on errors. + +## Library Usage + +Import public APIs from `codeowners::runner::*`. + +```rust +use codeowners::runner::{RunConfig, for_file, teams_for_files_from_codeowners}; + +fn main() { + let run_config = RunConfig { + project_root: std::path::PathBuf::from("."), + codeowners_file_path: std::path::PathBuf::from(".github/CODEOWNERS"), + config_path: std::path::PathBuf::from("config/code_ownership.yml"), + no_cache: true, // set false to enable on-disk caching + }; + + // Find owner for a single file using the optimized path (not just CODEOWNERS) + let result = for_file(&run_config, "app/models/user.rb", false); + for msg in result.info_messages { println!("{}", msg); } + for err in result.io_errors { eprintln!("io: {}", err); } + for err in result.validation_errors { eprintln!("validation: {}", err); } + + // Map multiple files to teams using CODEOWNERS rules only + let files = vec![ + "app/models/user.rb".to_string(), + "config/teams/payroll.yml".to_string(), + ]; + match teams_for_files_from_codeowners(&run_config, &files) { + Ok(map) => println!("{:?}", map), + Err(e) => eprintln!("error: {}", e), + } +} +``` ## Development @@ -190,3 +277,11 @@ codeowners for-team Payroll ``` - Please update `CHANGELOG.md` and this `README.md` when making changes. + +### Module layout + +- `src/runner.rs`: public façade re-exporting the API and types. +- `src/runner/api.rs`: externally available functions used by the CLI and other crates. +- `src/runner/types.rs`: `RunConfig`, `RunResult`, and runner `Error`. +- `src/ownership/`: all ownership logic (parsing, mapping, validation, generation). +- `src/ownership/codeowners_query.rs`: CODEOWNERS-only queries consumed by the façade. diff --git a/src/config.rs b/src/config.rs index aa087df..893cd06 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use std::{fs::File, path::Path}; #[derive(Deserialize, Debug, Clone)] pub struct Config { @@ -77,6 +78,13 @@ fn default_ignore_dirs() -> Vec { ] } +impl Config { + pub fn load_from_path(path: &Path) -> std::result::Result { + let file = File::open(path).map_err(|e| format!("Can't open config file: {} ({})", path.to_string_lossy(), e))?; + serde_yaml::from_reader(file).map_err(|e| format!("Can't parse config file: {} ({})", path.to_string_lossy(), e)) + } +} + #[cfg(test)] mod tests { use std::{ diff --git a/src/crosscheck.rs b/src/crosscheck.rs index 02eba9b..b8e9405 100644 --- a/src/crosscheck.rs +++ b/src/crosscheck.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::{ cache::Cache, config::Config, - ownership::for_file_fast::find_file_owners, + ownership::file_owner_resolver::find_file_owners, project::Project, project_builder::ProjectBuilder, runner::{RunConfig, RunResult, config_from_path, team_for_file_from_codeowners}, diff --git a/src/lib.rs b/src/lib.rs index e5e2cec..13d53a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub(crate) mod common_test; pub mod config; pub mod crosscheck; pub mod ownership; +pub mod path_utils; pub(crate) mod project; pub mod project_builder; pub mod project_file_builder; diff --git a/src/ownership.rs b/src/ownership.rs index 88516f8..8f109ef 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -10,9 +10,10 @@ use std::{ use tracing::{info, instrument}; pub(crate) mod codeowners_file_parser; +pub(crate) mod codeowners_query; mod file_generator; mod file_owner_finder; -pub mod for_file_fast; +pub mod file_owner_resolver; pub(crate) mod mapper; mod validator; diff --git a/src/ownership/codeowners_query.rs b/src/ownership/codeowners_query.rs new file mode 100644 index 0000000..70846e4 --- /dev/null +++ b/src/ownership/codeowners_query.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::ownership::codeowners_file_parser::Parser; +use crate::project::Team; + +pub(crate) fn team_for_file_from_codeowners( + project_root: &Path, + codeowners_file_path: &Path, + team_file_globs: &[String], + file_path: &Path, +) -> Result, String> { + let relative_file_path = if file_path.is_absolute() { + crate::path_utils::relative_to_buf(project_root, file_path) + } else { + PathBuf::from(file_path) + }; + + let parser = Parser { + codeowners_file_path: codeowners_file_path.to_path_buf(), + project_root: project_root.to_path_buf(), + team_file_globs: team_file_globs.to_vec(), + }; + + parser.team_from_file_path(&relative_file_path).map_err(|e| e.to_string()) +} + +pub(crate) fn teams_for_files_from_codeowners( + project_root: &Path, + codeowners_file_path: &Path, + team_file_globs: &[String], + file_paths: &[String], +) -> Result>, String> { + let relative_file_paths: Vec = file_paths + .iter() + .map(Path::new) + .map(|path| { + if path.is_absolute() { + crate::path_utils::relative_to_buf(project_root, path) + } else { + path.to_path_buf() + } + }) + .collect(); + + let parser = Parser { + codeowners_file_path: codeowners_file_path.to_path_buf(), + project_root: project_root.to_path_buf(), + team_file_globs: team_file_globs.to_vec(), + }; + + parser.teams_from_files_paths(&relative_file_paths).map_err(|e| e.to_string()) +} diff --git a/src/ownership/for_file_fast.rs b/src/ownership/file_owner_resolver.rs similarity index 95% rename from src/ownership/for_file_fast.rs rename to src/ownership/file_owner_resolver.rs index c5fe908..cac884a 100644 --- a/src/ownership/for_file_fast.rs +++ b/src/ownership/file_owner_resolver.rs @@ -17,10 +17,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) } else { project_root.join(file_path) }; - let relative_file_path = absolute_file_path - .strip_prefix(project_root) - .unwrap_or(&absolute_file_path) - .to_path_buf(); + let relative_file_path = crate::path_utils::relative_to_buf(project_root, &absolute_file_path); let teams = load_teams(project_root, &config.team_file_glob)?; let teams_by_name = build_teams_by_name_map(&teams); @@ -68,7 +65,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) } for team in &teams { - let team_rel = team.path.strip_prefix(project_root).unwrap_or(&team.path).to_path_buf(); + let team_rel = crate::path_utils::relative_to_buf(project_root, &team.path); if team_rel == relative_file_path { sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamYml); } @@ -77,10 +74,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) let mut file_owners: Vec = Vec::new(); for (team_name, sources) in sources_by_team.into_iter() { if let Some(team) = teams_by_name.get(&team_name) { - let relative_team_yml_path = team - .path - .strip_prefix(project_root) - .unwrap_or(&team.path) + let relative_team_yml_path = crate::path_utils::relative_to(project_root, &team.path) .to_string_lossy() .to_string(); file_owners.push(FileOwner { @@ -131,8 +125,6 @@ fn load_teams(project_root: &Path, team_file_globs: &[String]) -> std::result::R Ok(teams) } -// no regex: parse cheaply with ASCII-aware checks - fn read_top_of_file_team(path: &Path) -> Option { let project_file = build_project_file_without_cache(&path.to_path_buf()); if let Some(owner) = project_file.owner { @@ -157,9 +149,7 @@ fn most_specific_directory_owner( if let Ok(owner_str) = fs::read_to_string(&codeowner_path) { let owner = owner_str.trim(); if let Some(team) = teams_by_name.get(owner) { - let relative_dir = current - .strip_prefix(project_root) - .unwrap_or(current.as_path()) + let relative_dir = crate::path_utils::relative_to(project_root, current.as_path()) .to_string_lossy() .to_string(); let candidate = (team.name.clone(), Source::Directory(relative_dir)); @@ -191,7 +181,7 @@ fn nearest_package_owner( if !current.pop() { break; } - let parent_rel = current.strip_prefix(project_root).unwrap_or(current.as_path()); + let parent_rel = crate::path_utils::relative_to(project_root, current.as_path()); if let Some(rel_str) = parent_rel.to_str() { if glob_list_matches(rel_str, &config.ruby_package_paths) { let pkg_yml = current.join("package.yml"); diff --git a/src/ownership/mapper.rs b/src/ownership/mapper.rs index b36a492..d04c4ee 100644 --- a/src/ownership/mapper.rs +++ b/src/ownership/mapper.rs @@ -5,18 +5,18 @@ use std::{ path::{Path, PathBuf}, }; +mod annotated_file_mapper; pub(crate) mod directory_mapper; mod escaper; mod package_mapper; -mod team_file_mapper; mod team_gem_mapper; mod team_glob_mapper; mod team_yml_mapper; +pub use annotated_file_mapper::TeamFileMapper; pub use directory_mapper::DirectoryMapper; pub use package_mapper::JavascriptPackageMapper; pub use package_mapper::RubyPackageMapper; -pub use team_file_mapper::TeamFileMapper; pub use team_gem_mapper::TeamGemMapper; pub use team_glob_mapper::TeamGlobMapper; pub use team_yml_mapper::TeamYmlMapper; diff --git a/src/ownership/mapper/team_file_mapper.rs b/src/ownership/mapper/annotated_file_mapper.rs similarity index 100% rename from src/ownership/mapper/team_file_mapper.rs rename to src/ownership/mapper/annotated_file_mapper.rs diff --git a/src/path_utils.rs b/src/path_utils.rs new file mode 100644 index 0000000..230b1d1 --- /dev/null +++ b/src/path_utils.rs @@ -0,0 +1,49 @@ +use std::path::{Path, PathBuf}; + +/// Return `path` relative to `root` if possible; otherwise return `path` unchanged. +pub fn relative_to<'a>(root: &'a Path, path: &'a Path) -> &'a Path { + path.strip_prefix(root).unwrap_or(path) +} + +/// Like `relative_to`, but returns an owned `PathBuf`. +pub fn relative_to_buf(root: &Path, path: &Path) -> PathBuf { + relative_to(root, path).to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_to_returns_relative_when_under_root() { + let root = Path::new("/a/b"); + let path = Path::new("/a/b/c/d.txt"); + let rel = relative_to(root, path); + assert_eq!(rel, Path::new("c/d.txt")); + } + + #[test] + fn relative_to_returns_input_when_not_under_root() { + let root = Path::new("/a/b"); + let path = Path::new("/x/y/z.txt"); + let rel = relative_to(root, path); + assert_eq!(rel, path); + } + + #[test] + fn relative_to_handles_equal_paths() { + let root = Path::new("/a/b"); + let path = Path::new("/a/b"); + let rel = relative_to(root, path); + assert_eq!(rel, Path::new("")); + } + + #[test] + fn relative_to_buf_matches_relative_to() { + let root = Path::new("/proj"); + let path = Path::new("/proj/src/lib.rs"); + let rel_ref = relative_to(root, path); + let rel_buf = relative_to_buf(root, path); + assert_eq!(rel_ref, rel_buf.as_path()); + } +} diff --git a/src/project_file_builder.rs b/src/project_file_builder.rs index fc69cc3..712f8fb 100644 --- a/src/project_file_builder.rs +++ b/src/project_file_builder.rs @@ -77,50 +77,31 @@ pub(crate) fn build_project_file_without_cache(path: &PathBuf) -> ProjectFile { #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; + type FirstLine = &'static str; + type Owner = &'static str; #[test] fn test_team_regex() { - let owner = TEAM_REGEX - .captures("// @team Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("// @team Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("// @team: Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("# @team: Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("<%# @team: Foo %>") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); + let mut map: HashMap = HashMap::new(); + map.insert("// @team Foo", "Foo"); + map.insert("// @team Foo Bar", "Foo Bar"); + map.insert("// @team Zoo", "Zoo"); + map.insert("// @team: Zoo Foo", "Zoo Foo"); + map.insert("# @team: Bap", "Bap"); + map.insert("# @team: Bap Hap", "Bap Hap"); + map.insert("", "Zoink"); + map.insert("", "Zoink Err"); + map.insert("<%# @team: Zap %>", "Zap"); + map.insert("<%# @team: Zap Zip%>", "Zap Zip"); + map.insert("", "Blast"); + map.insert("", "Blast Off"); + + for (key, value) in map { + let owner = TEAM_REGEX.captures(key).and_then(|cap| cap.get(1)).map(|m| m.as_str()); + assert_eq!(owner, Some(value)); + } } } diff --git a/src/runner.rs b/src/runner.rs index 114ab04..0aef0c3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,92 +1,33 @@ -use core::fmt; -use std::{ - collections::HashMap, - fs::File, - path::{Path, PathBuf}, - process::Command, -}; +use std::{path::Path, process::Command}; -use error_stack::{Context, Result, ResultExt}; -use serde::{Deserialize, Serialize}; +use error_stack::{Result, ResultExt}; use crate::{ cache::{Cache, Caching, file::GlobalCache, noop::NoopCache}, config::Config, ownership::{FileOwner, Ownership}, - project::Team, project_builder::ProjectBuilder, }; -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct RunResult { - pub validation_errors: Vec, - pub io_errors: Vec, - pub info_messages: Vec, -} -#[derive(Debug, Clone)] -pub struct RunConfig { - pub project_root: PathBuf, - pub codeowners_file_path: PathBuf, - pub config_path: PathBuf, - pub no_cache: bool, -} +mod types; +pub use self::types::{Error, RunConfig, RunResult}; +mod api; +pub use self::api::*; pub struct Runner { run_config: RunConfig, ownership: Ownership, cache: Cache, -} - -pub fn for_file(run_config: &RunConfig, file_path: &str, from_codeowners: bool) -> RunResult { - if from_codeowners { - return for_file_codeowners_only(run_config, file_path); - } - for_file_optimized(run_config, file_path) -} - -pub fn file_owner_for_file(run_config: &RunConfig, file_path: &str) -> Result, Error> { - let config = config_from_path(&run_config.config_path)?; - use crate::ownership::for_file_fast::find_file_owners; - let owners = find_file_owners(&run_config.project_root, &config, std::path::Path::new(file_path)).map_err(Error::Io)?; - Ok(owners.first().cloned()) -} - -pub fn team_for_file(run_config: &RunConfig, file_path: &str) -> Result, Error> { - let owner = file_owner_for_file(run_config, file_path)?; - Ok(owner.map(|fo| fo.team.clone())) + config: Config, } pub fn version() -> String { env!("CARGO_PKG_VERSION").to_string() } -pub fn for_team(run_config: &RunConfig, team_name: &str) -> RunResult { - run_with_runner(run_config, |runner| runner.for_team(team_name)) -} - -pub fn validate(run_config: &RunConfig, _file_paths: Vec) -> RunResult { - run_with_runner(run_config, |runner| runner.validate()) -} - -pub fn generate(run_config: &RunConfig, git_stage: bool) -> RunResult { - run_with_runner(run_config, |runner| runner.generate(git_stage)) -} - -pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec, git_stage: bool) -> RunResult { - run_with_runner(run_config, |runner| runner.generate_and_validate(git_stage)) -} - -pub fn delete_cache(run_config: &RunConfig) -> RunResult { - run_with_runner(run_config, |runner| runner.delete_cache()) -} - -pub fn crosscheck_owners(run_config: &RunConfig) -> RunResult { - run_with_runner(run_config, |runner| runner.crosscheck_owners()) -} - pub type Runnable = fn(Runner) -> RunResult; -pub fn run_with_runner(run_config: &RunConfig, runnable: F) -> RunResult +pub fn run(run_config: &RunConfig, runnable: F) -> RunResult where F: FnOnce(Runner) -> RunResult, { @@ -102,35 +43,12 @@ where runnable(runner) } -impl RunResult { - pub fn has_errors(&self) -> bool { - !self.validation_errors.is_empty() || !self.io_errors.is_empty() +pub(crate) fn config_from_path(path: &Path) -> Result { + match crate::config::Config::load_from_path(path) { + Ok(c) => Ok(c), + Err(msg) => Err(error_stack::Report::new(Error::Io(msg))), } } - -#[derive(Debug)] -pub enum Error { - Io(String), - ValidationFailed, -} - -impl Context for Error {} -impl fmt::Display for Error { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(msg) => fmt.write_str(msg), - Error::ValidationFailed => fmt.write_str("Error::ValidationFailed"), - } - } -} - -pub(crate) fn config_from_path(path: &PathBuf) -> Result { - let config_file = File::open(path) - .change_context(Error::Io(format!("Can't open config file: {}", &path.to_string_lossy()))) - .attach_printable(format!("Can't open config file: {}", &path.to_string_lossy()))?; - - serde_yaml::from_reader(config_file).change_context(Error::Io(format!("Can't parse config file: {}", &path.to_string_lossy()))) -} impl Runner { pub fn new(run_config: &RunConfig) -> Result { let config = config_from_path(&run_config.config_path)?; @@ -168,6 +86,7 @@ impl Runner { run_config: run_config.clone(), ownership, cache, + config, }) } @@ -255,223 +174,75 @@ impl Runner { pub fn crosscheck_owners(&self) -> RunResult { crate::crosscheck::crosscheck_owners(&self.run_config, &self.cache) } -} -fn for_file_codeowners_only(run_config: &RunConfig, file_path: &str) -> RunResult { - match team_for_file_from_codeowners(run_config, file_path) { - Ok(Some(team)) => { - let relative_team_path = team - .path - .strip_prefix(&run_config.project_root) - .unwrap_or(team.path.as_path()) - .to_string_lossy() - .to_string(); - RunResult { - info_messages: vec![format!( - "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:\n- Owner inferred from codeowners file", - team.name, team.github_team, relative_team_path - )], - ..Default::default() - } - } - Ok(None) => RunResult::default(), - Err(err) => RunResult { - io_errors: vec![err.to_string()], - ..Default::default() - }, + pub fn owners_for_file(&self, file_path: &str) -> Result, Error> { + use crate::ownership::file_owner_resolver::find_file_owners; + let owners = find_file_owners(&self.run_config.project_root, &self.config, std::path::Path::new(file_path)).map_err(Error::Io)?; + Ok(owners) } -} - -// For an array of file paths, return a map of file path to its owning team -pub fn teams_for_files_from_codeowners(run_config: &RunConfig, file_paths: &[String]) -> Result>, Error> { - let relative_file_paths: Vec = file_paths - .iter() - .map(|path| Path::new(path).strip_prefix(&run_config.project_root).unwrap_or(Path::new(path))) - .map(|path| path.to_path_buf()) - .collect(); - - let parser = build_codeowners_parser(run_config)?; - Ok(parser - .teams_from_files_paths(&relative_file_paths) - .map_err(|e| Error::Io(e.to_string()))?) -} - -fn build_codeowners_parser(run_config: &RunConfig) -> Result { - let config = config_from_path(&run_config.config_path)?; - Ok(crate::ownership::codeowners_file_parser::Parser { - codeowners_file_path: run_config.codeowners_file_path.clone(), - project_root: run_config.project_root.clone(), - team_file_globs: config.team_file_glob.clone(), - }) -} -pub fn team_for_file_from_codeowners(run_config: &RunConfig, file_path: &str) -> Result, Error> { - let relative_file_path = Path::new(file_path) - .strip_prefix(&run_config.project_root) - .unwrap_or(Path::new(file_path)); - - let parser = build_codeowners_parser(run_config)?; - Ok(parser - .team_from_file_path(Path::new(relative_file_path)) - .map_err(|e| Error::Io(e.to_string()))?) -} - -fn for_file_optimized(run_config: &RunConfig, file_path: &str) -> RunResult { - let config = match config_from_path(&run_config.config_path) { - Ok(c) => c, - Err(err) => { - return RunResult { - io_errors: vec![err.to_string()], - ..Default::default() - }; - } - }; + pub fn for_file_optimized(&self, file_path: &str) -> RunResult { + let file_owners = match self.owners_for_file(file_path) { + Ok(v) => v, + Err(err) => { + return RunResult { + io_errors: vec![err.to_string()], + ..Default::default() + }; + } + }; - use crate::ownership::for_file_fast::find_file_owners; - let file_owners = match find_file_owners(&run_config.project_root, &config, std::path::Path::new(file_path)) { - Ok(v) => v, - Err(err) => { - return RunResult { - io_errors: vec![err], - ..Default::default() - }; + let info_messages: Vec = match file_owners.len() { + 0 => vec![format!("{}", FileOwner::default())], + 1 => vec![format!("{}", file_owners[0])], + _ => { + let mut error_messages = vec!["Error: file is owned by multiple teams!".to_string()]; + for file_owner in file_owners { + error_messages.push(format!("\n{}", file_owner)); + } + return RunResult { + validation_errors: error_messages, + ..Default::default() + }; + } + }; + RunResult { + info_messages, + ..Default::default() } - }; + } - let info_messages: Vec = match file_owners.len() { - 0 => vec![format!("{}", FileOwner::default())], - 1 => vec![format!("{}", file_owners[0])], - _ => { - let mut error_messages = vec!["Error: file is owned by multiple teams!".to_string()]; - for file_owner in file_owners { - error_messages.push(format!("\n{}", file_owner)); + pub fn for_file_codeowners_only(&self, file_path: &str) -> RunResult { + match team_for_file_from_codeowners(&self.run_config, file_path) { + Ok(Some(team)) => { + let relative_team_path = crate::path_utils::relative_to(&self.run_config.project_root, team.path.as_path()) + .to_string_lossy() + .to_string(); + RunResult { + info_messages: vec![format!( + "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:\n- Owner inferred from codeowners file", + team.name, team.github_team, relative_team_path + )], + ..Default::default() + } } - return RunResult { - validation_errors: error_messages, + Ok(None) => RunResult::default(), + Err(err) => RunResult { + io_errors: vec![err.to_string()], ..Default::default() - }; + }, } - }; - RunResult { - info_messages, - ..Default::default() } } +// removed free functions for for_file_* variants in favor of Runner methods + #[cfg(test)] mod tests { - use tempfile::tempdir; - use super::*; - use crate::{common_test, ownership::mapper::Source}; #[test] fn test_version() { assert_eq!(version(), env!("CARGO_PKG_VERSION").to_string()); } - fn write_file(temp_dir: &Path, file_path: &str, content: &str) { - let file_path = temp_dir.join(file_path); - let _ = std::fs::create_dir_all(file_path.parent().unwrap()); - std::fs::write(file_path, content).unwrap(); - } - - #[test] - fn test_file_owners_for_file() { - let temp_dir = tempdir().unwrap(); - write_file( - temp_dir.path(), - "config/code_ownership.yml", - common_test::tests::DEFAULT_CODE_OWNERSHIP_YML, - ); - ["a", "b", "c"].iter().for_each(|name| { - let team_yml = format!("name: {}\ngithub:\n team: \"@{}\"\n members:\n - {}member\n", name, name, name); - write_file(temp_dir.path(), &format!("config/teams/{}.yml", name), &team_yml); - }); - write_file( - temp_dir.path(), - "app/consumers/deep/nesting/nestdir/deep_file.rb", - "# @team b\nclass DeepFile end;", - ); - - let run_config = RunConfig { - project_root: temp_dir.path().to_path_buf(), - codeowners_file_path: temp_dir.path().join(".github/CODEOWNERS").to_path_buf(), - config_path: temp_dir.path().join("config/code_ownership.yml").to_path_buf(), - no_cache: false, - }; - - let file_owner = file_owner_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb") - .unwrap() - .unwrap(); - assert_eq!(file_owner.team.name, "b"); - assert_eq!(file_owner.team.github_team, "@b"); - assert!(file_owner.team.path.to_string_lossy().ends_with("config/teams/b.yml")); - assert_eq!(file_owner.sources.len(), 1); - assert_eq!(file_owner.sources, vec![Source::AnnotatedFile]); - - let team = team_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb") - .unwrap() - .unwrap(); - assert_eq!(team.name, "b"); - assert_eq!(team.github_team, "@b"); - assert!(team.path.to_string_lossy().ends_with("config/teams/b.yml")); - } - - #[test] - fn test_teams_for_files_from_codeowners() { - let project_root = Path::new("tests/fixtures/valid_project"); - let file_paths = [ - "javascript/packages/items/item.ts", - "config/teams/payroll.yml", - "ruby/app/models/bank_account.rb", - "made/up/file.rb", - "ruby/ignored_files/git_ignored.rb", - ]; - let run_config = RunConfig { - project_root: project_root.to_path_buf(), - codeowners_file_path: project_root.join(".github/CODEOWNERS").to_path_buf(), - config_path: project_root.join("config/code_ownership.yml").to_path_buf(), - no_cache: false, - }; - let teams = - teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); - assert_eq!(teams.len(), 5); - assert_eq!( - teams - .get("javascript/packages/items/item.ts") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - Some("Payroll") - ); - assert_eq!( - teams.get("config/teams/payroll.yml").unwrap().as_ref().map(|t| t.name.as_str()), - Some("Payroll") - ); - assert_eq!( - teams - .get("ruby/app/models/bank_account.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - Some("Payments") - ); - assert_eq!(teams.get("made/up/file.rb").unwrap().as_ref().map(|t| t.name.as_str()), None); - assert_eq!( - teams - .get("ruby/ignored_files/git_ignored.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - None - ); - assert_eq!( - teams - .get("ruby/ignored_files/git_ignored.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - None - ); - } } diff --git a/src/runner/api.rs b/src/runner/api.rs new file mode 100644 index 0000000..ea5fcaa --- /dev/null +++ b/src/runner/api.rs @@ -0,0 +1,83 @@ +use std::collections::HashMap; +use std::path::Path; + +use crate::ownership::FileOwner; +use crate::project::Team; + +use super::{Error, RunConfig, RunResult, Runner, config_from_path, run}; + +pub fn for_file(run_config: &RunConfig, file_path: &str, from_codeowners: bool) -> RunResult { + run(run_config, |runner| { + if from_codeowners { + runner.for_file_codeowners_only(file_path) + } else { + runner.for_file_optimized(file_path) + } + }) +} + +pub fn for_team(run_config: &RunConfig, team_name: &str) -> RunResult { + run(run_config, |runner| runner.for_team(team_name)) +} + +pub fn validate(run_config: &RunConfig, _file_paths: Vec) -> RunResult { + run(run_config, |runner| runner.validate()) +} + +pub fn generate(run_config: &RunConfig, git_stage: bool) -> RunResult { + run(run_config, |runner| runner.generate(git_stage)) +} + +pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec, git_stage: bool) -> RunResult { + run(run_config, |runner| runner.generate_and_validate(git_stage)) +} + +pub fn delete_cache(run_config: &RunConfig) -> RunResult { + run(run_config, |runner| runner.delete_cache()) +} + +pub fn crosscheck_owners(run_config: &RunConfig) -> RunResult { + run(run_config, |runner| runner.crosscheck_owners()) +} + +// Returns the highest priority owner for a file. More to come here. +pub fn file_owner_for_file(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let runner = Runner::new(run_config)?; + let owners = runner.owners_for_file(file_path)?; + Ok(owners.first().cloned()) +} + +pub fn team_for_file(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let owner = file_owner_for_file(run_config, file_path)?; + Ok(owner.map(|fo| fo.team.clone())) +} + +// For an array of file paths, return a map of file path to its owning team +pub fn teams_for_files_from_codeowners( + run_config: &RunConfig, + file_paths: &[String], +) -> error_stack::Result>, Error> { + let config = config_from_path(&run_config.config_path)?; + let res = crate::ownership::codeowners_query::teams_for_files_from_codeowners( + &run_config.project_root, + &run_config.codeowners_file_path, + &config.team_file_glob, + file_paths, + ) + .map_err(Error::Io)?; + Ok(res) +} + +pub fn team_for_file_from_codeowners(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let relative_file_path = crate::path_utils::relative_to(&run_config.project_root, Path::new(file_path)); + + let config = config_from_path(&run_config.config_path)?; + let res = crate::ownership::codeowners_query::team_for_file_from_codeowners( + &run_config.project_root, + &run_config.codeowners_file_path, + &config.team_file_glob, + Path::new(relative_file_path), + ) + .map_err(Error::Io)?; + Ok(res) +} diff --git a/src/runner/types.rs b/src/runner/types.rs new file mode 100644 index 0000000..5ba38ea --- /dev/null +++ b/src/runner/types.rs @@ -0,0 +1,43 @@ +use core::fmt; +use std::path::PathBuf; + +use error_stack::Context; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct RunResult { + pub validation_errors: Vec, + pub io_errors: Vec, + pub info_messages: Vec, +} + +impl RunResult { + pub fn has_errors(&self) -> bool { + !self.validation_errors.is_empty() || !self.io_errors.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct RunConfig { + pub project_root: PathBuf, + pub codeowners_file_path: PathBuf, + pub config_path: PathBuf, + pub no_cache: bool, +} + +#[derive(Debug)] +pub enum Error { + Io(String), + ValidationFailed, +} + +impl Context for Error {} + +impl fmt::Display for Error { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(msg) => fmt.write_str(msg), + Error::ValidationFailed => fmt.write_str("Error::ValidationFailed"), + } + } +} diff --git a/tests/fixtures/javascript_only_project/.github/CODEOWNERS b/tests/fixtures/javascript_only_project/.github/CODEOWNERS new file mode 100644 index 0000000..b589987 --- /dev/null +++ b/tests/fixtures/javascript_only_project/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# STOP! - DO NOT EDIT THIS FILE MANUALLY +# This file was automatically generated by "bin/codeownership validate". +# +# CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub +# teams. This is useful when developers create Pull Requests since the +# code/file owner is notified. Reference GitHub docs for more details: +# https://help.github.com/en/articles/about-code-owners + + diff --git a/tests/fixtures/javascript_only_project/.keep b/tests/fixtures/javascript_only_project/.keep new file mode 100644 index 0000000..92371a0 --- /dev/null +++ b/tests/fixtures/javascript_only_project/.keep @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/javascript_only_project/config/code_ownership.yml b/tests/fixtures/javascript_only_project/config/code_ownership.yml new file mode 100644 index 0000000..50206a3 --- /dev/null +++ b/tests/fixtures/javascript_only_project/config/code_ownership.yml @@ -0,0 +1,12 @@ +owned_globs: + - "{config,frontend}/**/*.{js,jsx,ts,tsx,json,yml}" +javascript_package_paths: + - frontend/packages/** +team_file_glob: + - config/teams/**/*.yml +unbuilt_gems_path: gems +unowned_globs: + - frontend/**/node_modules/**/* + - frontend/**/__generated__/**/* + + diff --git a/tests/fixtures/javascript_only_project/config/teams/design.yml b/tests/fixtures/javascript_only_project/config/teams/design.yml new file mode 100644 index 0000000..2c745d2 --- /dev/null +++ b/tests/fixtures/javascript_only_project/config/teams/design.yml @@ -0,0 +1,7 @@ +name: Design +github: + team: '@DesignTeam' +owned_globs: + - frontend/apps/public/**/* + + diff --git a/tests/fixtures/javascript_only_project/config/teams/frontend.yml b/tests/fixtures/javascript_only_project/config/teams/frontend.yml new file mode 100644 index 0000000..a6c5bbd --- /dev/null +++ b/tests/fixtures/javascript_only_project/config/teams/frontend.yml @@ -0,0 +1,6 @@ +name: Frontend +github: + team: '@FrontendTeam' +owned_globs: [] + + diff --git a/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx b/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx new file mode 100644 index 0000000..4977341 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx @@ -0,0 +1,6 @@ +// @team: Design +export default function Public() { + return
Public
; +} + + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json new file mode 100644 index 0000000..57863e3 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json @@ -0,0 +1,9 @@ +{ + "name": "@acme/dashboard", + "version": "1.0.0", + "metadata": { + "owner": "Frontend" + } +} + + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx new file mode 100644 index 0000000..9a3792d --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx @@ -0,0 +1,6 @@ +// @team Frontend +export const App = () => { + return
Dashboard
; +}; + + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner new file mode 100644 index 0000000..122a46e --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner @@ -0,0 +1,3 @@ +Design + + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx new file mode 100644 index 0000000..be3cdf5 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx @@ -0,0 +1,5 @@ +export const Button = () => { + return ; +}; + + diff --git a/tests/runner_api.rs b/tests/runner_api.rs new file mode 100644 index 0000000..1d1f9f6 --- /dev/null +++ b/tests/runner_api.rs @@ -0,0 +1,181 @@ +use std::path::Path; + +use codeowners::runner::{self, RunConfig}; + +fn write_file(temp_dir: &Path, file_path: &str, content: &str) { + let file_path = temp_dir.join(file_path); + let _ = std::fs::create_dir_all(file_path.parent().unwrap()); + std::fs::write(file_path, content).unwrap(); +} + +#[test] +fn test_file_owners_for_file() { + let temp_dir = tempfile::tempdir().unwrap(); + const DEFAULT_CODE_OWNERSHIP_YML: &str = r#"--- +owned_globs: + - "{app,components,config,frontend,lib,packs,spec,ruby}/**/*.{rb,rake,js,jsx,ts,tsx,json,yml,erb}" +unowned_globs: + - config/code_ownership.yml +javascript_package_paths: + - javascript/packages/** +vendored_gems_path: gems +team_file_glob: + - config/teams/**/*.yml +"#; + write_file(temp_dir.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); + ["a", "b", "c"].iter().for_each(|name| { + let team_yml = format!("name: {}\ngithub:\n team: \"@{}\"\n members:\n - {}member\n", name, name, name); + write_file(temp_dir.path(), &format!("config/teams/{}.yml", name), &team_yml); + }); + write_file( + temp_dir.path(), + "app/consumers/deep/nesting/nestdir/deep_file.rb", + "# @team b\nclass DeepFile end;", + ); + + let run_config = RunConfig { + project_root: temp_dir.path().to_path_buf(), + codeowners_file_path: temp_dir.path().join(".github/CODEOWNERS").to_path_buf(), + config_path: temp_dir.path().join("config/code_ownership.yml").to_path_buf(), + no_cache: true, + }; + + let file_owner = runner::file_owner_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb") + .unwrap() + .unwrap(); + assert_eq!(file_owner.team.name, "b"); + assert_eq!(file_owner.team.github_team, "@b"); + assert!(file_owner.team.path.to_string_lossy().ends_with("config/teams/b.yml")); +} + +#[test] +fn test_teams_for_files_from_codeowners() { + let project_root = Path::new("tests/fixtures/valid_project"); + let file_paths = [ + "javascript/packages/items/item.ts", + "config/teams/payroll.yml", + "ruby/app/models/bank_account.rb", + "made/up/file.rb", + "ruby/ignored_files/git_ignored.rb", + ]; + let run_config = RunConfig { + project_root: project_root.to_path_buf(), + codeowners_file_path: project_root.join(".github/CODEOWNERS").to_path_buf(), + config_path: project_root.join("config/code_ownership.yml").to_path_buf(), + no_cache: true, + }; + let teams = + runner::teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); + assert_eq!(teams.len(), 5); + assert_eq!( + teams + .get("javascript/packages/items/item.ts") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + Some("Payroll") + ); + assert_eq!( + teams.get("config/teams/payroll.yml").unwrap().as_ref().map(|t| t.name.as_str()), + Some("Payroll") + ); + assert_eq!( + teams + .get("ruby/app/models/bank_account.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + Some("Payments") + ); + assert_eq!(teams.get("made/up/file.rb").unwrap().as_ref().map(|t| t.name.as_str()), None); + assert_eq!( + teams + .get("ruby/ignored_files/git_ignored.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + None + ); +} + +#[test] +fn test_for_team_reads_codeowners() { + let td = tempfile::tempdir().unwrap(); + // minimal config + const DEFAULT_CODE_OWNERSHIP_YML: &str = r#"--- +owned_globs: + - "app/**/*" +unowned_globs: + - config/code_ownership.yml +team_file_glob: + - config/teams/**/*.yml +vendored_gems_path: gems +javascript_package_paths: + - javascript/packages/** +"#; + write_file(td.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); + + // team file for Foo + write_file( + td.path(), + "config/teams/foo.yml", + "name: Foo\ngithub:\n team: \"@Foo\"\n members:\n - user\n", + ); + // provide a CODEOWNERS file referencing @Foo + write_file(td.path(), ".github/CODEOWNERS", "/app/** @Foo\n"); + + let rc = RunConfig { + project_root: td.path().to_path_buf(), + codeowners_file_path: td.path().join(".github/CODEOWNERS"), + config_path: td.path().join("config/code_ownership.yml"), + no_cache: true, + }; + + // Ensure CODEOWNERS file matches generator output to avoid out-of-date errors + let _ = runner::generate(&rc, false); + let res = runner::for_team(&rc, "Foo"); + assert!(res.io_errors.is_empty(), "unexpected io errors: {:?}", res.io_errors); + assert!(res.validation_errors.is_empty()); + assert!( + res.info_messages + .iter() + .any(|m| m.contains("# Code Ownership Report for `Foo` Team")) + ); +} + +#[test] +fn test_validate_and_generate_and_validate() { + let td = tempfile::tempdir().unwrap(); + // config and team so generation has inputs + const DEFAULT_CODE_OWNERSHIP_YML: &str = r#"--- +owned_globs: + - "**/*" +team_file_glob: + - config/teams/**/*.yml +vendored_gems_path: gems +javascript_package_paths: + - javascript/packages/** +"#; + write_file(td.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); + write_file( + td.path(), + "config/teams/foo.yml", + "name: Foo\ngithub:\n team: \"@Foo\"\n members:\n - user\nowned_globs:\n - \"app/**\"\n - \"config/code_ownership.yml\"\n", + ); + // create a file to be matched (no annotation to avoid multi-source ownership) + write_file(td.path(), "app/x.rb", "puts :x\n"); + + let rc = RunConfig { + project_root: td.path().to_path_buf(), + codeowners_file_path: td.path().join(".github/CODEOWNERS"), + config_path: td.path().join("config/code_ownership.yml"), + no_cache: true, + }; + + let gv = runner::generate_and_validate(&rc, vec![], true); + assert!(gv.io_errors.is_empty(), "io: {:?}", gv.io_errors); + assert!(gv.validation_errors.is_empty(), "val: {:?}", gv.validation_errors); + // file should exist after generate + let content = std::fs::read_to_string(td.path().join(".github/CODEOWNERS")).unwrap(); + assert!(!content.is_empty()); +}