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