use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::{LazyLock, Mutex, OnceLock};
use fallow_config::{
FallowConfig, OutputFormat, PartialRulesConfig, ProductionAnalysis, ResolvedConfig, RulesConfig,
};
use rustc_hash::FxHashSet;
static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(FxHashSet::default()));
static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
pub fn set_max_file_size_override(max_file_size_mb: Option<u32>) {
let _ = MAX_FILE_SIZE_OVERRIDE.set(max_file_size_mb);
}
fn resolve_max_file_size_mb() -> Option<u32> {
if let Some(Some(mb)) = MAX_FILE_SIZE_OVERRIDE.get() {
return Some(*mb);
}
std::env::var("FALLOW_MAX_FILE_SIZE")
.ok()
.and_then(|raw| raw.trim().parse::<u32>().ok())
}
#[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()
.is_ok_and(|mut logged| logged.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 loaded_user_config = user_config.is_some();
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()
},
};
crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
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 mut resolved = final_config.resolve(
root.to_path_buf(),
output,
threads,
no_cache,
quiet,
cache_max_size_mb,
);
if let Some(mb) = resolve_max_file_size_mb() {
resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
}
apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
crate::cache_notice::record_candidate(
root,
&resolved.cache_dir,
output,
quiet,
resolved.no_cache,
);
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)
}
fn config_shape_for(
config: &FallowConfig,
loaded_user_config: bool,
) -> crate::telemetry::ConfigShape {
if !config.plugins.is_empty() || !config.framework.is_empty() {
return crate::telemetry::ConfigShape::PluginsEnabled;
}
if config.rules != RulesConfig::default()
|| config
.overrides
.iter()
.any(|entry| partial_rules_config_has_values(&entry.rules))
{
return crate::telemetry::ConfigShape::CustomRules;
}
if loaded_user_config {
return crate::telemetry::ConfigShape::CustomConfig;
}
crate::telemetry::ConfigShape::Default
}
fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
serde_json::to_value(rules)
.ok()
.and_then(|value| value.as_object().map(|object| !object.is_empty()))
.unwrap_or(false)
}
#[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)
}
fn resolve_cache_dir_env() -> Option<PathBuf> {
std::env::var_os("FALLOW_CACHE_DIR")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
root.join(path)
}
}
fn apply_cache_dir_env_override(
root: &Path,
resolved: &mut ResolvedConfig,
env_value: Option<PathBuf>,
) {
if let Some(path) = env_value {
resolved.cache_dir = resolve_cache_dir_value(root, path);
}
}
#[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));
}
#[test]
fn cache_dir_env_value_resolves_relative_to_project_root() {
assert_eq!(
resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
PathBuf::from("/repo/.cache/fallow")
);
assert_eq!(
resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
PathBuf::from("/tmp/fallow-cache")
);
}
#[test]
fn cache_dir_env_value_wins_over_configured_cache_dir() {
let mut resolved = FallowConfig {
cache: fallow_config::CacheConfig {
dir: Some(PathBuf::from(".cache/from-config")),
..Default::default()
},
..Default::default()
}
.resolve(
PathBuf::from("/repo"),
OutputFormat::Human,
1,
false,
true,
None,
);
apply_cache_dir_env_override(
Path::new("/repo"),
&mut resolved,
Some(PathBuf::from(".cache/from-env")),
);
assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
}
}