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