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