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/// The production flags of one run: the global `--production` override and one
15/// override per analysis (`--production-dead-code`, `--production-health`,
16/// `--production-dupes`).
17///
18/// Every command and surface that runs more than one analysis reads the
19/// production mode of each analysis from here, so the precedence has one
20/// implementation: the flag of the analysis, then the global override, then
21/// the config.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct ProductionFlags {
24    /// The global override. The CLI sets `Some(true)` for `--production` and
25    /// `None` without it. The programmatic API can also pass `Some(false)`.
26    pub global: Option<bool>,
27    /// Override for the dead-code analysis.
28    pub dead_code: Option<bool>,
29    /// Override for the health analysis.
30    pub health: Option<bool>,
31    /// Override for the duplication analysis.
32    pub dupes: Option<bool>,
33}
34
35impl ProductionFlags {
36    /// The flags of a CLI run, where `--production` can only switch the mode
37    /// on.
38    #[must_use]
39    pub const fn from_cli(
40        production: bool,
41        dead_code: Option<bool>,
42        health: Option<bool>,
43        dupes: Option<bool>,
44    ) -> Self {
45        Self {
46            global: if production { Some(true) } else { None },
47            dead_code,
48            health,
49            dupes,
50        }
51    }
52
53    /// The production override of a command that runs one analysis
54    /// (`dead-code`, `dupes`, `health`): its own override, else
55    /// `--production`. `None` means that the config decides.
56    #[must_use]
57    pub const fn single_analysis_override(production: bool, own: Option<bool>) -> Option<bool> {
58        Self::from_cli(production, own, None, None).override_for(ProductionAnalysis::DeadCode)
59    }
60
61    /// The override flag of one analysis, without the global override.
62    #[must_use]
63    pub const fn own(self, analysis: ProductionAnalysis) -> Option<bool> {
64        match analysis {
65            ProductionAnalysis::DeadCode => self.dead_code,
66            ProductionAnalysis::Health => self.health,
67            ProductionAnalysis::Dupes => self.dupes,
68        }
69    }
70
71    /// The production override of one analysis: its own flag, else the global
72    /// override. `None` means that the config decides.
73    #[must_use]
74    pub const fn override_for(self, analysis: ProductionAnalysis) -> Option<bool> {
75        match self.own(analysis) {
76            Some(value) => Some(value),
77            None => self.global,
78        }
79    }
80
81    /// The production mode of one analysis as far as the flags decide, before
82    /// the config is loaded. A missing flag reads as `false`.
83    #[must_use]
84    pub const fn mode(self, analysis: ProductionAnalysis) -> bool {
85        match self.override_for(analysis) {
86            Some(value) => value,
87            None => false,
88        }
89    }
90
91    /// The production mode of each analysis as far as the flags decide.
92    #[must_use]
93    pub const fn modes(self) -> ProductionModes {
94        ProductionModes {
95            dead_code: self.mode(ProductionAnalysis::DeadCode),
96            health: self.mode(ProductionAnalysis::Health),
97            dupes: self.mode(ProductionAnalysis::Dupes),
98        }
99    }
100
101    /// The effective production mode of one analysis: the flags first, then
102    /// the `production` setting of the config.
103    #[must_use]
104    pub const fn effective(
105        self,
106        analysis: ProductionAnalysis,
107        config: fallow_config::ProductionConfig,
108    ) -> bool {
109        match self.override_for(analysis) {
110            Some(value) => value,
111            None => config.for_analysis(analysis),
112        }
113    }
114
115    /// The effective production mode of each analysis.
116    #[must_use]
117    pub const fn effective_modes(self, config: fallow_config::ProductionConfig) -> ProductionModes {
118        ProductionModes {
119            dead_code: self.effective(ProductionAnalysis::DeadCode, config),
120            health: self.effective(ProductionAnalysis::Health, config),
121            dupes: self.effective(ProductionAnalysis::Dupes, config),
122        }
123    }
124}
125
126/// The production mode of each analysis of one run.
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128pub struct ProductionModes {
129    /// Production mode of the dead-code analysis.
130    pub dead_code: bool,
131    /// Production mode of the health analysis.
132    pub health: bool,
133    /// Production mode of the duplication analysis.
134    pub dupes: bool,
135}
136
137impl ProductionModes {
138    /// Whether health can reuse the dead-code parse: both run in the same mode.
139    #[must_use]
140    pub const fn dead_code_matches_health(self) -> bool {
141        self.dead_code == self.health
142    }
143
144    /// Whether duplication can reuse the dead-code files: both run in the same
145    /// mode.
146    #[must_use]
147    pub const fn dead_code_matches_dupes(self) -> bool {
148        self.dead_code == self.dupes
149    }
150
151    /// Whether all three analyses run in the same mode.
152    #[must_use]
153    pub const fn all_match(self) -> bool {
154        self.dead_code_matches_health() && self.dead_code_matches_dupes()
155    }
156}
157
158/// Resolved project config plus the config file path when one was loaded.
159#[derive(Debug)]
160pub struct ProjectConfig {
161    /// Fully resolved config for the project.
162    pub config: ResolvedConfig,
163    /// Path of the loaded config file; `None` when defaults were used.
164    pub path: Option<PathBuf>,
165    /// Workspace metadata discovered under the project root.
166    pub workspaces: Vec<WorkspaceInfo>,
167    /// Diagnostics from workspace discovery (undeclared or invalid members).
168    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
169    /// Workspace discovery wall time in milliseconds, when measured.
170    pub workspace_discovery_ms: Option<f64>,
171    /// The plugin files, rule packs and `autoDiscover` directories that
172    /// config resolution read.
173    pub inputs: fallow_config::ConfigInputs,
174    /// The content of [`Self::inputs`] just before resolution read them.
175    pub inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
176}
177
178/// Scalar config-loading knobs for one analysis family.
179#[derive(Debug, Clone, Copy)]
180pub struct ProjectConfigOptions {
181    /// Output format the resolved config carries into rendering.
182    pub output: OutputFormat,
183    /// Bypass the parse cache for this run.
184    pub no_cache: bool,
185    /// Worker thread count recorded on the resolved config.
186    pub threads: usize,
187    /// Tri-state production override: `Some` forces production-only analysis
188    /// on or off regardless of config, `None` defers to the config value.
189    pub production_override: Option<bool>,
190    /// Suppress progress notes on stderr.
191    pub quiet: bool,
192    /// Which analysis family's per-analysis production config to flatten.
193    pub analysis: ProductionAnalysis,
194    /// Permit `extends` config inheritance from remote URLs.
195    pub allow_remote_extends: bool,
196}
197
198/// Resolved project configuration plus doctor-specific plugin diagnostics.
199#[derive(Debug)]
200#[non_exhaustive]
201pub struct ProjectConfigReadiness {
202    /// The same resolved configuration returned by [`config_for_project_analysis`].
203    pub project: ProjectConfig,
204    /// Unresolved resources named explicitly by the `plugins` config field.
205    pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
206}
207
208/// Resolve the analysis config for a project.
209///
210/// # Errors
211///
212/// Returns an error when an explicit config cannot be loaded or automatic
213/// config discovery finds an invalid config.
214pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
215    config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
216}
217
218/// Resolve project config with an explicit inheritance trust policy.
219///
220/// # Errors
221///
222/// Returns an error when config loading or validation fails.
223pub fn config_for_project_with_load_options(
224    root: &Path,
225    config_path: Option<&Path>,
226    load_options: ConfigLoadOptions,
227) -> EngineResult<ProjectConfig> {
228    let user_config = load_user_config(root, config_path, load_options)?;
229    let (mut config, path) = match user_config {
230        Some((config, path)) => (config, Some(path)),
231        None => (FallowConfig::default(), None),
232    };
233    if path.is_some() {
234        config.production = config
235            .production
236            .for_analysis(ProductionAnalysis::DeadCode)
237            .into();
238        validate_boundaries_and_rule_packs(root, &config)?;
239    }
240    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
241    let inputs = fallow_config::ConfigInputs::new(root, &config);
242    let inputs_before_resolve = inputs.snapshot();
243    let mut resolved = config.resolve(
244        root.to_path_buf(),
245        OutputFormat::Human,
246        threads,
247        false,
248        true,
249        None,
250    );
251    apply_max_file_size_env(&mut resolved);
252    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
253        collect_workspace_metadata(&resolved)?;
254    Ok(ProjectConfig {
255        inputs,
256        inputs_before_resolve,
257        config: resolved,
258        path,
259        workspaces,
260        workspace_diagnostics,
261        workspace_discovery_ms: Some(workspace_discovery_ms),
262    })
263}
264
265/// Resolve the parse-cache size limit for a resolved config.
266#[must_use]
267pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
268    config
269        .cache_max_size_mb
270        .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
271            (mb as usize).saturating_mul(1024 * 1024)
272        })
273}
274
275pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
276    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
277    let inputs = fallow_config::ConfigInputs::new(root, &FallowConfig::default());
278    let inputs_before_resolve = inputs.snapshot();
279    let config = FallowConfig::default().resolve(
280        root.to_path_buf(),
281        OutputFormat::Human,
282        threads,
283        false,
284        true,
285        None,
286    );
287    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
288        collect_workspace_metadata_lossy(&config);
289    ProjectConfig {
290        inputs,
291        inputs_before_resolve,
292        config,
293        path: None,
294        workspaces,
295        workspace_diagnostics,
296        workspace_discovery_ms: Some(workspace_discovery_ms),
297    }
298}
299
300/// Resolve config for a specific analysis without depending on the CLI crate.
301///
302/// This mirrors the CLI's core config semantics: explicit production overrides
303/// are applied before resolution, per-analysis production config is flattened
304/// for the requested analysis, and boundary / external plugin / rule-pack
305/// validation happens before the resolved config reaches the engine.
306///
307/// # Errors
308///
309/// Returns an engine error when config loading or validation fails.
310pub fn config_for_project_analysis(
311    root: &Path,
312    config_path: Option<&Path>,
313    options: ProjectConfigOptions,
314) -> EngineResult<ProjectConfig> {
315    resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
316}
317
318/// Resolve project configuration and collect typed readiness diagnostics for
319/// plugin resources named explicitly by the user configuration.
320///
321/// # Errors
322///
323/// Returns an engine error when config loading or validation fails.
324pub fn config_for_project_readiness(
325    root: &Path,
326    config_path: Option<&Path>,
327    options: ProjectConfigOptions,
328) -> EngineResult<ProjectConfigReadiness> {
329    let (project, configured_plugin_paths) =
330        resolve_project_config_analysis(root, config_path, options)?;
331    let configured_plugin_diagnostics =
332        fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
333    Ok(ProjectConfigReadiness {
334        project,
335        configured_plugin_diagnostics,
336    })
337}
338
339fn resolve_project_config_analysis(
340    root: &Path,
341    config_path: Option<&Path>,
342    options: ProjectConfigOptions,
343) -> EngineResult<(ProjectConfig, Vec<String>)> {
344    let user_config = load_user_config(
345        root,
346        config_path,
347        ConfigLoadOptions {
348            allow_remote_extends: options.allow_remote_extends,
349        },
350    )?;
351    let loaded_user_config = user_config.is_some();
352    let (mut config, path) = match user_config {
353        Some((config, path)) => (config, Some(path)),
354        None => (
355            FallowConfig {
356                production: options.production_override.unwrap_or(false).into(),
357                ..FallowConfig::default()
358            },
359            None,
360        ),
361    };
362
363    if loaded_user_config {
364        let production = ProductionFlags {
365            global: options.production_override,
366            ..ProductionFlags::default()
367        }
368        .effective(options.analysis, config.production);
369        config.production = production.into();
370    }
371    validate_config(root, &config)?;
372    let configured_plugin_paths = config.plugins.clone();
373    let inputs = fallow_config::ConfigInputs::new(root, &config);
374    let inputs_before_resolve = inputs.snapshot();
375    let mut resolved = config.resolve(
376        root.to_path_buf(),
377        options.output,
378        options.threads,
379        options.no_cache,
380        options.quiet,
381        None,
382    );
383    apply_max_file_size_env(&mut resolved);
384    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
385        collect_workspace_metadata(&resolved)?;
386    Ok((
387        ProjectConfig {
388            inputs,
389            inputs_before_resolve,
390            config: resolved,
391            path,
392            workspaces,
393            workspace_diagnostics,
394            workspace_discovery_ms: Some(workspace_discovery_ms),
395        },
396        configured_plugin_paths,
397    ))
398}
399
400fn apply_max_file_size_env(config: &mut ResolvedConfig) {
401    let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
402        .ok()
403        .and_then(|raw| raw.trim().parse::<u32>().ok());
404    if let Some(max_file_size_mb) = max_file_size_mb {
405        config.max_file_size_bytes =
406            fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
407    }
408}
409
410pub(crate) fn collect_workspace_metadata(
411    config: &ResolvedConfig,
412) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
413    let start = std::time::Instant::now();
414    let (workspaces, diagnostics) =
415        fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
416            .map_err(|err| EngineError::new(err.to_string()))?;
417    let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
418    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
419    Ok((workspaces, diagnostics, elapsed_ms))
420}
421
422fn collect_workspace_metadata_lossy(
423    config: &ResolvedConfig,
424) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
425    collect_workspace_metadata(config).unwrap_or_default()
426}
427
428fn with_undeclared_workspace_diagnostics(
429    config: &ResolvedConfig,
430    workspaces: &[WorkspaceInfo],
431    mut diagnostics: Vec<WorkspaceDiagnostic>,
432) -> Vec<WorkspaceDiagnostic> {
433    let mut existing: FxHashSet<PathBuf> = diagnostics
434        .iter()
435        .map(|diagnostic| {
436            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
437        })
438        .collect();
439    for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
440        &config.root,
441        workspaces,
442        &config.ignore_patterns,
443    ) {
444        let canonical =
445            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
446        if existing.insert(canonical) {
447            diagnostics.push(diagnostic);
448        }
449    }
450    diagnostics
451}
452
453fn load_user_config(
454    root: &Path,
455    config_path: Option<&Path>,
456    options: ConfigLoadOptions,
457) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
458    if let Some(path) = config_path {
459        let config = FallowConfig::load_with_options(path, options)
460            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
461        return Ok(Some((config, path.to_path_buf())));
462    }
463    FallowConfig::find_and_load_with_options(root, options)
464        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
465}
466
467fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
468    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
469        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
470    validate_boundaries_and_rule_packs(root, config)
471}
472
473fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
474    config
475        .validate_resolved_boundaries(root)
476        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
477    let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
478        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
479    let zone_errors = fallow_config::validate_rule_pack_zones(
480        root,
481        &config.boundaries,
482        &config.rule_packs,
483        &packs,
484    );
485    if !zone_errors.is_empty() {
486        return Err(joined_config_errors("invalid rule pack", &zone_errors));
487    }
488    Ok(())
489}
490
491fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
492    let joined = errors
493        .iter()
494        .map(ToString::to_string)
495        .collect::<Vec<_>>()
496        .join("\n  - ");
497    EngineError::new(format!("{label}:\n  - {joined}"))
498}