use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::{LazyLock, Mutex};
use fallow_config::{FallowConfig, OutputFormat, ProductionAnalysis, ResolvedConfig};
use rustc_hash::FxHashSet;
static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(FxHashSet::default()));
#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
pub enum AnalysisKind {
#[value(alias = "check")]
DeadCode,
Dupes,
Health,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum GroupBy {
#[value(alias = "team", alias = "codeowner")]
Owner,
Directory,
#[value(alias = "workspace", alias = "pkg")]
Package,
#[value(alias = "gl-section")]
Section,
}
pub fn build_ownership_resolver(
group_by: Option<GroupBy>,
root: &Path,
codeowners_path: Option<&str>,
output: OutputFormat,
) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
let Some(mode) = group_by else {
return Ok(None);
};
match mode {
GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
Err(e) => Err(crate::error::emit_error(&e, 2, output)),
},
GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
Ok(co) => {
if co.has_sections() {
Ok(Some(crate::report::OwnershipResolver::Section(co)))
} else {
Err(crate::error::emit_error(
"--group-by section requires a GitLab-style CODEOWNERS file \
with `[Section]` headers. This CODEOWNERS has no sections; \
use --group-by owner instead.",
2,
output,
))
}
}
Err(e) => Err(crate::error::emit_error(&e, 2, output)),
},
GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
GroupBy::Package => {
let workspaces = fallow_config::discover_workspaces(root);
if workspaces.is_empty() {
Err(crate::error::emit_error(
"--group-by package requires a monorepo with workspace packages \
(package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
For single-package projects try --group-by directory instead.",
2,
output,
))
} else {
Ok(Some(crate::report::OwnershipResolver::Package(
crate::report::grouping::PackageResolver::new(root, &workspaces),
)))
}
}
}
}
fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
if quiet || !matches!(output, OutputFormat::Human) {
return;
}
if !should_log_config_loaded(path) {
return;
}
eprintln!("loaded config: {}", path.display());
}
fn should_log_config_loaded(path: &Path) -> bool {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
CONFIG_LOADED_LOGGED.lock().unwrap().insert(key)
}
#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
pub fn load_config(
root: &Path,
config_path: &Option<PathBuf>,
output: OutputFormat,
no_cache: bool,
threads: usize,
production: bool,
quiet: bool,
) -> Result<ResolvedConfig, ExitCode> {
load_config_for_analysis(
root,
config_path,
output,
no_cache,
threads,
production.then_some(true),
quiet,
ProductionAnalysis::DeadCode,
)
}
#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
#[expect(
clippy::too_many_arguments,
reason = "central config loader mirrors CLI dispatch options"
)]
pub fn load_config_for_analysis(
root: &Path,
config_path: &Option<PathBuf>,
output: OutputFormat,
no_cache: bool,
threads: usize,
production_override: Option<bool>,
quiet: bool,
analysis: ProductionAnalysis,
) -> Result<ResolvedConfig, ExitCode> {
let user_config = if let Some(path) = config_path {
match FallowConfig::load(path) {
Ok(c) => {
log_config_loaded(path, output, quiet);
Some(c)
}
Err(e) => {
let msg = format!("failed to load config '{}': {e}", path.display());
return Err(crate::error::emit_error(&msg, 2, output));
}
}
} else {
match FallowConfig::find_and_load(root) {
Ok(Some((config, found_path))) => {
log_config_loaded(&found_path, output, quiet);
Some(config)
}
Ok(None) => None,
Err(e) => {
return Err(crate::error::emit_error(&e, 2, output));
}
}
};
let final_config = match user_config {
Some(mut config) => {
let production =
production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
config.production = production.into();
config
}
None => FallowConfig {
production: production_override.unwrap_or(false).into(),
..FallowConfig::default()
},
};
if let Err(errors) =
fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
{
let joined = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ");
let msg = format!("invalid external plugin definition:\n - {joined}");
return Err(crate::error::emit_error(&msg, 2, output));
}
if let Err(errors) = final_config.validate_resolved_boundaries(root) {
let joined = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ");
let msg = format!("invalid boundary configuration:\n - {joined}");
return Err(crate::error::emit_error(&msg, 2, output));
}
let cache_max_size_mb = resolve_cache_max_size_env();
let resolved = final_config.resolve(
root.to_path_buf(),
output,
threads,
no_cache,
quiet,
cache_max_size_mb,
);
match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
Ok((_, diagnostics)) => {
fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
eprintln!(
"fallow: {} workspace discovery diagnostic{}. \
Run `fallow list --workspaces` for detail.",
diagnostics.len(),
if diagnostics.len() == 1 { "" } else { "s" }
);
}
}
Err(err) => {
return Err(crate::error::emit_error(&err.to_string(), 2, output));
}
}
Ok(resolved)
}
#[must_use]
pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
fallow_config::workspace_diagnostics_for(root)
}
fn resolve_cache_max_size_env() -> Option<u32> {
std::env::var("FALLOW_CACHE_MAX_SIZE")
.ok()
.and_then(|raw| raw.trim().parse::<u32>().ok())
.filter(|mb| *mb > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_loaded_notice_dedupes_by_config_path() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first.fallow.json");
let second = dir.path().join("second.fallow.json");
std::fs::write(&first, "{}").unwrap();
std::fs::write(&second, "{}").unwrap();
assert!(should_log_config_loaded(&first));
assert!(!should_log_config_loaded(&first));
assert!(should_log_config_loaded(&second));
}
}