fallow-engine 3.30.0

Typed analysis engine facade for fallow consumers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
//! Project config resolution owned by the engine boundary.

use std::path::{Path, PathBuf};

use fallow_config::{
    ConfigLoadOptions, FallowConfig, ProductionAnalysis, ResolvedConfig, WorkspaceDiagnostic,
    WorkspaceInfo,
};
use fallow_types::output_format::OutputFormat;
use rustc_hash::FxHashSet;

use crate::{EngineError, EngineResult};

/// The production flags of one run: the global `--production` override and one
/// override per analysis (`--production-dead-code`, `--production-health`,
/// `--production-dupes`).
///
/// Every command and surface that runs more than one analysis reads the
/// production mode of each analysis from here, so the precedence has one
/// implementation: the flag of the analysis, then the global override, then
/// the config.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProductionFlags {
    /// The global override. The CLI sets `Some(true)` for `--production` and
    /// `None` without it. The programmatic API can also pass `Some(false)`.
    pub global: Option<bool>,
    /// Override for the dead-code analysis.
    pub dead_code: Option<bool>,
    /// Override for the health analysis.
    pub health: Option<bool>,
    /// Override for the duplication analysis.
    pub dupes: Option<bool>,
}

impl ProductionFlags {
    /// The flags of a CLI run, where `--production` can only switch the mode
    /// on.
    #[must_use]
    pub const fn from_cli(
        production: bool,
        dead_code: Option<bool>,
        health: Option<bool>,
        dupes: Option<bool>,
    ) -> Self {
        Self {
            global: if production { Some(true) } else { None },
            dead_code,
            health,
            dupes,
        }
    }

    /// The production override of a command that runs one analysis
    /// (`dead-code`, `dupes`, `health`): its own override, else
    /// `--production`. `None` means that the config decides.
    #[must_use]
    pub const fn single_analysis_override(production: bool, own: Option<bool>) -> Option<bool> {
        Self::from_cli(production, own, None, None).override_for(ProductionAnalysis::DeadCode)
    }

    /// The override flag of one analysis, without the global override.
    #[must_use]
    pub const fn own(self, analysis: ProductionAnalysis) -> Option<bool> {
        match analysis {
            ProductionAnalysis::DeadCode => self.dead_code,
            ProductionAnalysis::Health => self.health,
            ProductionAnalysis::Dupes => self.dupes,
        }
    }

    /// The production override of one analysis: its own flag, else the global
    /// override. `None` means that the config decides.
    #[must_use]
    pub const fn override_for(self, analysis: ProductionAnalysis) -> Option<bool> {
        match self.own(analysis) {
            Some(value) => Some(value),
            None => self.global,
        }
    }

    /// The production mode of one analysis as far as the flags decide, before
    /// the config is loaded. A missing flag reads as `false`.
    #[must_use]
    pub const fn mode(self, analysis: ProductionAnalysis) -> bool {
        match self.override_for(analysis) {
            Some(value) => value,
            None => false,
        }
    }

    /// The production mode of each analysis as far as the flags decide.
    #[must_use]
    pub const fn modes(self) -> ProductionModes {
        ProductionModes {
            dead_code: self.mode(ProductionAnalysis::DeadCode),
            health: self.mode(ProductionAnalysis::Health),
            dupes: self.mode(ProductionAnalysis::Dupes),
        }
    }

    /// The effective production mode of one analysis: the flags first, then
    /// the `production` setting of the config.
    #[must_use]
    pub const fn effective(
        self,
        analysis: ProductionAnalysis,
        config: fallow_config::ProductionConfig,
    ) -> bool {
        match self.override_for(analysis) {
            Some(value) => value,
            None => config.for_analysis(analysis),
        }
    }

    /// The effective production mode of each analysis.
    #[must_use]
    pub const fn effective_modes(self, config: fallow_config::ProductionConfig) -> ProductionModes {
        ProductionModes {
            dead_code: self.effective(ProductionAnalysis::DeadCode, config),
            health: self.effective(ProductionAnalysis::Health, config),
            dupes: self.effective(ProductionAnalysis::Dupes, config),
        }
    }
}

/// The production mode of each analysis of one run.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProductionModes {
    /// Production mode of the dead-code analysis.
    pub dead_code: bool,
    /// Production mode of the health analysis.
    pub health: bool,
    /// Production mode of the duplication analysis.
    pub dupes: bool,
}

impl ProductionModes {
    /// Whether health can reuse the dead-code parse: both run in the same mode.
    #[must_use]
    pub const fn dead_code_matches_health(self) -> bool {
        self.dead_code == self.health
    }

    /// Whether duplication can reuse the dead-code files: both run in the same
    /// mode.
    #[must_use]
    pub const fn dead_code_matches_dupes(self) -> bool {
        self.dead_code == self.dupes
    }

    /// Whether all three analyses run in the same mode.
    #[must_use]
    pub const fn all_match(self) -> bool {
        self.dead_code_matches_health() && self.dead_code_matches_dupes()
    }
}

/// Resolved project config plus the config file path when one was loaded.
#[derive(Debug)]
pub struct ProjectConfig {
    /// Fully resolved config for the project.
    pub config: ResolvedConfig,
    /// Path of the loaded config file; `None` when defaults were used.
    pub path: Option<PathBuf>,
    /// Workspace metadata discovered under the project root.
    pub workspaces: Vec<WorkspaceInfo>,
    /// Diagnostics from workspace discovery (undeclared or invalid members).
    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
    /// Workspace discovery wall time in milliseconds, when measured.
    pub workspace_discovery_ms: Option<f64>,
    /// The plugin files, rule packs and `autoDiscover` directories that
    /// config resolution read.
    pub inputs: fallow_config::ConfigInputs,
    /// The content of [`Self::inputs`] just before resolution read them.
    pub inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
}

/// Scalar config-loading knobs for one analysis family.
#[derive(Debug, Clone, Copy)]
pub struct ProjectConfigOptions {
    /// Output format the resolved config carries into rendering.
    pub output: OutputFormat,
    /// Bypass the parse cache for this run.
    pub no_cache: bool,
    /// Worker thread count recorded on the resolved config.
    pub threads: usize,
    /// Tri-state production override: `Some` forces production-only analysis
    /// on or off regardless of config, `None` defers to the config value.
    pub production_override: Option<bool>,
    /// Suppress progress notes on stderr.
    pub quiet: bool,
    /// Which analysis family's per-analysis production config to flatten.
    pub analysis: ProductionAnalysis,
    /// Permit `extends` config inheritance from remote URLs.
    pub allow_remote_extends: bool,
}

/// Resolved project configuration plus doctor-specific plugin diagnostics.
#[derive(Debug)]
#[non_exhaustive]
pub struct ProjectConfigReadiness {
    /// The same resolved configuration returned by [`config_for_project_analysis`].
    pub project: ProjectConfig,
    /// Unresolved resources named explicitly by the `plugins` config field.
    pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
}

/// Resolve the analysis config for a project.
///
/// # Errors
///
/// Returns an error when an explicit config cannot be loaded or automatic
/// config discovery finds an invalid config.
pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
    config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
}

/// Resolve project config with an explicit inheritance trust policy.
///
/// # Errors
///
/// Returns an error when config loading or validation fails.
pub fn config_for_project_with_load_options(
    root: &Path,
    config_path: Option<&Path>,
    load_options: ConfigLoadOptions,
) -> EngineResult<ProjectConfig> {
    let user_config = load_user_config(root, config_path, load_options)?;
    let (mut config, path) = match user_config {
        Some((config, path)) => (config, Some(path)),
        None => (FallowConfig::default(), None),
    };
    if path.is_some() {
        config.production = config
            .production
            .for_analysis(ProductionAnalysis::DeadCode)
            .into();
        validate_boundaries_and_rule_packs(root, &config)?;
    }
    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
    let inputs = fallow_config::ConfigInputs::new(root, &config);
    let inputs_before_resolve = inputs.snapshot();
    let mut resolved = config.resolve(
        root.to_path_buf(),
        OutputFormat::Human,
        threads,
        false,
        true,
        None,
    );
    apply_max_file_size_env(&mut resolved);
    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
        collect_workspace_metadata(&resolved)?;
    Ok(ProjectConfig {
        inputs,
        inputs_before_resolve,
        config: resolved,
        path,
        workspaces,
        workspace_diagnostics,
        workspace_discovery_ms: Some(workspace_discovery_ms),
    })
}

/// Resolve the parse-cache size limit for a resolved config.
#[must_use]
pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
    config
        .cache_max_size_mb
        .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
            (mb as usize).saturating_mul(1024 * 1024)
        })
}

pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
    let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
    let inputs = fallow_config::ConfigInputs::new(root, &FallowConfig::default());
    let inputs_before_resolve = inputs.snapshot();
    let config = FallowConfig::default().resolve(
        root.to_path_buf(),
        OutputFormat::Human,
        threads,
        false,
        true,
        None,
    );
    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
        collect_workspace_metadata_lossy(&config);
    ProjectConfig {
        inputs,
        inputs_before_resolve,
        config,
        path: None,
        workspaces,
        workspace_diagnostics,
        workspace_discovery_ms: Some(workspace_discovery_ms),
    }
}

/// Resolve config for a specific analysis without depending on the CLI crate.
///
/// This mirrors the CLI's core config semantics: explicit production overrides
/// are applied before resolution, per-analysis production config is flattened
/// for the requested analysis, and boundary / external plugin / rule-pack
/// validation happens before the resolved config reaches the engine.
///
/// # Errors
///
/// Returns an engine error when config loading or validation fails.
pub fn config_for_project_analysis(
    root: &Path,
    config_path: Option<&Path>,
    options: ProjectConfigOptions,
) -> EngineResult<ProjectConfig> {
    resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
}

/// Resolve project configuration and collect typed readiness diagnostics for
/// plugin resources named explicitly by the user configuration.
///
/// # Errors
///
/// Returns an engine error when config loading or validation fails.
pub fn config_for_project_readiness(
    root: &Path,
    config_path: Option<&Path>,
    options: ProjectConfigOptions,
) -> EngineResult<ProjectConfigReadiness> {
    let (project, configured_plugin_paths) =
        resolve_project_config_analysis(root, config_path, options)?;
    let configured_plugin_diagnostics =
        fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
    Ok(ProjectConfigReadiness {
        project,
        configured_plugin_diagnostics,
    })
}

fn resolve_project_config_analysis(
    root: &Path,
    config_path: Option<&Path>,
    options: ProjectConfigOptions,
) -> EngineResult<(ProjectConfig, Vec<String>)> {
    let user_config = load_user_config(
        root,
        config_path,
        ConfigLoadOptions {
            allow_remote_extends: options.allow_remote_extends,
        },
    )?;
    let loaded_user_config = user_config.is_some();
    let (mut config, path) = match user_config {
        Some((config, path)) => (config, Some(path)),
        None => (
            FallowConfig {
                production: options.production_override.unwrap_or(false).into(),
                ..FallowConfig::default()
            },
            None,
        ),
    };

    if loaded_user_config {
        let production = ProductionFlags {
            global: options.production_override,
            ..ProductionFlags::default()
        }
        .effective(options.analysis, config.production);
        config.production = production.into();
    }
    validate_config(root, &config)?;
    let configured_plugin_paths = config.plugins.clone();
    let inputs = fallow_config::ConfigInputs::new(root, &config);
    let inputs_before_resolve = inputs.snapshot();
    let mut resolved = config.resolve(
        root.to_path_buf(),
        options.output,
        options.threads,
        options.no_cache,
        options.quiet,
        None,
    );
    apply_max_file_size_env(&mut resolved);
    let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
        collect_workspace_metadata(&resolved)?;
    Ok((
        ProjectConfig {
            inputs,
            inputs_before_resolve,
            config: resolved,
            path,
            workspaces,
            workspace_diagnostics,
            workspace_discovery_ms: Some(workspace_discovery_ms),
        },
        configured_plugin_paths,
    ))
}

fn apply_max_file_size_env(config: &mut ResolvedConfig) {
    let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
        .ok()
        .and_then(|raw| raw.trim().parse::<u32>().ok());
    if let Some(max_file_size_mb) = max_file_size_mb {
        config.max_file_size_bytes =
            fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
    }
}

pub(crate) fn collect_workspace_metadata(
    config: &ResolvedConfig,
) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
    let start = std::time::Instant::now();
    let (workspaces, diagnostics) =
        fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
            .map_err(|err| EngineError::new(err.to_string()))?;
    let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
    Ok((workspaces, diagnostics, elapsed_ms))
}

fn collect_workspace_metadata_lossy(
    config: &ResolvedConfig,
) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
    collect_workspace_metadata(config).unwrap_or_default()
}

fn with_undeclared_workspace_diagnostics(
    config: &ResolvedConfig,
    workspaces: &[WorkspaceInfo],
    mut diagnostics: Vec<WorkspaceDiagnostic>,
) -> Vec<WorkspaceDiagnostic> {
    let mut existing: FxHashSet<PathBuf> = diagnostics
        .iter()
        .map(|diagnostic| {
            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
        })
        .collect();
    for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
        &config.root,
        workspaces,
        &config.ignore_patterns,
    ) {
        let canonical =
            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
        if existing.insert(canonical) {
            diagnostics.push(diagnostic);
        }
    }
    diagnostics
}

fn load_user_config(
    root: &Path,
    config_path: Option<&Path>,
    options: ConfigLoadOptions,
) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
    if let Some(path) = config_path {
        let config = FallowConfig::load_with_options(path, options)
            .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
        return Ok(Some((config, path.to_path_buf())));
    }
    FallowConfig::find_and_load_with_options(root, options)
        .map_err(|err| EngineError::new(format!("invalid config: {err}")))
}

fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
    fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
        .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
    validate_boundaries_and_rule_packs(root, config)
}

fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
    config
        .validate_resolved_boundaries(root)
        .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
    let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
        .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
    let zone_errors = fallow_config::validate_rule_pack_zones(
        root,
        &config.boundaries,
        &config.rule_packs,
        &packs,
    );
    if !zone_errors.is_empty() {
        return Err(joined_config_errors("invalid rule pack", &zone_errors));
    }
    Ok(())
}

fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
    let joined = errors
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join("\n  - ");
    EngineError::new(format!("{label}:\n  - {joined}"))
}