Skip to main content

fallow_api/runtime/
mod.rs

1//! Programmatic runtime entry points that avoid depending on `fallow-cli`.
2
3use std::path::PathBuf;
4
5use fallow_output::{HealthGrouping, HealthReport, RootEnvelopeMode};
6use fallow_types::output_format::OutputFormat;
7use fallow_types::workspace::WorkspaceDiagnostic;
8
9mod audit;
10mod dead_code;
11mod decision_surface;
12mod duplication;
13mod feature_flags;
14mod trace;
15
16pub use crate::runtime_output::{
17    AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
18    BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
19    CircularDependenciesProgrammaticOutput, DeadCodeOutput, DeadCodeProgrammaticOutput,
20    DecisionSurfaceProgrammaticOutput, DuplicationOutput, DuplicationProgrammaticOutput,
21    FeatureFlagsOutput, FeatureFlagsProgrammaticOutput, HealthJsonReportInput,
22    HealthProgrammaticOutput, TraceCloneOutput, TraceCloneProgrammaticOutput,
23    TraceDependencyOutput, TraceDependencyProgrammaticOutput, TraceExportOutput,
24    TraceExportProgrammaticOutput, TraceFileOutput, TraceFileProgrammaticOutput,
25    serialize_health_report_json,
26};
27pub use audit::run_audit;
28pub use dead_code::{run_boundary_violations, run_circular_dependencies, run_dead_code};
29pub use decision_surface::run_decision_surface;
30pub use duplication::run_duplication;
31pub use feature_flags::run_feature_flags;
32pub use trace::{run_trace_clone, run_trace_dependency, run_trace_export, run_trace_file};
33
34use crate::{
35    ComplexityOptions, ProgrammaticError,
36    analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context},
37    next_steps::{setup_pointer_applicable, suggestions_enabled},
38};
39
40type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
41
42/// Runtime probes used by programmatic health output assembly.
43///
44/// Concrete runners supply environment and project facts while the stable
45/// command strings and output ordering remain owned by `fallow-output`.
46pub struct ProgrammaticHealthNextStepFacts {
47    pub suggestions_enabled: bool,
48    pub offer_setup: bool,
49    pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
50    pub audit_changed: bool,
51}
52
53/// API-owned health analysis payload returned by programmatic runners.
54///
55/// The engine owns execution, but this type is the public runner contract so
56/// embedders do not have to construct or depend on engine result structs.
57pub struct ProgrammaticHealthAnalysis {
58    pub report: HealthReport,
59    pub grouping: Option<HealthGrouping>,
60    pub root: PathBuf,
61    pub elapsed: std::time::Duration,
62}
63
64impl ProgrammaticHealthAnalysis {
65    fn from_engine<GroupResolver>(
66        analysis: fallow_engine::HealthAnalysisResult<GroupResolver>,
67    ) -> Self {
68        Self {
69            root: analysis.config.root,
70            report: analysis.report,
71            grouping: analysis.grouping,
72            elapsed: analysis.elapsed,
73        }
74    }
75}
76
77/// Health runner output shared by API, NAPI, and alternate runners.
78///
79/// Runtime-only presentation probes stay explicit so the API boundary, not the
80/// concrete runner, owns the final programmatic report assembly.
81pub struct ProgrammaticHealthRun {
82    pub analysis: ProgrammaticHealthAnalysis,
83    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
84    pub next_step_facts: ProgrammaticHealthNextStepFacts,
85    pub telemetry_analysis_run_id: Option<String>,
86}
87
88/// Runner boundary for programmatic health.
89///
90/// This keeps embedders on the typed API contract while still allowing tests
91/// and host integrations to provide a custom health runner.
92pub trait ProgrammaticHealthRunner {
93    /// Run health analysis for public programmatic options.
94    ///
95    /// # Errors
96    ///
97    /// Returns a structured programmatic error when the concrete runner cannot
98    /// resolve options or complete health analysis.
99    fn run_programmatic_health(
100        &self,
101        options: &ComplexityOptions,
102    ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
103}
104
105/// Default health runner backed directly by `fallow-engine`.
106///
107/// This runs the command-neutral health pipeline through the engine health
108/// runner without touching the CLI crate: the programmatic
109/// path never groups (`--group-by`), never drives the runtime coverage sidecar,
110/// and never records CLI telemetry, so the runner hooks are inert. NAPI and
111/// future Rust embedders use this runner; the CLI keeps its own runner for the
112/// `fallow health` command path.
113#[derive(Debug, Clone, Copy, Default)]
114pub struct EngineHealthRunner;
115
116impl ProgrammaticHealthRunner for EngineHealthRunner {
117    fn run_programmatic_health(
118        &self,
119        options: &ComplexityOptions,
120    ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
121        let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
122        resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
123    }
124}
125
126fn run_programmatic_health_on_engine(
127    resolved: &ProgrammaticAnalysisContext,
128    options: &ComplexityOptions,
129) -> ProgrammaticResult<ProgrammaticHealthRun> {
130    let health_options = derive_programmatic_health_execution_options(resolved, options);
131    let result =
132        fallow_engine::run_ungrouped_health(&health_options, resolved.workspace_roots.clone())
133            .map_err(|_| generic_health_error("health"))?;
134
135    let root = result.config.root.clone();
136    let next_step_facts = ProgrammaticHealthNextStepFacts {
137        suggestions_enabled: suggestions_enabled(),
138        offer_setup: setup_pointer_applicable(&root),
139        impact_digest: None,
140        audit_changed: fallow_engine::is_git_repo(&root),
141    };
142    Ok(ProgrammaticHealthRun {
143        workspace_diagnostics: result.workspace_diagnostics.clone(),
144        analysis: ProgrammaticHealthAnalysis::from_engine(result.without_group_resolver()),
145        next_step_facts,
146        telemetry_analysis_run_id: None,
147    })
148}
149
150fn generic_health_error(command: &str) -> ProgrammaticError {
151    let code = format!(
152        "FALLOW_{}_FAILED",
153        command.replace('-', "_").to_ascii_uppercase()
154    );
155    ProgrammaticError::new(format!("{command} failed"), 2)
156        .with_code(code)
157        .with_context(format!("fallow {command}"))
158        .with_help(format!(
159            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
160        ))
161}
162
163/// Run programmatic health / complexity through the engine-backed runner.
164///
165/// # Errors
166///
167/// Returns a structured programmatic error for invalid options or analysis
168/// failures.
169pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
170    run_health_with_runner(options, &EngineHealthRunner)
171}
172
173#[must_use]
174fn derive_programmatic_health_execution_options<'a>(
175    resolved: &'a ProgrammaticAnalysisContext,
176    options: &'a ComplexityOptions,
177) -> fallow_engine::HealthExecutionOptions<'a> {
178    let run = crate::derive_complexity_run_options(options);
179
180    fallow_engine::HealthExecutionOptions {
181        root: resolved.root(),
182        config_path: resolved.config_path(),
183        output: OutputFormat::Human,
184        no_cache: resolved.no_cache(),
185        threads: resolved.threads(),
186        quiet: true,
187        complexity_breakdown: run.complexity_breakdown,
188        thresholds: crate::thresholds_to_engine(run.thresholds),
189        top: run.top,
190        sort: crate::complexity_sort_to_engine(run.sort),
191        production: resolved.production_override().unwrap_or(false),
192        production_override: resolved.production_override(),
193        changed_since: resolved.changed_since(),
194        diff_index: resolved.diff_index(),
195        use_shared_diff_index: false,
196        workspace: resolved.workspace(),
197        changed_workspaces: resolved.changed_workspaces(),
198        baseline: None,
199        save_baseline: None,
200        complexity: run.sections.complexity,
201        file_scores: run.sections.file_scores,
202        coverage_gaps: run.sections.coverage_gaps,
203        config_activates_coverage_gaps: !run.sections.any_section,
204        hotspots: run.sections.hotspots,
205        ownership: run.sections.ownership,
206        targets: run.sections.targets,
207        css: run.css,
208        force_full: run.sections.force_full,
209        score_only_output: run.sections.score_only_output,
210        enforce_coverage_gap_gate: true,
211        effort: run.effort.map(crate::target_effort_to_output),
212        score: run.sections.score,
213        gates: fallow_engine::HealthGateOptions::default(),
214        since: run.since,
215        min_commits: run.min_commits,
216        explain: resolved.explain_enabled(),
217        summary: false,
218        save_snapshot: None,
219        trend: false,
220        coverage_inputs: crate::coverage_inputs_to_engine(run.coverage_inputs),
221        performance: false,
222        runtime_coverage: None,
223        churn_file: None,
224        group_by: None,
225        ownership_emails: run
226            .ownership_emails
227            .map(crate::ownership_email_mode_to_config),
228    }
229}
230
231/// Run programmatic health / complexity and return typed API output.
232///
233/// The concrete runner is injected while the health implementation is still
234/// being migrated out of the CLI crate. Runner-owned responsibilities are
235/// limited to typed analysis plus runtime facts; this API crate owns the final
236/// programmatic report assembly.
237///
238/// # Errors
239///
240/// Returns a structured programmatic error for invalid options or runner
241/// failures.
242pub fn run_complexity_with_runner(
243    options: &ComplexityOptions,
244    runner: &impl ProgrammaticHealthRunner,
245) -> ProgrammaticResult<HealthProgrammaticOutput> {
246    crate::validate_complexity_options(options)?;
247    let ProgrammaticHealthRun {
248        analysis,
249        workspace_diagnostics,
250        next_step_facts,
251        telemetry_analysis_run_id,
252    } = runner.run_programmatic_health(options)?;
253    let root = analysis.root.clone();
254    let next_steps =
255        fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
256            &analysis.report,
257            next_step_facts.suggestions_enabled,
258            next_step_facts.offer_setup,
259            next_step_facts.impact_digest,
260            next_step_facts.audit_changed,
261        ));
262    Ok(HealthProgrammaticOutput {
263        report: analysis.report,
264        grouping: analysis.grouping,
265        root,
266        elapsed: analysis.elapsed,
267        explain: options.analysis.explain,
268        workspace_diagnostics,
269        next_steps,
270        envelope_mode: root_envelope_mode(),
271        telemetry_analysis_run_id,
272    })
273}
274
275/// Alias for [`run_complexity_with_runner`] with a product-oriented name.
276///
277/// # Errors
278///
279/// Returns the same structured errors as [`run_complexity_with_runner`].
280pub fn run_health_with_runner(
281    options: &ComplexityOptions,
282    runner: &impl ProgrammaticHealthRunner,
283) -> ProgrammaticResult<HealthProgrammaticOutput> {
284    run_complexity_with_runner(options, runner)
285}
286
287const fn root_envelope_mode() -> RootEnvelopeMode {
288    RootEnvelopeMode::Tagged
289}
290
291#[cfg(test)]
292mod tests;