use std::ffi::OsString;
use std::path::{Path, PathBuf};
use fallow_config::{
ConfigLoadOptions, FallowConfig, ProductionAnalysis, ResolvedConfig, WorkspaceDiagnostic,
WorkspaceInfo,
};
use fallow_types::output_format::OutputFormat;
use rustc_hash::FxHashSet;
use crate::{EngineError, EngineResult};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProductionFlags {
pub global: Option<bool>,
pub dead_code: Option<bool>,
pub health: Option<bool>,
pub dupes: Option<bool>,
}
impl ProductionFlags {
#[must_use]
pub const fn from_cli(
production: bool,
dead_code: Option<bool>,
health: Option<bool>,
dupes: Option<bool>,
) -> Self {
Self {
global: if production { Some(true) } else { None },
dead_code,
health,
dupes,
}
}
#[must_use]
pub const fn single_analysis_override(production: bool, own: Option<bool>) -> Option<bool> {
Self::from_cli(production, own, None, None).override_for(ProductionAnalysis::DeadCode)
}
#[must_use]
pub const fn own(self, analysis: ProductionAnalysis) -> Option<bool> {
match analysis {
ProductionAnalysis::DeadCode => self.dead_code,
ProductionAnalysis::Health => self.health,
ProductionAnalysis::Dupes => self.dupes,
}
}
#[must_use]
pub const fn override_for(self, analysis: ProductionAnalysis) -> Option<bool> {
match self.own(analysis) {
Some(value) => Some(value),
None => self.global,
}
}
#[must_use]
pub const fn mode(self, analysis: ProductionAnalysis) -> bool {
match self.override_for(analysis) {
Some(value) => value,
None => false,
}
}
#[must_use]
pub const fn modes(self) -> ProductionModes {
ProductionModes {
dead_code: self.mode(ProductionAnalysis::DeadCode),
health: self.mode(ProductionAnalysis::Health),
dupes: self.mode(ProductionAnalysis::Dupes),
}
}
#[must_use]
pub const fn effective(
self,
analysis: ProductionAnalysis,
config: fallow_config::ProductionConfig,
) -> bool {
match self.override_for(analysis) {
Some(value) => value,
None => config.for_analysis(analysis),
}
}
#[must_use]
pub const fn effective_modes(self, config: fallow_config::ProductionConfig) -> ProductionModes {
ProductionModes {
dead_code: self.effective(ProductionAnalysis::DeadCode, config),
health: self.effective(ProductionAnalysis::Health, config),
dupes: self.effective(ProductionAnalysis::Dupes, config),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProductionModes {
pub dead_code: bool,
pub health: bool,
pub dupes: bool,
}
impl ProductionModes {
#[must_use]
pub const fn dead_code_matches_health(self) -> bool {
self.dead_code == self.health
}
#[must_use]
pub const fn dead_code_matches_dupes(self) -> bool {
self.dead_code == self.dupes
}
#[must_use]
pub const fn all_match(self) -> bool {
self.dead_code_matches_health() && self.dead_code_matches_dupes()
}
}
#[derive(Debug)]
pub struct ProjectConfig {
pub config: ResolvedConfig,
pub path: Option<PathBuf>,
pub workspaces: Vec<WorkspaceInfo>,
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub workspace_discovery_ms: Option<f64>,
pub inputs: fallow_config::ConfigInputs,
pub inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
}
#[derive(Debug, Clone, Copy)]
pub struct ProjectConfigOptions {
pub output: OutputFormat,
pub no_cache: bool,
pub threads: usize,
pub production_override: Option<bool>,
pub quiet: bool,
pub analysis: ProductionAnalysis,
pub allow_remote_extends: bool,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct ProjectConfigReadiness {
pub project: ProjectConfig,
pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
}
pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
}
pub fn config_for_project_with_load_options(
root: &Path,
config_path: Option<&Path>,
load_options: ConfigLoadOptions,
) -> EngineResult<ProjectConfig> {
let user_config = load_user_config(root, config_path, load_options)?;
let (mut config, path) = match user_config {
Some((config, path)) => (config, Some(path)),
None => (FallowConfig::default(), None),
};
if path.is_some() {
config.production = config
.production
.for_analysis(ProductionAnalysis::DeadCode)
.into();
validate_boundaries_and_rule_packs(root, &config)?;
}
let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let inputs = fallow_config::ConfigInputs::new(root, &config);
let inputs_before_resolve = inputs.snapshot();
let mut resolved = config.resolve(
root.to_path_buf(),
OutputFormat::Human,
threads,
false,
true,
None,
);
apply_env_overrides(&mut resolved);
let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
collect_workspace_metadata(&resolved)?;
Ok(ProjectConfig {
inputs,
inputs_before_resolve,
config: resolved,
path,
workspaces,
workspace_diagnostics,
workspace_discovery_ms: Some(workspace_discovery_ms),
})
}
#[must_use]
pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
config
.cache_max_size_mb
.map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
(mb as usize).saturating_mul(1024 * 1024)
})
}
pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let inputs = fallow_config::ConfigInputs::new(root, &FallowConfig::default());
let inputs_before_resolve = inputs.snapshot();
let mut config = FallowConfig::default().resolve(
root.to_path_buf(),
OutputFormat::Human,
threads,
false,
true,
None,
);
apply_env_overrides(&mut config);
let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
collect_workspace_metadata_lossy(&config);
ProjectConfig {
inputs,
inputs_before_resolve,
config,
path: None,
workspaces,
workspace_diagnostics,
workspace_discovery_ms: Some(workspace_discovery_ms),
}
}
pub fn config_for_project_analysis(
root: &Path,
config_path: Option<&Path>,
options: ProjectConfigOptions,
) -> EngineResult<ProjectConfig> {
resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
}
pub fn config_for_project_readiness(
root: &Path,
config_path: Option<&Path>,
options: ProjectConfigOptions,
) -> EngineResult<ProjectConfigReadiness> {
let (project, configured_plugin_paths) =
resolve_project_config_analysis(root, config_path, options)?;
let configured_plugin_diagnostics =
fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
Ok(ProjectConfigReadiness {
project,
configured_plugin_diagnostics,
})
}
fn resolve_project_config_analysis(
root: &Path,
config_path: Option<&Path>,
options: ProjectConfigOptions,
) -> EngineResult<(ProjectConfig, Vec<String>)> {
let user_config = load_user_config(
root,
config_path,
ConfigLoadOptions {
allow_remote_extends: options.allow_remote_extends,
},
)?;
let loaded_user_config = user_config.is_some();
let (mut config, path) = match user_config {
Some((config, path)) => (config, Some(path)),
None => (
FallowConfig {
production: options.production_override.unwrap_or(false).into(),
..FallowConfig::default()
},
None,
),
};
if loaded_user_config {
let production = ProductionFlags {
global: options.production_override,
..ProductionFlags::default()
}
.effective(options.analysis, config.production);
config.production = production.into();
}
validate_config(root, &config)?;
let configured_plugin_paths = config.plugins.clone();
let inputs = fallow_config::ConfigInputs::new(root, &config);
let inputs_before_resolve = inputs.snapshot();
let mut resolved = config.resolve(
root.to_path_buf(),
options.output,
options.threads,
options.no_cache,
options.quiet,
None,
);
apply_env_overrides(&mut resolved);
let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
collect_workspace_metadata(&resolved)?;
Ok((
ProjectConfig {
inputs,
inputs_before_resolve,
config: resolved,
path,
workspaces,
workspace_diagnostics,
workspace_discovery_ms: Some(workspace_discovery_ms),
},
configured_plugin_paths,
))
}
fn apply_env_overrides(config: &mut ResolvedConfig) {
apply_env_overrides_from(config, |name| std::env::var_os(name));
}
fn apply_env_overrides_from(
config: &mut ResolvedConfig,
lookup: impl Fn(&str) -> Option<OsString>,
) {
let max_file_size_mb = lookup("FALLOW_MAX_FILE_SIZE")
.and_then(|raw| raw.to_str().and_then(|raw| raw.trim().parse::<u32>().ok()));
if let Some(max_file_size_mb) = max_file_size_mb {
config.max_file_size_bytes =
fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
}
if let Some(dir) =
lookup(fallow_config::CACHE_DIR_ENV).and_then(fallow_config::cache_dir_from_env_value)
{
config.override_cache_dir(dir);
}
if let Some(mb) = lookup(fallow_config::CACHE_MAX_SIZE_ENV)
.as_deref()
.and_then(fallow_config::cache_max_size_from_env_value)
{
config.cache_max_size_mb = Some(mb);
}
config.apply_package_baselines_env(lookup(fallow_config::PACKAGE_BASELINES_ENV).as_deref());
}
pub(crate) fn collect_workspace_metadata(
config: &ResolvedConfig,
) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
let start = std::time::Instant::now();
let (workspaces, diagnostics) =
fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
.map_err(|err| EngineError::new(err.to_string()))?;
let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
Ok((workspaces, diagnostics, elapsed_ms))
}
fn collect_workspace_metadata_lossy(
config: &ResolvedConfig,
) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
collect_workspace_metadata(config).unwrap_or_default()
}
fn with_undeclared_workspace_diagnostics(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
mut diagnostics: Vec<WorkspaceDiagnostic>,
) -> Vec<WorkspaceDiagnostic> {
let mut existing: FxHashSet<PathBuf> = diagnostics
.iter()
.map(|diagnostic| {
dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
})
.collect();
for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
&config.root,
workspaces,
&config.ignore_patterns,
) {
let canonical =
dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
if existing.insert(canonical) {
diagnostics.push(diagnostic);
}
}
diagnostics
}
fn load_user_config(
root: &Path,
config_path: Option<&Path>,
options: ConfigLoadOptions,
) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
if let Some(path) = config_path {
let config = FallowConfig::load_with_options(path, options)
.map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
return Ok(Some((config, path.to_path_buf())));
}
FallowConfig::find_and_load_with_options(root, options)
.map_err(|err| EngineError::new(format!("invalid config: {err}")))
}
fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
.map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
validate_boundaries_and_rule_packs(root, config)
}
fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
config
.validate_resolved_boundaries(root)
.map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
.map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
let zone_errors = fallow_config::validate_rule_pack_zones(
root,
&config.boundaries,
&config.rule_packs,
&packs,
);
if !zone_errors.is_empty() {
return Err(joined_config_errors("invalid rule pack", &zone_errors));
}
Ok(())
}
fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
let joined = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ");
EngineError::new(format!("{label}:\n - {joined}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup_from(
vars: &'static [(&'static str, &'static str)],
) -> impl Fn(&str) -> Option<OsString> {
move |name| {
vars.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| OsString::from(value))
}
}
#[test]
fn env_overrides_reach_every_host_config() {
let root = Path::new("/repo");
let mut config = default_project_config(root).config;
apply_env_overrides_from(
&mut config,
lookup_from(&[
("FALLOW_MAX_FILE_SIZE", "7"),
("FALLOW_CACHE_DIR", ".cache/fallow"),
("FALLOW_CACHE_MAX_SIZE", "64"),
]),
);
assert_eq!(
config.max_file_size_bytes,
fallow_config::resolve_max_file_size_bytes(Some(7))
);
assert_eq!(config.cache_dir, root.join(".cache/fallow"));
assert_eq!(config.cache_max_size_mb, Some(64));
}
#[test]
fn invalid_or_empty_env_values_keep_the_config() {
let root = Path::new("/repo");
let mut config = default_project_config(root).config;
let before_dir = config.cache_dir.clone();
let before_max = config.cache_max_size_mb;
apply_env_overrides_from(
&mut config,
lookup_from(&[("FALLOW_CACHE_DIR", ""), ("FALLOW_CACHE_MAX_SIZE", "0")]),
);
assert_eq!(config.cache_dir, before_dir);
assert_eq!(config.cache_max_size_mb, before_max);
}
#[test]
fn package_baselines_env_turns_the_map_off_only_for_a_false_value() {
let root = Path::new("/repo");
let with_map = || {
let mut config = default_project_config(root).config;
config
.workspace_changed_since
.insert("packages/web".to_owned(), "main".to_owned());
config
};
let mut disabled = with_map();
apply_env_overrides_from(
&mut disabled,
lookup_from(&[("FALLOW_PACKAGE_BASELINES", " Off ")]),
);
assert!(disabled.workspace_changed_since.is_empty());
for value in ["true", "1", "maybe", ""] {
let mut kept = with_map();
let vars: &'static [(&'static str, &'static str)] = match value {
"true" => &[("FALLOW_PACKAGE_BASELINES", "true")],
"1" => &[("FALLOW_PACKAGE_BASELINES", "1")],
"maybe" => &[("FALLOW_PACKAGE_BASELINES", "maybe")],
_ => &[("FALLOW_PACKAGE_BASELINES", "")],
};
apply_env_overrides_from(&mut kept, lookup_from(vars));
assert_eq!(kept.workspace_changed_since.len(), 1, "{value:?}");
}
}
}