Skip to main content

fallow_engine/
project_config.rs

1//! Project config resolution owned by the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{
6    FallowConfig, ProductionAnalysis, ResolvedConfig, WorkspaceDiagnostic, WorkspaceInfo,
7};
8use fallow_types::output_format::OutputFormat;
9use rustc_hash::FxHashSet;
10
11use crate::{EngineError, EngineResult};
12
13/// Resolved project config plus the config file path when one was loaded.
14#[derive(Debug)]
15pub struct ProjectConfig {
16    pub config: ResolvedConfig,
17    pub path: Option<PathBuf>,
18    pub workspaces: Vec<WorkspaceInfo>,
19    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
20    pub workspace_discovery_ms: Option<f64>,
21}
22
23/// Scalar config-loading knobs for one analysis family.
24#[derive(Debug, Clone, Copy)]
25pub struct ProjectConfigOptions {
26    pub output: OutputFormat,
27    pub no_cache: bool,
28    pub threads: usize,
29    pub production_override: Option<bool>,
30    pub quiet: bool,
31    pub analysis: ProductionAnalysis,
32}
33
34/// Resolve the analysis config for a project.
35///
36/// # Errors
37///
38/// Returns an error when an explicit config cannot be loaded or automatic
39/// config discovery finds an invalid config.
40pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
41    let user_config = load_user_config(root, config_path)?;
42    let (mut config, path) = match user_config {
43        Some((config, path)) => (config, Some(path)),
44        None => (FallowConfig::default(), None),
45    };
46    if path.is_some() {
47        config.production = config
48            .production
49            .for_analysis(ProductionAnalysis::DeadCode)
50            .into();
51        validate_boundaries_and_rule_packs(root, &config)?;
52    }
53    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
54    let resolved = config.resolve(
55        root.to_path_buf(),
56        OutputFormat::Human,
57        threads,
58        false,
59        true,
60        None,
61    );
62    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
63        collect_workspace_metadata(&resolved)?;
64    Ok(ProjectConfig {
65        config: resolved,
66        path,
67        workspaces,
68        workspace_diagnostics,
69        workspace_discovery_ms: Some(workspace_discovery_ms),
70    })
71}
72
73/// Resolve the parse-cache size limit for a resolved config.
74#[must_use]
75pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
76    config
77        .cache_max_size_mb
78        .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
79            (mb as usize).saturating_mul(1024 * 1024)
80        })
81}
82
83pub fn default_project_config(root: &Path) -> ProjectConfig {
84    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
85    let config = FallowConfig::default().resolve(
86        root.to_path_buf(),
87        OutputFormat::Human,
88        threads,
89        false,
90        true,
91        None,
92    );
93    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
94        collect_workspace_metadata_lossy(&config);
95    ProjectConfig {
96        config,
97        path: None,
98        workspaces,
99        workspace_diagnostics,
100        workspace_discovery_ms: Some(workspace_discovery_ms),
101    }
102}
103
104/// Resolve config for a specific analysis without depending on the CLI crate.
105///
106/// This mirrors the CLI's core config semantics: explicit production overrides
107/// are applied before resolution, per-analysis production config is flattened
108/// for the requested analysis, and boundary / external plugin / rule-pack
109/// validation happens before the resolved config reaches the engine.
110///
111/// # Errors
112///
113/// Returns an engine error when config loading or validation fails.
114pub fn config_for_project_analysis(
115    root: &Path,
116    config_path: Option<&Path>,
117    options: ProjectConfigOptions,
118) -> EngineResult<ProjectConfig> {
119    let user_config = load_user_config(root, config_path)?;
120    let loaded_user_config = user_config.is_some();
121    let (mut config, path) = match user_config {
122        Some((config, path)) => (config, Some(path)),
123        None => (
124            FallowConfig {
125                production: options.production_override.unwrap_or(false).into(),
126                ..FallowConfig::default()
127            },
128            None,
129        ),
130    };
131
132    if loaded_user_config {
133        let production = options
134            .production_override
135            .unwrap_or_else(|| config.production.for_analysis(options.analysis));
136        config.production = production.into();
137    }
138    validate_config(root, &config)?;
139    let resolved = config.resolve(
140        root.to_path_buf(),
141        options.output,
142        options.threads,
143        options.no_cache,
144        options.quiet,
145        None,
146    );
147    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
148        collect_workspace_metadata(&resolved)?;
149    Ok(ProjectConfig {
150        config: resolved,
151        path,
152        workspaces,
153        workspace_diagnostics,
154        workspace_discovery_ms: Some(workspace_discovery_ms),
155    })
156}
157
158fn collect_workspace_metadata(
159    config: &ResolvedConfig,
160) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
161    let start = std::time::Instant::now();
162    let (workspaces, diagnostics) =
163        fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
164            .map_err(|err| EngineError::new(err.to_string()))?;
165    let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
166    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
167    Ok((workspaces, diagnostics, elapsed_ms))
168}
169
170fn collect_workspace_metadata_lossy(
171    config: &ResolvedConfig,
172) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
173    collect_workspace_metadata(config).unwrap_or_default()
174}
175
176fn with_undeclared_workspace_diagnostics(
177    config: &ResolvedConfig,
178    workspaces: &[WorkspaceInfo],
179    mut diagnostics: Vec<WorkspaceDiagnostic>,
180) -> Vec<WorkspaceDiagnostic> {
181    let mut existing: FxHashSet<PathBuf> = diagnostics
182        .iter()
183        .map(|diagnostic| {
184            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
185        })
186        .collect();
187    for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
188        &config.root,
189        workspaces,
190        &config.ignore_patterns,
191    ) {
192        let canonical =
193            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
194        if existing.insert(canonical) {
195            diagnostics.push(diagnostic);
196        }
197    }
198    diagnostics
199}
200
201fn load_user_config(
202    root: &Path,
203    config_path: Option<&Path>,
204) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
205    if let Some(path) = config_path {
206        let config = FallowConfig::load(path)
207            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
208        return Ok(Some((config, path.to_path_buf())));
209    }
210    FallowConfig::find_and_load(root)
211        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
212}
213
214fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
215    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
216        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
217    validate_boundaries_and_rule_packs(root, config)
218}
219
220fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
221    config
222        .validate_resolved_boundaries(root)
223        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
224    let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
225        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
226    let boundaries =
227        fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
228    let zone_errors = fallow_config::validate_rule_pack_zone_references(
229        root,
230        &config.rule_packs,
231        &packs,
232        &boundaries,
233    );
234    if !zone_errors.is_empty() {
235        return Err(joined_config_errors("invalid rule pack", &zone_errors));
236    }
237    Ok(())
238}
239
240fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
241    let joined = errors
242        .iter()
243        .map(ToString::to_string)
244        .collect::<Vec<_>>()
245        .join("\n  - ");
246    EngineError::new(format!("{label}:\n  - {joined}"))
247}