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 resolved = config.resolve(
83        root.to_path_buf(),
84        OutputFormat::Human,
85        threads,
86        false,
87        true,
88        None,
89    );
90    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
91        collect_workspace_metadata(&resolved)?;
92    Ok(ProjectConfig {
93        config: resolved,
94        path,
95        workspaces,
96        workspace_diagnostics,
97        workspace_discovery_ms: Some(workspace_discovery_ms),
98    })
99}
100
101/// Resolve the parse-cache size limit for a resolved config.
102#[must_use]
103pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
104    config
105        .cache_max_size_mb
106        .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
107            (mb as usize).saturating_mul(1024 * 1024)
108        })
109}
110
111pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
112    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
113    let config = FallowConfig::default().resolve(
114        root.to_path_buf(),
115        OutputFormat::Human,
116        threads,
117        false,
118        true,
119        None,
120    );
121    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
122        collect_workspace_metadata_lossy(&config);
123    ProjectConfig {
124        config,
125        path: None,
126        workspaces,
127        workspace_diagnostics,
128        workspace_discovery_ms: Some(workspace_discovery_ms),
129    }
130}
131
132/// Resolve config for a specific analysis without depending on the CLI crate.
133///
134/// This mirrors the CLI's core config semantics: explicit production overrides
135/// are applied before resolution, per-analysis production config is flattened
136/// for the requested analysis, and boundary / external plugin / rule-pack
137/// validation happens before the resolved config reaches the engine.
138///
139/// # Errors
140///
141/// Returns an engine error when config loading or validation fails.
142pub fn config_for_project_analysis(
143    root: &Path,
144    config_path: Option<&Path>,
145    options: ProjectConfigOptions,
146) -> EngineResult<ProjectConfig> {
147    let user_config = load_user_config(
148        root,
149        config_path,
150        ConfigLoadOptions {
151            allow_remote_extends: options.allow_remote_extends,
152        },
153    )?;
154    let loaded_user_config = user_config.is_some();
155    let (mut config, path) = match user_config {
156        Some((config, path)) => (config, Some(path)),
157        None => (
158            FallowConfig {
159                production: options.production_override.unwrap_or(false).into(),
160                ..FallowConfig::default()
161            },
162            None,
163        ),
164    };
165
166    if loaded_user_config {
167        let production = options
168            .production_override
169            .unwrap_or_else(|| config.production.for_analysis(options.analysis));
170        config.production = production.into();
171    }
172    validate_config(root, &config)?;
173    let resolved = config.resolve(
174        root.to_path_buf(),
175        options.output,
176        options.threads,
177        options.no_cache,
178        options.quiet,
179        None,
180    );
181    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
182        collect_workspace_metadata(&resolved)?;
183    Ok(ProjectConfig {
184        config: resolved,
185        path,
186        workspaces,
187        workspace_diagnostics,
188        workspace_discovery_ms: Some(workspace_discovery_ms),
189    })
190}
191
192pub(crate) fn collect_workspace_metadata(
193    config: &ResolvedConfig,
194) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
195    let start = std::time::Instant::now();
196    let (workspaces, diagnostics) =
197        fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
198            .map_err(|err| EngineError::new(err.to_string()))?;
199    let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
200    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
201    Ok((workspaces, diagnostics, elapsed_ms))
202}
203
204fn collect_workspace_metadata_lossy(
205    config: &ResolvedConfig,
206) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
207    collect_workspace_metadata(config).unwrap_or_default()
208}
209
210fn with_undeclared_workspace_diagnostics(
211    config: &ResolvedConfig,
212    workspaces: &[WorkspaceInfo],
213    mut diagnostics: Vec<WorkspaceDiagnostic>,
214) -> Vec<WorkspaceDiagnostic> {
215    let mut existing: FxHashSet<PathBuf> = diagnostics
216        .iter()
217        .map(|diagnostic| {
218            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
219        })
220        .collect();
221    for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
222        &config.root,
223        workspaces,
224        &config.ignore_patterns,
225    ) {
226        let canonical =
227            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
228        if existing.insert(canonical) {
229            diagnostics.push(diagnostic);
230        }
231    }
232    diagnostics
233}
234
235fn load_user_config(
236    root: &Path,
237    config_path: Option<&Path>,
238    options: ConfigLoadOptions,
239) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
240    if let Some(path) = config_path {
241        let config = FallowConfig::load_with_options(path, options)
242            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
243        return Ok(Some((config, path.to_path_buf())));
244    }
245    FallowConfig::find_and_load_with_options(root, options)
246        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
247}
248
249fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
250    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
251        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
252    validate_boundaries_and_rule_packs(root, config)
253}
254
255fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
256    config
257        .validate_resolved_boundaries(root)
258        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
259    let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
260        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
261    let boundaries =
262        fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
263    let zone_errors = fallow_config::validate_rule_pack_zone_references(
264        root,
265        &config.rule_packs,
266        &packs,
267        &boundaries,
268    );
269    if !zone_errors.is_empty() {
270        return Err(joined_config_errors("invalid rule pack", &zone_errors));
271    }
272    Ok(())
273}
274
275fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
276    let joined = errors
277        .iter()
278        .map(ToString::to_string)
279        .collect::<Vec<_>>()
280        .join("\n  - ");
281    EngineError::new(format!("{label}:\n  - {joined}"))
282}