diff --git a/src/runner.rs b/src/runner.rs index 5562979..312f73c 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -79,6 +79,90 @@ pub(crate) fn resolve_codeowners_file_path(run_config: &RunConfig, config: &Conf run_config.project_root.join(&config.codeowners_path).join("CODEOWNERS") } +/// Validates ownership for an explicit list of paths. +/// +/// Resolves ownership entirely from the config and the CODEOWNERS file, so it +/// needs no `Project` and therefore no project build. Lives outside `Runner` so +/// the `validate ` entry point can call it without constructing one. +pub(crate) fn validate_file_paths(run_config: &RunConfig, config: &Config, file_paths: Vec) -> RunResult { + // Filter files based on owned_globs and unowned_globs configuration + // Only validate files that match owned_globs and don't match unowned_globs + // + // Each surviving path is kept alongside its project-relative form: the + // relative form is what the CODEOWNERS query is keyed by, while the original + // is what gets reported back to the caller. + let filtered: Vec<(String, String)> = file_paths + .into_iter() + .filter_map(|file_path| { + // Convert to relative path for glob matching + let path = Path::new(&file_path); + let relative_path = if path.is_absolute() { + path.strip_prefix(&run_config.project_root).unwrap_or(path) + } else { + path + }; + + // Mirror the filtering applied by ProjectBuilder when walking the project + if matches_globs(relative_path, &config.owned_globs) && !matches_globs(relative_path, &config.unowned_globs) { + let relative = relative_path.to_string_lossy().into_owned(); + Some((file_path, relative)) + } else { + None + } + }) + .collect(); + + if filtered.is_empty() { + return RunResult::default(); + } + + // One batched query for every path, rather than one query per path. The + // per-path version re-read and re-parsed the entire CODEOWNERS file each time + // (`parse_codeowners_entries` is not memoized), costing ~9.5ms per file + // against an 18k-line CODEOWNERS. The batch function already parallelizes + // across the paths it is given. + let codeowners_file_path = resolve_codeowners_file_path(run_config, config); + let relative_paths: Vec = filtered.iter().map(|(_, relative)| relative.clone()).collect(); + let teams = match debug_span!("per_file_query").in_scope(|| { + crate::ownership::codeowners_query::teams_for_files_from_codeowners( + &run_config.project_root, + &codeowners_file_path, + &config.team_file_glob, + &relative_paths, + ) + }) { + Ok(teams) => teams, + // The per-path loop could attribute an IO failure to one specific file. A + // batched read either succeeds or fails for the whole set, so the error is + // no longer per-path. + Err(err) => { + return RunResult { + io_errors: vec![err], + ..Default::default() + }; + } + }; + + let unowned_files: Vec = filtered + .into_iter() + .filter(|(_, relative)| teams.get(relative).is_none_or(Option::is_none)) + .map(|(original, _)| original) + .collect(); + + if !unowned_files.is_empty() { + let validation_errors = std::iter::once("Unowned files detected:".to_string()) + .chain(unowned_files.into_iter().map(|file| format!(" {}", file))) + .collect(); + + return RunResult { + validation_errors, + ..Default::default() + }; + } + + RunResult::default() +} + impl Runner { pub fn new(run_config: &RunConfig) -> Result> { let config = debug_span!("config_load").in_scope(|| config_from_run_config(run_config))?; @@ -143,57 +227,7 @@ impl Runner { } fn validate_files(&self, file_paths: Vec) -> RunResult { - let mut unowned_files = Vec::new(); - let mut io_errors = Vec::new(); - - // Filter files based on owned_globs and unowned_globs configuration - // Only validate files that match owned_globs and don't match unowned_globs - let filtered_paths: Vec = file_paths - .into_iter() - .filter(|file_path| { - // Convert to relative path for glob matching - let path = Path::new(file_path); - let relative_path = if path.is_absolute() { - path.strip_prefix(&self.run_config.project_root).unwrap_or(path) - } else { - path - }; - - // Mirror the filtering applied by ProjectBuilder when walking the project - matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs) - }) - .collect(); - - debug_span!("per_file_query").in_scope(|| { - for file_path in filtered_paths { - match team_for_file_from_codeowners(&self.run_config, &file_path) { - Ok(Some(_)) => {} - Ok(None) => unowned_files.push(file_path), - Err(err) => io_errors.push(format!("{}: {}", file_path, err)), - } - } - }); - - if !unowned_files.is_empty() { - let validation_errors = std::iter::once("Unowned files detected:".to_string()) - .chain(unowned_files.into_iter().map(|file| format!(" {}", file))) - .collect(); - - return RunResult { - validation_errors, - io_errors, - ..Default::default() - }; - } - - if !io_errors.is_empty() { - return RunResult { - io_errors, - ..Default::default() - }; - } - - RunResult::default() + validate_file_paths(&self.run_config, &self.config, file_paths) } pub fn generate(&self, git_stage: bool) -> RunResult { diff --git a/src/runner/api.rs b/src/runner/api.rs index 0c09b4a..3b9d5c0 100644 --- a/src/runner/api.rs +++ b/src/runner/api.rs @@ -18,6 +18,22 @@ pub fn for_team(run_config: &RunConfig, team_name: &str) -> RunResult { } pub fn validate(run_config: &RunConfig, file_paths: Vec) -> RunResult { + // Fast path for `validate `. Ownership for an explicit path list is + // resolved from the config and the CODEOWNERS file alone, so building the + // whole project first — a full walk of every tracked file — is dead work. + // + // Tradeoff: this path no longer creates or persists the cache, so it does not + // warm it for a subsequent command, and it cannot surface project-build IO + // errors. `--no-cache` has no effect here. + if !file_paths.is_empty() { + return match config_from_run_config(run_config) { + Ok(config) => super::validate_file_paths(run_config, &config, file_paths), + Err(err) => RunResult { + io_errors: vec![format!("{:?}", err)], + ..Default::default() + }, + }; + } run(run_config, |runner| runner.validate(file_paths)) }