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}
172
173/// Scalar config-loading knobs for one analysis family.
174#[derive(Debug, Clone, Copy)]
175pub struct ProjectConfigOptions {
176    /// Output format the resolved config carries into rendering.
177    pub output: OutputFormat,
178    /// Bypass the parse cache for this run.
179    pub no_cache: bool,
180    /// Worker thread count recorded on the resolved config.
181    pub threads: usize,
182    /// Tri-state production override: `Some` forces production-only analysis
183    /// on or off regardless of config, `None` defers to the config value.
184    pub production_override: Option<bool>,
185    /// Suppress progress notes on stderr.
186    pub quiet: bool,
187    /// Which analysis family's per-analysis production config to flatten.
188    pub analysis: ProductionAnalysis,
189    /// Permit `extends` config inheritance from remote URLs.
190    pub allow_remote_extends: bool,
191}
192
193/// Resolved project configuration plus doctor-specific plugin diagnostics.
194#[derive(Debug)]
195#[non_exhaustive]
196pub struct ProjectConfigReadiness {
197    /// The same resolved configuration returned by [`config_for_project_analysis`].
198    pub project: ProjectConfig,
199    /// Unresolved resources named explicitly by the `plugins` config field.
200    pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
201}
202
203/// Resolve the analysis config for a project.
204///
205/// # Errors
206///
207/// Returns an error when an explicit config cannot be loaded or automatic
208/// config discovery finds an invalid config.
209pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
210    config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
211}
212
213/// Resolve project config with an explicit inheritance trust policy.
214///
215/// # Errors
216///
217/// Returns an error when config loading or validation fails.
218pub fn config_for_project_with_load_options(
219    root: &Path,
220    config_path: Option<&Path>,
221    load_options: ConfigLoadOptions,
222) -> EngineResult<ProjectConfig> {
223    let user_config = load_user_config(root, config_path, load_options)?;
224    let (mut config, path) = match user_config {
225        Some((config, path)) => (config, Some(path)),
226        None => (FallowConfig::default(), None),
227    };
228    if path.is_some() {
229        config.production = config
230            .production
231            .for_analysis(ProductionAnalysis::DeadCode)
232            .into();
233        validate_boundaries_and_rule_packs(root, &config)?;
234    }
235    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
236    let mut resolved = config.resolve(
237        root.to_path_buf(),
238        OutputFormat::Human,
239        threads,
240        false,
241        true,
242        None,
243    );
244    apply_max_file_size_env(&mut resolved);
245    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
246        collect_workspace_metadata(&resolved)?;
247    Ok(ProjectConfig {
248        config: resolved,
249        path,
250        workspaces,
251        workspace_diagnostics,
252        workspace_discovery_ms: Some(workspace_discovery_ms),
253    })
254}
255
256/// Resolve the parse-cache size limit for a resolved config.
257#[must_use]
258pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
259    config
260        .cache_max_size_mb
261        .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
262            (mb as usize).saturating_mul(1024 * 1024)
263        })
264}
265
266pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
267    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
268    let config = FallowConfig::default().resolve(
269        root.to_path_buf(),
270        OutputFormat::Human,
271        threads,
272        false,
273        true,
274        None,
275    );
276    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
277        collect_workspace_metadata_lossy(&config);
278    ProjectConfig {
279        config,
280        path: None,
281        workspaces,
282        workspace_diagnostics,
283        workspace_discovery_ms: Some(workspace_discovery_ms),
284    }
285}
286
287/// Resolve config for a specific analysis without depending on the CLI crate.
288///
289/// This mirrors the CLI's core config semantics: explicit production overrides
290/// are applied before resolution, per-analysis production config is flattened
291/// for the requested analysis, and boundary / external plugin / rule-pack
292/// validation happens before the resolved config reaches the engine.
293///
294/// # Errors
295///
296/// Returns an engine error when config loading or validation fails.
297pub fn config_for_project_analysis(
298    root: &Path,
299    config_path: Option<&Path>,
300    options: ProjectConfigOptions,
301) -> EngineResult<ProjectConfig> {
302    resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
303}
304
305/// Resolve project configuration and collect typed readiness diagnostics for
306/// plugin resources named explicitly by the user configuration.
307///
308/// # Errors
309///
310/// Returns an engine error when config loading or validation fails.
311pub fn config_for_project_readiness(
312    root: &Path,
313    config_path: Option<&Path>,
314    options: ProjectConfigOptions,
315) -> EngineResult<ProjectConfigReadiness> {
316    let (project, configured_plugin_paths) =
317        resolve_project_config_analysis(root, config_path, options)?;
318    let configured_plugin_diagnostics =
319        fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
320    Ok(ProjectConfigReadiness {
321        project,
322        configured_plugin_diagnostics,
323    })
324}
325
326fn resolve_project_config_analysis(
327    root: &Path,
328    config_path: Option<&Path>,
329    options: ProjectConfigOptions,
330) -> EngineResult<(ProjectConfig, Vec<String>)> {
331    let user_config = load_user_config(
332        root,
333        config_path,
334        ConfigLoadOptions {
335            allow_remote_extends: options.allow_remote_extends,
336        },
337    )?;
338    let loaded_user_config = user_config.is_some();
339    let (mut config, path) = match user_config {
340        Some((config, path)) => (config, Some(path)),
341        None => (
342            FallowConfig {
343                production: options.production_override.unwrap_or(false).into(),
344                ..FallowConfig::default()
345            },
346            None,
347        ),
348    };
349
350    if loaded_user_config {
351        let production = ProductionFlags {
352            global: options.production_override,
353            ..ProductionFlags::default()
354        }
355        .effective(options.analysis, config.production);
356        config.production = production.into();
357    }
358    validate_config(root, &config)?;
359    let configured_plugin_paths = config.plugins.clone();
360    let mut resolved = config.resolve(
361        root.to_path_buf(),
362        options.output,
363        options.threads,
364        options.no_cache,
365        options.quiet,
366        None,
367    );
368    apply_max_file_size_env(&mut resolved);
369    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
370        collect_workspace_metadata(&resolved)?;
371    Ok((
372        ProjectConfig {
373            config: resolved,
374            path,
375            workspaces,
376            workspace_diagnostics,
377            workspace_discovery_ms: Some(workspace_discovery_ms),
378        },
379        configured_plugin_paths,
380    ))
381}
382
383fn apply_max_file_size_env(config: &mut ResolvedConfig) {
384    let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
385        .ok()
386        .and_then(|raw| raw.trim().parse::<u32>().ok());
387    if let Some(max_file_size_mb) = max_file_size_mb {
388        config.max_file_size_bytes =
389            fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
390    }
391}
392
393pub(crate) fn collect_workspace_metadata(
394    config: &ResolvedConfig,
395) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
396    let start = std::time::Instant::now();
397    let (workspaces, diagnostics) =
398        fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
399            .map_err(|err| EngineError::new(err.to_string()))?;
400    let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
401    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
402    Ok((workspaces, diagnostics, elapsed_ms))
403}
404
405fn collect_workspace_metadata_lossy(
406    config: &ResolvedConfig,
407) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
408    collect_workspace_metadata(config).unwrap_or_default()
409}
410
411fn with_undeclared_workspace_diagnostics(
412    config: &ResolvedConfig,
413    workspaces: &[WorkspaceInfo],
414    mut diagnostics: Vec<WorkspaceDiagnostic>,
415) -> Vec<WorkspaceDiagnostic> {
416    let mut existing: FxHashSet<PathBuf> = diagnostics
417        .iter()
418        .map(|diagnostic| {
419            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
420        })
421        .collect();
422    for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
423        &config.root,
424        workspaces,
425        &config.ignore_patterns,
426    ) {
427        let canonical =
428            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
429        if existing.insert(canonical) {
430            diagnostics.push(diagnostic);
431        }
432    }
433    diagnostics
434}
435
436fn load_user_config(
437    root: &Path,
438    config_path: Option<&Path>,
439    options: ConfigLoadOptions,
440) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
441    if let Some(path) = config_path {
442        let config = FallowConfig::load_with_options(path, options)
443            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
444        return Ok(Some((config, path.to_path_buf())));
445    }
446    FallowConfig::find_and_load_with_options(root, options)
447        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
448}
449
450fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
451    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
452        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
453    validate_boundaries_and_rule_packs(root, config)
454}
455
456fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
457    config
458        .validate_resolved_boundaries(root)
459        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
460    let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
461        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
462    let zone_errors = fallow_config::validate_rule_pack_zones(
463        root,
464        &config.boundaries,
465        &config.rule_packs,
466        &packs,
467    );
468    if !zone_errors.is_empty() {
469        return Err(joined_config_errors("invalid rule pack", &zone_errors));
470    }
471    Ok(())
472}
473
474fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
475    let joined = errors
476        .iter()
477        .map(ToString::to_string)
478        .collect::<Vec<_>>()
479        .join("\n  - ");
480    EngineError::new(format!("{label}:\n  - {joined}"))
481}