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::{FallowConfig, ProductionAnalysis, ResolvedConfig, WorkspaceDiagnostic};
6use fallow_types::output_format::OutputFormat;
7
8use crate::{EngineError, EngineResult, engine_error};
9
10/// Resolved project config plus the config file path when one was loaded.
11#[derive(Debug)]
12pub struct ProjectConfig {
13    pub config: ResolvedConfig,
14    pub path: Option<PathBuf>,
15    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
16}
17
18/// Scalar config-loading knobs for one analysis family.
19#[derive(Debug, Clone, Copy)]
20pub struct ProjectConfigOptions {
21    pub output: OutputFormat,
22    pub no_cache: bool,
23    pub threads: usize,
24    pub production_override: Option<bool>,
25    pub quiet: bool,
26    pub analysis: ProductionAnalysis,
27}
28
29/// Resolve the analysis config for a project.
30///
31/// # Errors
32///
33/// Returns an error when an explicit config cannot be loaded or automatic
34/// config discovery finds an invalid config.
35pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
36    fallow_core::config_for_project(root, config_path)
37        .map(|(config, path)| ProjectConfig {
38            workspace_diagnostics: collect_workspace_diagnostics(&config),
39            config,
40            path,
41        })
42        .map_err(engine_error)
43}
44
45/// Resolve the parse-cache size limit for a resolved config.
46#[must_use]
47pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
48    fallow_core::resolve_cache_max_size_bytes(config)
49}
50
51pub fn default_project_config(root: &Path) -> ProjectConfig {
52    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
53    ProjectConfig {
54        config: FallowConfig::default().resolve(
55            root.to_path_buf(),
56            OutputFormat::Human,
57            threads,
58            false,
59            true,
60            None,
61        ),
62        path: None,
63        workspace_diagnostics: Vec::new(),
64    }
65}
66
67/// Resolve config for a specific analysis without depending on the CLI crate.
68///
69/// This mirrors the CLI's core config semantics: explicit production overrides
70/// are applied before resolution, per-analysis production config is flattened
71/// for the requested analysis, and boundary / external plugin / rule-pack
72/// validation happens before the resolved config reaches the engine.
73///
74/// # Errors
75///
76/// Returns an engine error when config loading or validation fails.
77pub fn config_for_project_analysis(
78    root: &Path,
79    config_path: Option<&Path>,
80    options: ProjectConfigOptions,
81) -> EngineResult<ProjectConfig> {
82    let user_config = load_user_config(root, config_path)?;
83    let loaded_user_config = user_config.is_some();
84    let (mut config, path) = match user_config {
85        Some((config, path)) => (config, Some(path)),
86        None => (
87            FallowConfig {
88                production: options.production_override.unwrap_or(false).into(),
89                ..FallowConfig::default()
90            },
91            None,
92        ),
93    };
94
95    if loaded_user_config {
96        let production = options
97            .production_override
98            .unwrap_or_else(|| config.production.for_analysis(options.analysis));
99        config.production = production.into();
100    }
101    validate_config(root, &config)?;
102    let resolved = config.resolve(
103        root.to_path_buf(),
104        options.output,
105        options.threads,
106        options.no_cache,
107        options.quiet,
108        None,
109    );
110    Ok(ProjectConfig {
111        workspace_diagnostics: collect_workspace_diagnostics(&resolved),
112        config: resolved,
113        path,
114    })
115}
116
117fn collect_workspace_diagnostics(config: &ResolvedConfig) -> Vec<WorkspaceDiagnostic> {
118    fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
119        .map(|(_, diagnostics)| diagnostics)
120        .unwrap_or_default()
121}
122
123fn load_user_config(
124    root: &Path,
125    config_path: Option<&Path>,
126) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
127    if let Some(path) = config_path {
128        let config = FallowConfig::load(path)
129            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
130        return Ok(Some((config, path.to_path_buf())));
131    }
132    FallowConfig::find_and_load(root)
133        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
134}
135
136fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
137    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
138        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
139    config
140        .validate_resolved_boundaries(root)
141        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
142    fallow_config::load_rule_packs(root, &config.rule_packs)
143        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
144    Ok(())
145}
146
147fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
148    let joined = errors
149        .iter()
150        .map(ToString::to_string)
151        .collect::<Vec<_>>()
152        .join("\n  - ");
153    EngineError::new(format!("{label}:\n  - {joined}"))
154}