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