diff --git a/Cargo.lock b/Cargo.lock index 57a82a8..598809b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "codeowners" -version = "0.2.16" +version = "0.2.17" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index a11c487..5a884ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeowners" -version = "0.2.16" +version = "0.2.17" edition = "2024" [profile.release] diff --git a/src/cache/file.rs b/src/cache/file.rs index ba76126..4132ab8 100644 --- a/src/cache/file.rs +++ b/src/cache/file.rs @@ -17,7 +17,7 @@ pub struct GlobalCache { file_owner_cache: Option>>>, } -const DEFAULT_CACHE_CAPACITY: usize = 10000; +const DEFAULT_CACHE_CAPACITY: usize = 50000; impl Caching for GlobalCache { fn get_file_owner(&self, path: &Path) -> Result, Error> { @@ -62,7 +62,7 @@ impl Caching for GlobalCache { fn delete_cache(&self) -> Result<(), Error> { let cache_path = self.get_cache_path(); - dbg!("deleting", &cache_path); + tracing::debug!("Deleting cache file: {}", cache_path.display()); fs::remove_file(cache_path).change_context(Error::Io) } } diff --git a/src/cli.rs b/src/cli.rs index 4b6343e..1e72bce 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -17,10 +17,12 @@ enum Command { help = "Find the owner from the CODEOWNERS file and just return the team name and yml path" )] from_codeowners: bool, + #[arg(short, long, default_value = "false", help = "Output the result in JSON format")] + json: bool, name: String, }, - #[clap(about = "Finds code ownership information for a given team ", visible_alias = "t")] + #[clap(about = "Finds code ownership information for a given team", visible_alias = "t")] ForTeam { name: String }, #[clap( @@ -113,7 +115,11 @@ pub fn cli() -> Result { Command::Validate => runner::validate(&run_config, vec![]), Command::Generate { skip_stage } => runner::generate(&run_config, !skip_stage), Command::GenerateAndValidate { skip_stage } => runner::generate_and_validate(&run_config, vec![], !skip_stage), - Command::ForFile { name, from_codeowners } => runner::for_file(&run_config, &name, from_codeowners), + Command::ForFile { + name, + from_codeowners, + json, + } => runner::for_file(&run_config, &name, from_codeowners, json), Command::ForTeam { name } => runner::for_team(&run_config, &name), Command::DeleteCache => runner::delete_cache(&run_config), Command::CrosscheckOwners => runner::crosscheck_owners(&run_config), diff --git a/src/main.rs b/src/main.rs index d34ab1e..8afc20a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ fn maybe_print_errors(result: RunResult) -> Result<(), RunnerError> { for msg in result.validation_errors { println!("{}", msg); } - process::exit(-1); + process::exit(1); } Ok(()) diff --git a/src/ownership.rs b/src/ownership.rs index 8f109ef..ff8cbc6 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -59,19 +59,21 @@ impl TeamOwnership { impl Display for FileOwner { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let sources = if self.sources.is_empty() { - "Unowned".to_string() + "".to_string() } else { - self.sources + let sources_str = self + .sources .iter() .sorted_by_key(|source| source.to_string()) .map(|source| source.to_string()) .collect::>() - .join("\n- ") + .join("\n- "); + format!("\n- {}", sources_str) }; write!( f, - "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:\n- {}", + "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:{}", self.team.name, self.team.github_team, self.team_config_file_path, sources ) } diff --git a/src/ownership/codeowners_file_parser.rs b/src/ownership/codeowners_file_parser.rs index 75ab6c7..c40410c 100644 --- a/src/ownership/codeowners_file_parser.rs +++ b/src/ownership/codeowners_file_parser.rs @@ -6,13 +6,7 @@ use fast_glob::glob_match; use memoize::memoize; use rayon::prelude::*; use regex::Regex; -use std::{ - collections::HashMap, - error::Error, - fs, - io::Error as IoError, - path::{Path, PathBuf}, -}; +use std::{collections::HashMap, error::Error, fs, io::Error as IoError, path::PathBuf}; use super::file_generator::compare_lines; @@ -44,16 +38,7 @@ impl Parser { return Ok(HashMap::new()); } - let codeowners_entries: Vec<(String, String)> = - build_codeowners_lines_in_priority(self.codeowners_file_path.to_string_lossy().into_owned()) - .iter() - .map(|line| { - line.split_once(' ') - .map(|(glob, team_name)| (glob.to_string(), team_name.to_string())) - .ok_or_else(|| IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line")) - }) - .collect::>() - .map_err(|e| Box::new(e) as Box)?; + let codeowners_entries = parse_codeowners_entries(self.codeowners_file_path.to_string_lossy().into_owned()); let teams_by_name = teams_by_github_team_name(self.absolute_team_files_globs()); @@ -71,11 +56,6 @@ impl Parser { Ok(result) } - pub fn team_from_file_path(&self, file_path: &Path) -> Result, Box> { - let teams = self.teams_from_files_paths(&[file_path.to_path_buf()])?; - Ok(teams.get(file_path.to_string_lossy().into_owned().as_str()).cloned().flatten()) - } - fn absolute_team_files_globs(&self) -> Vec { self.team_file_globs .iter() @@ -111,7 +91,6 @@ fn teams_by_github_team_name(team_file_glob: Vec) -> HashMap Vec { let codeowners_file = match fs::read_to_string(codeowners_file_path) { Ok(codeowners_file) => codeowners_file, @@ -124,6 +103,16 @@ fn build_codeowners_lines_in_priority(codeowners_file_path: String) -> Vec Vec<(String, String)> { + build_codeowners_lines_in_priority(codeowners_file_path) + .iter() + .filter_map(|line| { + line.split_once(' ') + .map(|(glob, team_name)| (glob.to_string(), team_name.to_string())) + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct Section { heading: String, diff --git a/src/ownership/codeowners_query.rs b/src/ownership/codeowners_query.rs index 70846e4..562bb48 100644 --- a/src/ownership/codeowners_query.rs +++ b/src/ownership/codeowners_query.rs @@ -4,27 +4,6 @@ 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, diff --git a/src/project.rs b/src/project.rs index 5179371..c09b247 100644 --- a/src/project.rs +++ b/src/project.rs @@ -158,9 +158,9 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Error::Io => fmt.write_str("Error::Io"), - Error::SerdeYaml => fmt.write_str("Error::SerdeYaml"), - Error::SerdeJson => fmt.write_str("Error::SerdeJson"), + Error::Io => fmt.write_str("IO operation failed"), + Error::SerdeYaml => fmt.write_str("YAML serialization/deserialization failed"), + Error::SerdeJson => fmt.write_str("JSON serialization/deserialization failed"), } } } diff --git a/src/project_builder.rs b/src/project_builder.rs index 45a9d88..1945b0a 100644 --- a/src/project_builder.rs +++ b/src/project_builder.rs @@ -52,8 +52,9 @@ impl<'a> ProjectBuilder<'a> { } } - #[instrument(level = "debug", skip_all)] + #[instrument(level = "debug", skip_all, fields(base_path = %self.base_path.display()))] pub fn build(&mut self) -> Result { + tracing::info!("Starting project build"); let mut builder = WalkBuilder::new(&self.base_path); builder.hidden(false); builder.follow_links(false); @@ -277,6 +278,13 @@ impl<'a> ProjectBuilder<'a> { .flat_map(|team| vec![(team.name.clone(), team.clone()), (team.github_team.clone(), team.clone())]) .collect(); + tracing::info!( + files_count = %project_files.len(), + teams_count = %teams.len(), + packages_count = %packages.len(), + "Project build completed successfully" + ); + Ok(Project { base_path: self.base_path.to_owned(), files: project_files, diff --git a/src/runner.rs b/src/runner.rs index 0aef0c3..abf573d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,6 +1,7 @@ use std::{path::Path, process::Command}; use error_stack::{Result, ResultExt}; +use serde::Serialize; use crate::{ cache::{Cache, Caching, file::GlobalCache, noop::NoopCache}, @@ -181,61 +182,170 @@ impl Runner { Ok(owners) } - pub fn for_file_optimized(&self, file_path: &str) -> RunResult { + pub fn for_file_derived(&self, file_path: &str, json: bool) -> 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() - }; + return RunResult::from_io_error(Error::Io(err.to_string()), json); } }; - let info_messages: Vec = match file_owners.len() { - 0 => vec![format!("{}", FileOwner::default())], - 1 => vec![format!("{}", file_owners[0])], - _ => { + match file_owners.as_slice() { + [] => RunResult::from_file_owner(&FileOwner::default(), json), + [owner] => RunResult::from_file_owner(owner, json), + many => { 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)); + for owner in many { + error_messages.push(format!("\n{}", owner)); } - return RunResult { - validation_errors: error_messages, - ..Default::default() - }; + RunResult::from_validation_errors(error_messages, json) } - }; - RunResult { - info_messages, - ..Default::default() } } - pub fn for_file_codeowners_only(&self, file_path: &str) -> RunResult { + pub fn for_file_codeowners_only(&self, file_path: &str, json: bool) -> 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()) + let team_yml = 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() + let result = ForFileResult { + team_name: team.name.clone(), + github_team: team.github_team.clone(), + team_yml, + description: vec!["Owner inferred from codeowners file".to_string()], + }; + if json { + RunResult::json_info(result) + } else { + RunResult { + info_messages: vec![format!( + "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:\n- {}", + result.team_name, + result.github_team, + result.team_yml, + result.description.join("\n- ") + )], + ..Default::default() + } + } + } + Ok(None) => RunResult::from_file_owner(&FileOwner::default(), json), + Err(err) => { + if json { + RunResult::json_io_error(Error::Io(err.to_string())) + } else { + RunResult { + io_errors: vec![err.to_string()], + ..Default::default() + } } } - Ok(None) => RunResult::default(), - Err(err) => RunResult { - io_errors: vec![err.to_string()], - ..Default::default() - }, } } } -// removed free functions for for_file_* variants in favor of Runner methods +#[derive(Debug, Clone, Serialize)] +pub struct ForFileResult { + pub team_name: String, + pub github_team: String, + pub team_yml: String, + pub description: Vec, +} + +impl RunResult { + pub fn has_errors(&self) -> bool { + !self.validation_errors.is_empty() || !self.io_errors.is_empty() + } + + fn from_io_error(error: Error, json: bool) -> Self { + if json { + Self::json_io_error(error) + } else { + Self { + io_errors: vec![error.to_string()], + ..Default::default() + } + } + } + + fn from_file_owner(file_owner: &FileOwner, json: bool) -> Self { + if json { + let description: Vec = if file_owner.sources.is_empty() { + vec![] + } else { + file_owner.sources.iter().map(|source| source.to_string()).collect() + }; + Self::json_info(ForFileResult { + team_name: file_owner.team.name.clone(), + github_team: file_owner.team.github_team.clone(), + team_yml: file_owner.team_config_file_path.clone(), + description, + }) + } else { + Self { + info_messages: vec![format!("{}", file_owner)], + ..Default::default() + } + } + } + + fn from_validation_errors(validation_errors: Vec, json: bool) -> Self { + if json { + Self::json_validation_error(validation_errors) + } else { + Self { + validation_errors, + ..Default::default() + } + } + } + + pub fn json_info(result: ForFileResult) -> Self { + let json = match serde_json::to_string_pretty(&result) { + Ok(json) => json, + Err(e) => return Self::fallback_io_error(&e.to_string()), + }; + Self { + info_messages: vec![json], + ..Default::default() + } + } + + pub fn json_io_error(error: Error) -> Self { + let message = match error { + Error::Io(msg) => msg, + Error::ValidationFailed => "Error::ValidationFailed".to_string(), + }; + let json = match serde_json::to_string(&serde_json::json!({"error": message})) { + Ok(json) => json, + Err(e) => return Self::fallback_io_error(&format!("JSON serialization failed: {}", e)), + }; + Self { + io_errors: vec![json], + ..Default::default() + } + } + + pub fn json_validation_error(validation_errors: Vec) -> Self { + let json_obj = serde_json::json!({"validation_errors": validation_errors}); + let json = match serde_json::to_string_pretty(&json_obj) { + Ok(json) => json, + Err(e) => return Self::fallback_io_error(&format!("JSON serialization failed: {}", e)), + }; + Self { + validation_errors: vec![json], + ..Default::default() + } + } + + fn fallback_io_error(message: &str) -> Self { + Self { + io_errors: vec![format!("{{\"error\": \"{}\"}}", message.replace('"', "\\\""))], + ..Default::default() + } + } +} #[cfg(test)] mod tests { @@ -245,4 +355,36 @@ mod tests { fn test_version() { assert_eq!(version(), env!("CARGO_PKG_VERSION").to_string()); } + #[test] + fn test_json_info() { + let result = ForFileResult { + team_name: "team1".to_string(), + github_team: "team1".to_string(), + team_yml: "config/teams/team1.yml".to_string(), + description: vec!["file annotation".to_string()], + }; + let result = RunResult::json_info(result); + assert_eq!(result.info_messages.len(), 1); + assert_eq!( + result.info_messages[0], + "{\n \"team_name\": \"team1\",\n \"github_team\": \"team1\",\n \"team_yml\": \"config/teams/team1.yml\",\n \"description\": [\n \"file annotation\"\n ]\n}" + ); + } + + #[test] + fn test_json_io_error() { + let result = RunResult::json_io_error(Error::Io("unable to find file".to_string())); + assert_eq!(result.io_errors.len(), 1); + assert_eq!(result.io_errors[0], "{\"error\":\"unable to find file\"}"); + } + + #[test] + fn test_json_validation_error() { + let result = RunResult::json_validation_error(vec!["file has multiple owners".to_string()]); + assert_eq!(result.validation_errors.len(), 1); + assert_eq!( + result.validation_errors[0], + "{\n \"validation_errors\": [\n \"file has multiple owners\"\n ]\n}" + ); + } } diff --git a/src/runner/api.rs b/src/runner/api.rs index ea5fcaa..acaf8ae 100644 --- a/src/runner/api.rs +++ b/src/runner/api.rs @@ -1,19 +1,15 @@ 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}; +use super::{Error, ForFileResult, RunConfig, RunResult, 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_file(run_config: &RunConfig, file_path: &str, from_codeowners: bool, json: bool) -> RunResult { + if from_codeowners { + return for_file_codeowners_only_fast(run_config, file_path, json); + } + for_file_optimized(run_config, file_path, json) } pub fn for_team(run_config: &RunConfig, team_name: &str) -> RunResult { @@ -40,10 +36,17 @@ pub fn crosscheck_owners(run_config: &RunConfig) -> RunResult { run(run_config, |runner| runner.crosscheck_owners()) } +// Returns all owners for a file without creating a Runner (performance optimized) +pub fn owners_for_file(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let config = config_from_path(&run_config.config_path)?; + use crate::ownership::file_owner_resolver::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) +} + // 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)?; + let owners = owners_for_file(run_config, file_path)?; Ok(owners.first().cloned()) } @@ -69,15 +72,70 @@ pub fn teams_for_files_from_codeowners( } 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 result = teams_for_files_from_codeowners(run_config, &[file_path.to_string()])?; + // Since we only passed one file, there should be exactly one result + debug_assert_eq!(result.len(), 1); + Ok(result.into_values().next().flatten()) +} - 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) +// Fast path that avoids creating a full Runner for single file queries +fn for_file_optimized(run_config: &RunConfig, file_path: &str, json: bool) -> RunResult { + let config = match config_from_path(&run_config.config_path) { + Ok(c) => c, + Err(err) => { + return RunResult::from_io_error(Error::Io(err.to_string()), json); + } + }; + + use crate::ownership::file_owner_resolver::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::from_io_error(Error::Io(err), json); + } + }; + + match file_owners.as_slice() { + [] => RunResult::from_file_owner(&crate::ownership::FileOwner::default(), json), + [owner] => RunResult::from_file_owner(owner, json), + many => { + let mut error_messages = vec!["Error: file is owned by multiple teams!".to_string()]; + for owner in many { + error_messages.push(format!("\n{}", owner)); + } + RunResult::from_validation_errors(error_messages, json) + } + } +} + +fn for_file_codeowners_only_fast(run_config: &RunConfig, file_path: &str, json: bool) -> RunResult { + match team_for_file_from_codeowners(run_config, file_path) { + Ok(Some(team)) => { + let team_yml = crate::path_utils::relative_to(&run_config.project_root, team.path.as_path()) + .to_string_lossy() + .to_string(); + let result = ForFileResult { + team_name: team.name.clone(), + github_team: team.github_team.clone(), + team_yml, + description: vec!["Owner inferred from codeowners file".to_string()], + }; + if json { + RunResult::json_info(result) + } else { + RunResult { + info_messages: vec![format!( + "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:\n- {}", + result.team_name, + result.github_team, + result.team_yml, + result.description.join("\n- ") + )], + ..Default::default() + } + } + } + Ok(None) => RunResult::from_file_owner(&crate::ownership::FileOwner::default(), json), + Err(err) => RunResult::from_io_error(Error::Io(format!("{}", err)), json), + } } diff --git a/src/runner/types.rs b/src/runner/types.rs index 5ba38ea..c42fa02 100644 --- a/src/runner/types.rs +++ b/src/runner/types.rs @@ -11,12 +11,6 @@ pub struct RunResult { 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, @@ -25,7 +19,7 @@ pub struct RunConfig { pub no_cache: bool, } -#[derive(Debug)] +#[derive(Debug, Serialize)] pub enum Error { Io(String), ValidationFailed, diff --git a/tests/invalid_project_test.rs b/tests/invalid_project_test.rs index d3191ce..90a001f 100644 --- a/tests/invalid_project_test.rs +++ b/tests/invalid_project_test.rs @@ -54,12 +54,30 @@ fn test_for_file() -> Result<(), Box> { Github Team: Unowned Team YML: Description: - - Unowned "}), )?; Ok(()) } +#[test] +fn test_for_file_json() -> Result<(), Box> { + run_codeowners( + "invalid_project", + &["for-file", "ruby/app/models/blockchain.rb", "--json"], + true, + OutputStream::Stdout, + predicate::eq(indoc! {r#" + { + "team_name": "Unowned", + "github_team": "Unowned", + "team_yml": "", + "description": [] + } + "#}), + )?; + Ok(()) +} + #[test] fn test_for_file_multiple_owners() -> Result<(), Box> { run_codeowners( @@ -85,3 +103,42 @@ fn test_for_file_multiple_owners() -> Result<(), Box> { )?; Ok(()) } + +#[test] +fn test_for_file_multiple_owners_json() -> Result<(), Box> { + run_codeowners( + "invalid_project", + &["for-file", "ruby/app/services/multi_owned.rb", "--json"], + false, + OutputStream::Stdout, + predicate::eq(indoc! {r#" + { + "validation_errors": [ + "Error: file is owned by multiple teams!", + "\nTeam: Payments\nGithub Team: @PaymentTeam\nTeam YML: config/teams/payments.yml\nDescription:\n- Owner annotation at the top of the file", + "\nTeam: Payroll\nGithub Team: @PayrollTeam\nTeam YML: config/teams/payroll.yml\nDescription:\n- Owner specified in `ruby/app/services/.codeowner`" + ] + } + "#}), + )?; + Ok(()) +} + +#[test] +fn test_for_file_nonexistent_json() -> Result<(), Box> { + run_codeowners( + "invalid_project", + &["for-file", "nonexistent/file.rb", "--json"], + true, + OutputStream::Stdout, + predicate::eq(indoc! {r#" + { + "team_name": "Unowned", + "github_team": "Unowned", + "team_yml": "", + "description": [] + } + "#}), + )?; + Ok(()) +} diff --git a/tests/valid_project_test.rs b/tests/valid_project_test.rs index 0e28554..ca2aa56 100644 --- a/tests/valid_project_test.rs +++ b/tests/valid_project_test.rs @@ -71,6 +71,28 @@ fn test_for_file() -> Result<(), Box> { Ok(()) } +#[test] +fn test_for_file_json() -> Result<(), Box> { + run_codeowners( + "valid_project", + &["for-file", "ruby/app/models/payroll.rb", "--json"], + true, + OutputStream::Stdout, + predicate::eq(indoc! {r#" + { + "team_name": "Payroll", + "github_team": "@PayrollTeam", + "team_yml": "config/teams/payroll.yml", + "description": [ + "Owner annotation at the top of the file" + ] + } + "#}), + )?; + + Ok(()) +} + #[test] fn test_for_file_full_path() -> Result<(), Box> { let project_root = Path::new("tests/fixtures/valid_project"); @@ -94,6 +116,33 @@ fn test_for_file_full_path() -> Result<(), Box> { Ok(()) } +#[test] +fn test_for_file_full_path_json() -> Result<(), Box> { + let project_root = Path::new("tests/fixtures/valid_project"); + let for_file_absolute_path = fs::canonicalize(project_root.join("ruby/app/models/payroll.rb"))?; + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("for-file") + .arg(for_file_absolute_path.to_str().unwrap()) + .arg("--json") + .assert() + .success() + .stdout(predicate::eq(indoc! {r#" + { + "team_name": "Payroll", + "github_team": "@PayrollTeam", + "team_yml": "config/teams/payroll.yml", + "description": [ + "Owner annotation at the top of the file" + ] + } + "#})); + Ok(()) +} + #[test] fn test_fast_for_file() -> Result<(), Box> { Command::cargo_bin("codeowners")? @@ -129,7 +178,6 @@ fn test_fast_for_file_with_ignored_file() -> Result<(), Box> { Github Team: Unowned Team YML: Description: - - Unowned "})); Ok(()) }