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