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_config::{FallowConfig, 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::{run_trace_clone, run_trace_dependency, run_trace_export, run_trace_file};
40
41use crate::{
42    ComplexityOptions, ProgrammaticError,
43    analysis_context::{
44        ProgrammaticAnalysisContext, resolve_programmatic_analysis_context,
45        workspace_roots_for_session,
46    },
47    derive_complexity_options,
48    next_steps::{setup_pointer_applicable, suggestions_enabled},
49};
50
51type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(super) struct EffectiveProductionModes {
55    pub dead_code: bool,
56    pub health: bool,
57    pub dupes: bool,
58}
59
60pub(super) fn resolve_effective_production_modes(
61    resolved: &ProgrammaticAnalysisContext,
62    dead_code_override: Option<bool>,
63    health_override: Option<bool>,
64    dupes_override: Option<bool>,
65) -> ProgrammaticResult<EffectiveProductionModes> {
66    let config = load_context_production_config(resolved)?;
67    Ok(EffectiveProductionModes {
68        dead_code: effective_production_mode(
69            config,
70            ProductionAnalysis::DeadCode,
71            resolved,
72            dead_code_override,
73        ),
74        health: effective_production_mode(
75            config,
76            ProductionAnalysis::Health,
77            resolved,
78            health_override,
79        ),
80        dupes: effective_production_mode(
81            config,
82            ProductionAnalysis::Dupes,
83            resolved,
84            dupes_override,
85        ),
86    })
87}
88
89fn effective_production_mode(
90    config: ProductionConfig,
91    analysis: ProductionAnalysis,
92    resolved: &ProgrammaticAnalysisContext,
93    analysis_override: Option<bool>,
94) -> bool {
95    analysis_override
96        .or_else(|| resolved.production_override())
97        .unwrap_or_else(|| config.for_analysis(analysis))
98}
99
100fn load_context_production_config(
101    resolved: &ProgrammaticAnalysisContext,
102) -> ProgrammaticResult<ProductionConfig> {
103    let load_options = fallow_config::ConfigLoadOptions {
104        allow_remote_extends: resolved.allow_remote_extends(),
105    };
106    let loaded = if let Some(path) = resolved.config_path().as_deref() {
107        FallowConfig::load_with_options(path, load_options)
108            .map(Some)
109            .map_err(|err| {
110                ProgrammaticError::new(format!("failed to load config: {err:#}"), 2)
111                    .with_code("FALLOW_CONFIG_LOAD_FAILED")
112                    .with_context("analysis.configPath")
113            })?
114    } else {
115        FallowConfig::find_and_load_with_options(resolved.root(), load_options)
116            .map(|found| found.map(|(config, _)| config))
117            .map_err(|err| {
118                ProgrammaticError::new(format!("failed to load config: {err}"), 2)
119                    .with_code("FALLOW_CONFIG_LOAD_FAILED")
120                    .with_context("analysis.configPath")
121            })?
122    };
123    Ok(loaded.map_or_else(ProductionConfig::default, |config| config.production))
124}
125
126pub(super) fn health_may_consume_dead_code_artifacts(
127    options: &ComplexityOptions,
128    config: &fallow_config::ResolvedConfig,
129) -> bool {
130    let sections = derive_complexity_options(options);
131    let max_crap = options.max_crap.unwrap_or(config.health.max_crap);
132    sections.file_scores
133        || sections.coverage_gaps
134        || sections.hotspots
135        || sections.targets
136        || sections.force_full
137        || max_crap > 0.0
138}
139
140pub(super) fn health_may_consume_duplication_report(options: &ComplexityOptions) -> bool {
141    let sections = derive_complexity_options(options);
142    sections.score || sections.targets
143}
144
145/// Runtime probes used by programmatic health output assembly.
146///
147/// Concrete runners supply environment and project facts while the stable
148/// command strings and output ordering remain owned by `fallow-output`.
149pub struct ProgrammaticHealthNextStepFacts {
150    pub suggestions_enabled: bool,
151    pub offer_setup: bool,
152    pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
153    pub audit_changed: bool,
154}
155
156/// API-owned health analysis payload returned by programmatic runners.
157///
158/// The engine owns execution, but this type is the public runner contract so
159/// embedders do not have to construct or depend on engine result structs.
160pub struct ProgrammaticHealthAnalysis {
161    pub report: HealthReport,
162    pub grouping: Option<HealthGrouping>,
163    pub root: PathBuf,
164    pub elapsed: std::time::Duration,
165}
166
167impl ProgrammaticHealthAnalysis {
168    fn from_engine<GroupResolver>(
169        analysis: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
170    ) -> Self {
171        Self {
172            root: analysis.config.root,
173            report: analysis.report,
174            grouping: analysis.grouping,
175            elapsed: analysis.elapsed,
176        }
177    }
178}
179
180/// Health runner output shared by API, NAPI, and alternate runners.
181///
182/// Runtime-only presentation probes stay explicit so the API boundary, not the
183/// concrete runner, owns the final programmatic report assembly.
184pub struct ProgrammaticHealthRun {
185    pub analysis: ProgrammaticHealthAnalysis,
186    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
187    pub next_step_facts: ProgrammaticHealthNextStepFacts,
188    pub telemetry_analysis_run_id: Option<String>,
189}
190
191/// Runner boundary for programmatic health.
192///
193/// This keeps embedders on the typed API contract while still allowing tests
194/// and host integrations to provide a custom health runner.
195pub trait ProgrammaticHealthRunner {
196    /// Run health analysis for public programmatic options.
197    ///
198    /// # Errors
199    ///
200    /// Returns a structured programmatic error when the concrete runner cannot
201    /// resolve options or complete health analysis.
202    fn run_programmatic_health(
203        &self,
204        options: &ComplexityOptions,
205    ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
206}
207
208/// Default health runner backed directly by `fallow-engine`.
209///
210/// This runs the command-neutral health pipeline through the engine health
211/// runner without touching the CLI crate: the programmatic
212/// path never groups (`--group-by`), never drives the runtime coverage sidecar,
213/// and never records CLI telemetry, so the runner hooks are inert. NAPI and
214/// future Rust embedders use this runner; the CLI keeps its own runner for the
215/// `fallow health` command path.
216#[derive(Debug, Clone, Copy, Default)]
217pub struct EngineHealthRunner;
218
219impl ProgrammaticHealthRunner for EngineHealthRunner {
220    fn run_programmatic_health(
221        &self,
222        options: &ComplexityOptions,
223    ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
224        let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
225        resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
226    }
227}
228
229fn run_programmatic_health_on_engine(
230    resolved: &ProgrammaticAnalysisContext,
231    options: &ComplexityOptions,
232) -> ProgrammaticResult<ProgrammaticHealthRun> {
233    let health_options = derive_programmatic_health_execution_options(resolved, options);
234    let result = fallow_engine::health::run_ungrouped_health(
235        &health_options,
236        resolved.workspace_roots.clone(),
237    )
238    .map_err(|_| generic_health_error("health"))?;
239
240    Ok(programmatic_health_run_from_engine_result(result))
241}
242
243fn programmatic_health_run_from_engine_result<GroupResolver>(
244    result: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
245) -> ProgrammaticHealthRun {
246    let root = result.config.root.clone();
247    let next_step_facts = ProgrammaticHealthNextStepFacts {
248        suggestions_enabled: suggestions_enabled(),
249        offer_setup: setup_pointer_applicable(&root),
250        impact_digest: None,
251        audit_changed: fallow_engine::churn::is_git_repo(&root),
252    };
253    ProgrammaticHealthRun {
254        workspace_diagnostics: result.workspace_diagnostics.clone(),
255        analysis: ProgrammaticHealthAnalysis::from_engine(result.without_group_resolver()),
256        next_step_facts,
257        telemetry_analysis_run_id: None,
258    }
259}
260
261#[cfg(test)]
262pub(super) fn run_health_with_session(
263    options: &ComplexityOptions,
264    resolved: &ProgrammaticAnalysisContext,
265    session: &AnalysisSession,
266    changed_files: Option<&FxHashSet<PathBuf>>,
267) -> ProgrammaticResult<HealthProgrammaticOutput> {
268    run_health_with_session_artifacts(options, resolved, session, changed_files, None, None)
269}
270
271pub(super) fn run_health_with_session_artifacts(
272    options: &ComplexityOptions,
273    resolved: &ProgrammaticAnalysisContext,
274    session: &AnalysisSession,
275    changed_files: Option<&FxHashSet<PathBuf>>,
276    pre_computed_analysis: Option<DeadCodeAnalysisArtifacts>,
277    pre_computed_duplication: Option<DuplicationReport>,
278) -> ProgrammaticResult<HealthProgrammaticOutput> {
279    crate::validate_complexity_options(options)?;
280    let health_options = derive_programmatic_health_execution_options(resolved, options);
281    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
282    let result = fallow_engine::health::run_ungrouped_health_with_session_artifacts(
283        &health_options,
284        workspace_roots,
285        session,
286        changed_files.map(|files| files.iter().cloned().collect()),
287        pre_computed_analysis,
288        pre_computed_duplication,
289    )
290    .map_err(|_| generic_health_error("health"))?;
291
292    Ok(assemble_health_programmatic_output(
293        options,
294        programmatic_health_run_from_engine_result(result),
295    ))
296}
297
298fn generic_health_error(command: &str) -> ProgrammaticError {
299    let code = format!(
300        "FALLOW_{}_FAILED",
301        command.replace('-', "_").to_ascii_uppercase()
302    );
303    ProgrammaticError::new(format!("{command} failed"), 2)
304        .with_code(code)
305        .with_context(format!("fallow {command}"))
306        .with_help(format!(
307            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
308        ))
309}
310
311/// Run programmatic health / complexity through the engine-backed runner.
312///
313/// # Errors
314///
315/// Returns a structured programmatic error for invalid options or analysis
316/// failures.
317pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
318    run_health_with_runner(options, &EngineHealthRunner)
319}
320
321#[must_use]
322fn derive_programmatic_health_execution_options<'a>(
323    resolved: &'a ProgrammaticAnalysisContext,
324    options: &'a ComplexityOptions,
325) -> fallow_engine::health::HealthExecutionOptions<'a> {
326    let run = crate::derive_complexity_run_options(options);
327
328    fallow_engine::health::HealthExecutionOptions {
329        root: resolved.root(),
330        config_path: resolved.config_path(),
331        output: OutputFormat::Human,
332        no_cache: resolved.no_cache(),
333        threads: resolved.threads(),
334        quiet: true,
335        complexity_breakdown: run.complexity_breakdown,
336        thresholds: crate::thresholds_to_engine(run.thresholds),
337        top: run.top,
338        sort: crate::complexity_sort_to_engine(run.sort),
339        production: resolved.production_override().unwrap_or(false),
340        production_override: resolved.production_override(),
341        allow_remote_extends: resolved.allow_remote_extends(),
342        changed_since: resolved.changed_since(),
343        diff_index: resolved.diff_index(),
344        use_shared_diff_index: false,
345        workspace: resolved.workspace(),
346        changed_workspaces: resolved.changed_workspaces(),
347        baseline: None,
348        save_baseline: None,
349        complexity: run.sections.complexity,
350        file_scores: run.sections.file_scores,
351        coverage_gaps: run.sections.coverage_gaps,
352        config_activates_coverage_gaps: !run.sections.any_section,
353        hotspots: run.sections.hotspots,
354        ownership: run.sections.ownership,
355        targets: run.sections.targets,
356        css: run.css,
357        css_deep: run.css_deep,
358        force_full: run.sections.force_full,
359        score_only_output: run.sections.score_only_output,
360        enforce_coverage_gap_gate: true,
361        effort: run.effort.map(crate::target_effort_to_output),
362        score: run.sections.score,
363        gates: fallow_engine::health::HealthGateOptions::default(),
364        since: run.since,
365        min_commits: run.min_commits,
366        explain: resolved.explain_enabled(),
367        summary: false,
368        save_snapshot: None,
369        trend: false,
370        coverage_inputs: crate::coverage_inputs_to_engine(run.coverage_inputs),
371        performance: false,
372        runtime_coverage: None,
373        churn_file: None,
374        group_by: None,
375        ownership_emails: run
376            .ownership_emails
377            .map(crate::ownership_email_mode_to_config),
378    }
379}
380
381/// Run programmatic health / complexity and return typed API output.
382///
383/// The concrete runner is injected while the health implementation is still
384/// being migrated out of the CLI crate. Runner-owned responsibilities are
385/// limited to typed analysis plus runtime facts; this API crate owns the final
386/// programmatic report assembly.
387///
388/// # Errors
389///
390/// Returns a structured programmatic error for invalid options or runner
391/// failures.
392pub fn run_complexity_with_runner(
393    options: &ComplexityOptions,
394    runner: &impl ProgrammaticHealthRunner,
395) -> ProgrammaticResult<HealthProgrammaticOutput> {
396    crate::validate_complexity_options(options)?;
397    Ok(assemble_health_programmatic_output(
398        options,
399        runner.run_programmatic_health(options)?,
400    ))
401}
402
403fn assemble_health_programmatic_output(
404    options: &ComplexityOptions,
405    run: ProgrammaticHealthRun,
406) -> HealthProgrammaticOutput {
407    let ProgrammaticHealthRun {
408        analysis,
409        workspace_diagnostics,
410        next_step_facts,
411        telemetry_analysis_run_id,
412    } = run;
413    let root = analysis.root.clone();
414    let next_steps =
415        fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
416            &analysis.report,
417            next_step_facts.suggestions_enabled,
418            next_step_facts.offer_setup,
419            next_step_facts.impact_digest,
420            next_step_facts.audit_changed,
421        ));
422    HealthProgrammaticOutput {
423        report: analysis.report,
424        grouping: analysis.grouping,
425        root,
426        elapsed: analysis.elapsed,
427        explain: options.analysis.explain,
428        workspace_diagnostics,
429        next_steps,
430        envelope_mode: root_envelope_mode(),
431        telemetry_analysis_run_id,
432    }
433}
434
435/// Alias for [`run_complexity_with_runner`] with a product-oriented name.
436///
437/// # Errors
438///
439/// Returns the same structured errors as [`run_complexity_with_runner`].
440pub fn run_health_with_runner(
441    options: &ComplexityOptions,
442    runner: &impl ProgrammaticHealthRunner,
443) -> ProgrammaticResult<HealthProgrammaticOutput> {
444    run_complexity_with_runner(options, runner)
445}
446
447const fn root_envelope_mode() -> RootEnvelopeMode {
448    RootEnvelopeMode::Tagged
449}
450
451#[cfg(test)]
452mod tests;