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