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>,
}
#[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 mut resolved = config.resolve(
root.to_path_buf(),
OutputFormat::Human,
threads,
false,
true,
None,
);
apply_max_file_size_env(&mut resolved);
let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
collect_workspace_metadata(&resolved)?;
Ok(ProjectConfig {
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 config = FallowConfig::default().resolve(
root.to_path_buf(),
OutputFormat::Human,
threads,
false,
true,
None,
);
let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
collect_workspace_metadata_lossy(&config);
ProjectConfig {
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 mut resolved = config.resolve(
root.to_path_buf(),
options.output,
options.threads,
options.no_cache,
options.quiet,
None,
);
apply_max_file_size_env(&mut resolved);
let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
collect_workspace_metadata(&resolved)?;
Ok((
ProjectConfig {
config: resolved,
path,
workspaces,
workspace_diagnostics,
workspace_discovery_ms: Some(workspace_discovery_ms),
},
configured_plugin_paths,
))
}
fn apply_max_file_size_env(config: &mut ResolvedConfig) {
let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
.ok()
.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));
}
}
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}"))
}