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/// Resolved project configuration plus doctor-specific plugin diagnostics.
50#[derive(Debug)]
51#[non_exhaustive]
52pub struct ProjectConfigReadiness {
53    /// The same resolved configuration returned by [`config_for_project_analysis`].
54    pub project: ProjectConfig,
55    /// Unresolved resources named explicitly by the `plugins` config field.
56    pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
57}
58
59/// Resolve the analysis config for a project.
60///
61/// # Errors
62///
63/// Returns an error when an explicit config cannot be loaded or automatic
64/// config discovery finds an invalid config.
65pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
66    config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
67}
68
69/// Resolve project config with an explicit inheritance trust policy.
70///
71/// # Errors
72///
73/// Returns an error when config loading or validation fails.
74pub fn config_for_project_with_load_options(
75    root: &Path,
76    config_path: Option<&Path>,
77    load_options: ConfigLoadOptions,
78) -> EngineResult<ProjectConfig> {
79    let user_config = load_user_config(root, config_path, load_options)?;
80    let (mut config, path) = match user_config {
81        Some((config, path)) => (config, Some(path)),
82        None => (FallowConfig::default(), None),
83    };
84    if path.is_some() {
85        config.production = config
86            .production
87            .for_analysis(ProductionAnalysis::DeadCode)
88            .into();
89        validate_boundaries_and_rule_packs(root, &config)?;
90    }
91    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
92    let mut resolved = config.resolve(
93        root.to_path_buf(),
94        OutputFormat::Human,
95        threads,
96        false,
97        true,
98        None,
99    );
100    apply_max_file_size_env(&mut resolved);
101    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
102        collect_workspace_metadata(&resolved)?;
103    Ok(ProjectConfig {
104        config: resolved,
105        path,
106        workspaces,
107        workspace_diagnostics,
108        workspace_discovery_ms: Some(workspace_discovery_ms),
109    })
110}
111
112/// Resolve the parse-cache size limit for a resolved config.
113#[must_use]
114pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
115    config
116        .cache_max_size_mb
117        .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
118            (mb as usize).saturating_mul(1024 * 1024)
119        })
120}
121
122pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
123    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
124    let config = FallowConfig::default().resolve(
125        root.to_path_buf(),
126        OutputFormat::Human,
127        threads,
128        false,
129        true,
130        None,
131    );
132    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
133        collect_workspace_metadata_lossy(&config);
134    ProjectConfig {
135        config,
136        path: None,
137        workspaces,
138        workspace_diagnostics,
139        workspace_discovery_ms: Some(workspace_discovery_ms),
140    }
141}
142
143/// Resolve config for a specific analysis without depending on the CLI crate.
144///
145/// This mirrors the CLI's core config semantics: explicit production overrides
146/// are applied before resolution, per-analysis production config is flattened
147/// for the requested analysis, and boundary / external plugin / rule-pack
148/// validation happens before the resolved config reaches the engine.
149///
150/// # Errors
151///
152/// Returns an engine error when config loading or validation fails.
153pub fn config_for_project_analysis(
154    root: &Path,
155    config_path: Option<&Path>,
156    options: ProjectConfigOptions,
157) -> EngineResult<ProjectConfig> {
158    resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
159}
160
161/// Resolve project configuration and collect typed readiness diagnostics for
162/// plugin resources named explicitly by the user configuration.
163///
164/// # Errors
165///
166/// Returns an engine error when config loading or validation fails.
167pub fn config_for_project_readiness(
168    root: &Path,
169    config_path: Option<&Path>,
170    options: ProjectConfigOptions,
171) -> EngineResult<ProjectConfigReadiness> {
172    let (project, configured_plugin_paths) =
173        resolve_project_config_analysis(root, config_path, options)?;
174    let configured_plugin_diagnostics =
175        fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
176    Ok(ProjectConfigReadiness {
177        project,
178        configured_plugin_diagnostics,
179    })
180}
181
182fn resolve_project_config_analysis(
183    root: &Path,
184    config_path: Option<&Path>,
185    options: ProjectConfigOptions,
186) -> EngineResult<(ProjectConfig, Vec<String>)> {
187    let user_config = load_user_config(
188        root,
189        config_path,
190        ConfigLoadOptions {
191            allow_remote_extends: options.allow_remote_extends,
192        },
193    )?;
194    let loaded_user_config = user_config.is_some();
195    let (mut config, path) = match user_config {
196        Some((config, path)) => (config, Some(path)),
197        None => (
198            FallowConfig {
199                production: options.production_override.unwrap_or(false).into(),
200                ..FallowConfig::default()
201            },
202            None,
203        ),
204    };
205
206    if loaded_user_config {
207        let production = options
208            .production_override
209            .unwrap_or_else(|| config.production.for_analysis(options.analysis));
210        config.production = production.into();
211    }
212    validate_config(root, &config)?;
213    let configured_plugin_paths = config.plugins.clone();
214    let mut resolved = config.resolve(
215        root.to_path_buf(),
216        options.output,
217        options.threads,
218        options.no_cache,
219        options.quiet,
220        None,
221    );
222    apply_max_file_size_env(&mut resolved);
223    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
224        collect_workspace_metadata(&resolved)?;
225    Ok((
226        ProjectConfig {
227            config: resolved,
228            path,
229            workspaces,
230            workspace_diagnostics,
231            workspace_discovery_ms: Some(workspace_discovery_ms),
232        },
233        configured_plugin_paths,
234    ))
235}
236
237fn apply_max_file_size_env(config: &mut ResolvedConfig) {
238    let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
239        .ok()
240        .and_then(|raw| raw.trim().parse::<u32>().ok());
241    if let Some(max_file_size_mb) = max_file_size_mb {
242        config.max_file_size_bytes =
243            fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
244    }
245}
246
247pub(crate) fn collect_workspace_metadata(
248    config: &ResolvedConfig,
249) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
250    let start = std::time::Instant::now();
251    let (workspaces, diagnostics) =
252        fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
253            .map_err(|err| EngineError::new(err.to_string()))?;
254    let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
255    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
256    Ok((workspaces, diagnostics, elapsed_ms))
257}
258
259fn collect_workspace_metadata_lossy(
260    config: &ResolvedConfig,
261) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
262    collect_workspace_metadata(config).unwrap_or_default()
263}
264
265fn with_undeclared_workspace_diagnostics(
266    config: &ResolvedConfig,
267    workspaces: &[WorkspaceInfo],
268    mut diagnostics: Vec<WorkspaceDiagnostic>,
269) -> Vec<WorkspaceDiagnostic> {
270    let mut existing: FxHashSet<PathBuf> = diagnostics
271        .iter()
272        .map(|diagnostic| {
273            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
274        })
275        .collect();
276    for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
277        &config.root,
278        workspaces,
279        &config.ignore_patterns,
280    ) {
281        let canonical =
282            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
283        if existing.insert(canonical) {
284            diagnostics.push(diagnostic);
285        }
286    }
287    diagnostics
288}
289
290fn load_user_config(
291    root: &Path,
292    config_path: Option<&Path>,
293    options: ConfigLoadOptions,
294) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
295    if let Some(path) = config_path {
296        let config = FallowConfig::load_with_options(path, options)
297            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
298        return Ok(Some((config, path.to_path_buf())));
299    }
300    FallowConfig::find_and_load_with_options(root, options)
301        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
302}
303
304fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
305    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
306        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
307    validate_boundaries_and_rule_packs(root, config)
308}
309
310fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
311    config
312        .validate_resolved_boundaries(root)
313        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
314    let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
315        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
316    let boundaries =
317        fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
318    let zone_errors = fallow_config::validate_rule_pack_zone_references(
319        root,
320        &config.rule_packs,
321        &packs,
322        &boundaries,
323    );
324    if !zone_errors.is_empty() {
325        return Err(joined_config_errors("invalid rule pack", &zone_errors));
326    }
327    Ok(())
328}
329
330fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
331    let joined = errors
332        .iter()
333        .map(ToString::to_string)
334        .collect::<Vec<_>>()
335        .join("\n  - ");
336    EngineError::new(format!("{label}:\n  - {joined}"))
337}