Skip to main content

fallow_api/runtime/
mod.rs

1//! Programmatic runtime entry points that avoid depending on `fallow-cli`.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{FallowConfig, HealthConfig, ProductionAnalysis, ProductionConfig};
6use fallow_engine::{
7    dead_code::DeadCodeAnalysisArtifacts, duplicates::DuplicationReport, session::AnalysisSession,
8};
9use fallow_output::{HealthGrouping, HealthReport, RootEnvelopeMode};
10use fallow_types::output_format::OutputFormat;
11use fallow_types::workspace::WorkspaceDiagnostic;
12use rustc_hash::FxHashSet;
13
14mod audit;
15mod combined;
16mod dead_code;
17mod decision_surface;
18mod duplication;
19mod feature_flags;
20mod similar_code;
21mod trace;
22
23pub use crate::runtime_output::{
24    AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
25    BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
26    CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
27    DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
28    DuplicationProgrammaticOutput, FeatureFlagsOutput, FeatureFlagsProgrammaticOutput,
29    HealthJsonReportInput, HealthProgrammaticOutput, TraceClassMemberOutput, TraceCloneOutput,
30    TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
31    TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
32    TraceFileProgrammaticOutput, serialize_health_report_json,
33};
34pub use audit::run_audit;
35pub use combined::run_combined;
36pub use dead_code::{run_boundary_violations, run_circular_dependencies, run_dead_code};
37pub use decision_surface::run_decision_surface;
38pub use duplication::run_duplication;
39pub use feature_flags::run_feature_flags;
40pub use similar_code::{
41    inspect_similar_code, parse_similar_code_candidate_snapshot, review_similar_code,
42    run_similar_code, select_similar_code_candidate_snapshot,
43};
44pub use trace::{
45    TraceCloneBenchmarkResult, benchmark_trace_clone_compact_json,
46    benchmark_trace_graph_family_compact_json, run_trace_clone, run_trace_dependency,
47    run_trace_export, run_trace_file,
48};
49
50use crate::{
51    ComplexityOptions, ProgrammaticError,
52    analysis_context::{
53        ProgrammaticAnalysisContext, resolve_programmatic_analysis_context,
54        workspace_roots_for_session,
55    },
56    derive_complexity_options,
57    next_steps::{setup_pointer_applicable, suggestions_enabled},
58};
59
60type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(super) struct EffectiveProductionModes {
64    pub dead_code: bool,
65    pub health: bool,
66    pub dupes: bool,
67}
68
69pub(super) fn resolve_effective_production_modes(
70    resolved: &ProgrammaticAnalysisContext,
71    dead_code_override: Option<bool>,
72    health_override: Option<bool>,
73    dupes_override: Option<bool>,
74) -> ProgrammaticResult<EffectiveProductionModes> {
75    let config = load_context_production_config(resolved)?;
76    Ok(EffectiveProductionModes {
77        dead_code: effective_production_mode(
78            config,
79            ProductionAnalysis::DeadCode,
80            resolved,
81            dead_code_override,
82        ),
83        health: effective_production_mode(
84            config,
85            ProductionAnalysis::Health,
86            resolved,
87            health_override,
88        ),
89        dupes: effective_production_mode(
90            config,
91            ProductionAnalysis::Dupes,
92            resolved,
93            dupes_override,
94        ),
95    })
96}
97
98fn effective_production_mode(
99    config: ProductionConfig,
100    analysis: ProductionAnalysis,
101    resolved: &ProgrammaticAnalysisContext,
102    analysis_override: Option<bool>,
103) -> bool {
104    analysis_override
105        .or_else(|| resolved.production_override())
106        .unwrap_or_else(|| config.for_analysis(analysis))
107}
108
109fn load_context_production_config(
110    resolved: &ProgrammaticAnalysisContext,
111) -> ProgrammaticResult<ProductionConfig> {
112    let loaded = load_config_file(
113        resolved.root(),
114        resolved.config_path().as_deref(),
115        resolved.allow_remote_extends(),
116    )?;
117    Ok(loaded.map_or_else(ProductionConfig::default, |config| config.production))
118}
119
120/// Load the `health` section of the project config for adapters that layer
121/// config-sourced coverage inputs before building options (the MCP typed
122/// route; see [`crate::coverage`]). The root and config path resolve exactly
123/// as they do for the analysis itself. Returns `None` when the project has no
124/// config file.
125///
126/// # Errors
127///
128/// Returns the analysis-context errors for an invalid root or config path,
129/// and `FALLOW_CONFIG_LOAD_FAILED` when the config cannot be loaded.
130pub fn load_health_config(
131    options: &crate::AnalysisOptions,
132) -> ProgrammaticResult<Option<HealthConfig>> {
133    let root = crate::analysis_context::resolve_analysis_root(options.root.as_deref())?;
134    crate::analysis_context::validate_analysis_config_path(options.config_path.as_deref())?;
135    let loaded = load_config_file(
136        &root,
137        options.config_path.as_deref(),
138        options.allow_remote_extends,
139    )?;
140    Ok(loaded.map(|config| config.health))
141}
142
143fn load_config_file(
144    root: &Path,
145    config_path: Option<&Path>,
146    allow_remote_extends: bool,
147) -> ProgrammaticResult<Option<FallowConfig>> {
148    let load_options = fallow_config::ConfigLoadOptions {
149        allow_remote_extends,
150    };
151    if let Some(path) = config_path {
152        return FallowConfig::load_with_options(path, load_options)
153            .map(Some)
154            .map_err(|err| config_load_error(format!("failed to load config: {err:#}")));
155    }
156    FallowConfig::find_and_load_with_options(root, load_options)
157        .map(|found| found.map(|(config, _)| config))
158        .map_err(|err| config_load_error(format!("failed to load config: {err}")))
159}
160
161fn config_load_error(message: String) -> ProgrammaticError {
162    ProgrammaticError::new(message, 2)
163        .with_code("FALLOW_CONFIG_LOAD_FAILED")
164        .with_context("analysis.configPath")
165}
166
167pub(super) fn health_may_consume_dead_code_artifacts(
168    options: &ComplexityOptions,
169    config: &fallow_config::ResolvedConfig,
170) -> bool {
171    let sections = derive_complexity_options(options);
172    let max_crap = options.max_crap.unwrap_or(config.health.max_crap);
173    sections.file_scores
174        || sections.coverage_gaps
175        || sections.hotspots
176        || sections.targets
177        || sections.force_full
178        || max_crap > 0.0
179}
180
181pub(super) fn health_may_consume_duplication_report(options: &ComplexityOptions) -> bool {
182    let sections = derive_complexity_options(options);
183    sections.score || sections.targets
184}
185
186/// Runtime probes used by programmatic health output assembly.
187///
188/// Concrete runners supply environment and project facts while the stable
189/// command strings and output ordering remain owned by `fallow-output`.
190pub struct ProgrammaticHealthNextStepFacts {
191    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
192    pub suggestions_enabled: bool,
193    /// Offer the guided-setup pointer because no fallow config exists yet.
194    pub offer_setup: bool,
195    /// Local impact digest counters, when a digest is available.
196    pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
197    /// Offer `fallow audit` because the working tree has changed files.
198    pub audit_changed: bool,
199}
200
201/// API-owned health analysis payload returned by programmatic runners.
202///
203/// The engine owns execution, but this type is the public runner contract so
204/// embedders do not have to construct or depend on engine result structs.
205pub struct ProgrammaticHealthAnalysis {
206    /// Typed health report produced by the engine.
207    pub report: HealthReport,
208    /// Grouped findings when a grouping mode was requested.
209    pub grouping: Option<HealthGrouping>,
210    /// Resolved analysis root the report paths are relative to.
211    pub root: PathBuf,
212    /// Analysis wall time.
213    pub elapsed: std::time::Duration,
214}
215
216impl ProgrammaticHealthAnalysis {
217    fn from_engine<GroupResolver>(
218        analysis: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
219    ) -> Self {
220        Self {
221            root: analysis.config.root,
222            report: analysis.report,
223            grouping: analysis.grouping,
224            elapsed: analysis.elapsed,
225        }
226    }
227}
228
229/// Health runner output shared by API, NAPI, and alternate runners.
230///
231/// Runtime-only presentation probes stay explicit so the API boundary, not the
232/// concrete runner, owns the final programmatic report assembly.
233pub struct ProgrammaticHealthRun {
234    /// Engine analysis payload.
235    pub analysis: ProgrammaticHealthAnalysis,
236    /// Non-fatal per-file diagnostics collected during the workspace walk.
237    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
238    /// Environment and project facts that drive next-step suggestions.
239    pub next_step_facts: ProgrammaticHealthNextStepFacts,
240    /// Analysis run id stamped into telemetry metadata when present.
241    pub telemetry_analysis_run_id: Option<String>,
242}
243
244/// Runner boundary for programmatic health.
245///
246/// This keeps embedders on the typed API contract while still allowing tests
247/// and host integrations to provide a custom health runner.
248pub trait ProgrammaticHealthRunner {
249    /// Run health analysis for public programmatic options.
250    ///
251    /// # Errors
252    ///
253    /// Returns a structured programmatic error when the concrete runner cannot
254    /// resolve options or complete health analysis.
255    fn run_programmatic_health(
256        &self,
257        options: &ComplexityOptions,
258    ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
259}
260
261/// Default health runner backed directly by `fallow-engine`.
262///
263/// This runs the command-neutral health pipeline through the engine health
264/// runner without touching the CLI crate: the programmatic
265/// path never groups (`--group-by`), never drives the runtime coverage sidecar,
266/// and never records CLI telemetry, so the runner hooks are inert. NAPI and
267/// future Rust embedders use this runner; the CLI keeps its own runner for the
268/// `fallow health` command path.
269#[derive(Debug, Clone, Copy, Default)]
270pub struct EngineHealthRunner;
271
272impl ProgrammaticHealthRunner for EngineHealthRunner {
273    fn run_programmatic_health(
274        &self,
275        options: &ComplexityOptions,
276    ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
277        let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
278        resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
279    }
280}
281
282fn run_programmatic_health_on_engine(
283    resolved: &ProgrammaticAnalysisContext,
284    options: &ComplexityOptions,
285) -> ProgrammaticResult<ProgrammaticHealthRun> {
286    let health_options = derive_programmatic_health_execution_options(resolved, options);
287    let result = fallow_engine::health::run_ungrouped_health(
288        &health_options,
289        resolved.workspace_roots.clone(),
290    )
291    .map_err(|error| programmatic_health_error("health", error))?;
292
293    Ok(programmatic_health_run_from_engine_result(result))
294}
295
296fn programmatic_health_run_from_engine_result<GroupResolver>(
297    result: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
298) -> ProgrammaticHealthRun {
299    let root = result.config.root.clone();
300    let next_step_facts = ProgrammaticHealthNextStepFacts {
301        suggestions_enabled: suggestions_enabled(),
302        offer_setup: setup_pointer_applicable(&root),
303        impact_digest: None,
304        audit_changed: fallow_engine::churn::is_git_repo(&root),
305    };
306    ProgrammaticHealthRun {
307        workspace_diagnostics: result.workspace_diagnostics.clone(),
308        analysis: ProgrammaticHealthAnalysis::from_engine(result.without_group_resolver()),
309        next_step_facts,
310        telemetry_analysis_run_id: None,
311    }
312}
313
314#[cfg(test)]
315pub(super) fn run_health_with_session(
316    options: &ComplexityOptions,
317    resolved: &ProgrammaticAnalysisContext,
318    session: &AnalysisSession,
319    changed_files: Option<&FxHashSet<PathBuf>>,
320) -> ProgrammaticResult<HealthProgrammaticOutput> {
321    run_health_with_session_artifacts(options, resolved, session, changed_files, None, None)
322}
323
324pub(super) fn run_health_with_session_artifacts(
325    options: &ComplexityOptions,
326    resolved: &ProgrammaticAnalysisContext,
327    session: &AnalysisSession,
328    changed_files: Option<&FxHashSet<PathBuf>>,
329    pre_computed_analysis: Option<DeadCodeAnalysisArtifacts>,
330    pre_computed_duplication: Option<DuplicationReport>,
331) -> ProgrammaticResult<HealthProgrammaticOutput> {
332    crate::validate_complexity_options(options)?;
333    let health_options = derive_programmatic_health_execution_options(resolved, options);
334    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
335    let result = fallow_engine::health::run_ungrouped_health_with_session_artifacts(
336        &health_options,
337        workspace_roots,
338        session,
339        changed_files.map(|files| files.iter().cloned().collect()),
340        pre_computed_analysis,
341        pre_computed_duplication,
342    )
343    .map_err(|error| programmatic_health_error("health", error))?;
344
345    Ok(assemble_health_programmatic_output(
346        options,
347        programmatic_health_run_from_engine_result(result),
348    ))
349}
350
351fn programmatic_health_error(
352    command: &str,
353    error: fallow_engine::health::HealthError,
354) -> ProgrammaticError {
355    let (message, exit_code) = match error {
356        fallow_engine::health::HealthError::Message { message, exit_code } => (message, exit_code),
357        fallow_engine::health::HealthError::Printed(exit_code) => {
358            (format!("{command} failed"), exit_code)
359        }
360    };
361    let code = format!(
362        "FALLOW_{}_FAILED",
363        command.replace('-', "_").to_ascii_uppercase()
364    );
365    ProgrammaticError::new(message, exit_code)
366        .with_code(code)
367        .with_context(format!("fallow {command}"))
368        .with_help(format!(
369            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
370        ))
371}
372
373/// Run programmatic health / complexity through the engine-backed runner.
374///
375/// # Errors
376///
377/// Returns a structured programmatic error for invalid options or analysis
378/// failures.
379pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
380    run_health_with_runner(options, &EngineHealthRunner)
381}
382
383#[must_use]
384fn derive_programmatic_health_execution_options<'a>(
385    resolved: &'a ProgrammaticAnalysisContext,
386    options: &'a ComplexityOptions,
387) -> fallow_engine::health::HealthExecutionOptions<'a> {
388    let run = crate::derive_complexity_run_options(options);
389
390    fallow_engine::health::HealthExecutionOptions {
391        root: resolved.root(),
392        config_path: resolved.config_path(),
393        output: OutputFormat::Human,
394        no_cache: resolved.no_cache(),
395        threads: resolved.threads(),
396        quiet: true,
397        complexity_breakdown: run.complexity_breakdown,
398        thresholds: crate::thresholds_to_engine(run.thresholds),
399        top: run.top,
400        sort: crate::complexity_sort_to_engine(run.sort),
401        production: resolved.production_override().unwrap_or(false),
402        production_override: resolved.production_override(),
403        allow_remote_extends: resolved.allow_remote_extends(),
404        changed_since: resolved.changed_since(),
405        diff_index: resolved.diff_index(),
406        use_shared_diff_index: false,
407        workspace: resolved.workspace(),
408        changed_workspaces: resolved.changed_workspaces(),
409        baseline: None,
410        save_baseline: None,
411        baseline_mode: fallow_engine::baseline::HealthBaselineMode::Count,
412        baseline_mode_explicit: false,
413        complexity: run.sections.complexity,
414        file_scores: run.sections.file_scores,
415        coverage_gaps: run.sections.coverage_gaps,
416        config_activates_coverage_gaps: !run.sections.any_section,
417        hotspots: run.sections.hotspots,
418        ownership: run.sections.ownership,
419        targets: run.sections.targets,
420        css: run.css,
421        css_deep: run.css_deep,
422        force_full: run.sections.force_full,
423        score_only_output: run.sections.score_only_output,
424        enforce_coverage_gap_gate: true,
425        effort: run.effort.map(crate::target_effort_to_output),
426        score: run.sections.score,
427        gates: fallow_engine::health::HealthGateOptions::default(),
428        since: run.since,
429        min_commits: run.min_commits,
430        explain: resolved.explain_enabled(),
431        summary: false,
432        save_snapshot: None,
433        trend: false,
434        coverage_inputs: crate::coverage_inputs_to_engine(run.coverage_inputs),
435        performance: false,
436        runtime_coverage: None,
437        churn_file: None,
438        analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
439        group_by: None,
440        ownership_emails: run
441            .ownership_emails
442            .map(crate::ownership_email_mode_to_config),
443    }
444}
445
446/// Run programmatic health / complexity and return typed API output.
447///
448/// The concrete runner is injected while the health implementation is still
449/// being migrated out of the CLI crate. Runner-owned responsibilities are
450/// limited to typed analysis plus runtime facts; this API crate owns the final
451/// programmatic report assembly.
452///
453/// # Errors
454///
455/// Returns a structured programmatic error for invalid options or runner
456/// failures.
457pub fn run_complexity_with_runner(
458    options: &ComplexityOptions,
459    runner: &impl ProgrammaticHealthRunner,
460) -> ProgrammaticResult<HealthProgrammaticOutput> {
461    crate::validate_complexity_options(options)?;
462    crate::analysis_context::ensure_options_not_cancelled(&options.analysis, "health analysis")?;
463    Ok(assemble_health_programmatic_output(
464        options,
465        runner.run_programmatic_health(options)?,
466    ))
467}
468
469fn assemble_health_programmatic_output(
470    options: &ComplexityOptions,
471    run: ProgrammaticHealthRun,
472) -> HealthProgrammaticOutput {
473    let ProgrammaticHealthRun {
474        analysis,
475        workspace_diagnostics,
476        next_step_facts,
477        telemetry_analysis_run_id,
478    } = run;
479    let root = analysis.root.clone();
480    let next_steps =
481        fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
482            &analysis.report,
483            next_step_facts.suggestions_enabled,
484            next_step_facts.offer_setup,
485            next_step_facts.impact_digest,
486            next_step_facts.audit_changed,
487        ));
488    HealthProgrammaticOutput {
489        report: analysis.report,
490        grouping: analysis.grouping,
491        root,
492        elapsed: analysis.elapsed,
493        explain: options.analysis.explain,
494        workspace_diagnostics,
495        next_steps,
496        envelope_mode: root_envelope_mode(),
497        telemetry_analysis_run_id,
498    }
499}
500
501/// Alias for [`run_complexity_with_runner`] with a product-oriented name.
502///
503/// # Errors
504///
505/// Returns the same structured errors as [`run_complexity_with_runner`].
506pub fn run_health_with_runner(
507    options: &ComplexityOptions,
508    runner: &impl ProgrammaticHealthRunner,
509) -> ProgrammaticResult<HealthProgrammaticOutput> {
510    run_complexity_with_runner(options, runner)
511}
512
513const fn root_envelope_mode() -> RootEnvelopeMode {
514    RootEnvelopeMode::Tagged
515}
516
517#[cfg(test)]
518mod cancellation_tests;
519#[cfg(test)]
520mod tests;