Skip to main content

fallow_engine/
project_config.rs

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