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    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
151    pub suggestions_enabled: bool,
152    /// Offer the guided-setup pointer because no fallow config exists yet.
153    pub offer_setup: bool,
154    /// Local impact digest counters, when a digest is available.
155    pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
156    /// Offer `fallow audit` because the working tree has changed files.
157    pub audit_changed: bool,
158}
159
160/// API-owned health analysis payload returned by programmatic runners.
161///
162/// The engine owns execution, but this type is the public runner contract so
163/// embedders do not have to construct or depend on engine result structs.
164pub struct ProgrammaticHealthAnalysis {
165    /// Typed health report produced by the engine.
166    pub report: HealthReport,
167    /// Grouped findings when a grouping mode was requested.
168    pub grouping: Option<HealthGrouping>,
169    /// Resolved analysis root the report paths are relative to.
170    pub root: PathBuf,
171    /// Analysis wall time.
172    pub elapsed: std::time::Duration,
173}
174
175impl ProgrammaticHealthAnalysis {
176    fn from_engine<GroupResolver>(
177        analysis: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
178    ) -> Self {
179        Self {
180            root: analysis.config.root,
181            report: analysis.report,
182            grouping: analysis.grouping,
183            elapsed: analysis.elapsed,
184        }
185    }
186}
187
188/// Health runner output shared by API, NAPI, and alternate runners.
189///
190/// Runtime-only presentation probes stay explicit so the API boundary, not the
191/// concrete runner, owns the final programmatic report assembly.
192pub struct ProgrammaticHealthRun {
193    /// Engine analysis payload.
194    pub analysis: ProgrammaticHealthAnalysis,
195    /// Non-fatal per-file diagnostics collected during the workspace walk.
196    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
197    /// Environment and project facts that drive next-step suggestions.
198    pub next_step_facts: ProgrammaticHealthNextStepFacts,
199    /// Analysis run id stamped into telemetry metadata when present.
200    pub telemetry_analysis_run_id: Option<String>,
201}
202
203/// Runner boundary for programmatic health.
204///
205/// This keeps embedders on the typed API contract while still allowing tests
206/// and host integrations to provide a custom health runner.
207pub trait ProgrammaticHealthRunner {
208    /// Run health analysis for public programmatic options.
209    ///
210    /// # Errors
211    ///
212    /// Returns a structured programmatic error when the concrete runner cannot
213    /// resolve options or complete health analysis.
214    fn run_programmatic_health(
215        &self,
216        options: &ComplexityOptions,
217    ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
218}
219
220/// Default health runner backed directly by `fallow-engine`.
221///
222/// This runs the command-neutral health pipeline through the engine health
223/// runner without touching the CLI crate: the programmatic
224/// path never groups (`--group-by`), never drives the runtime coverage sidecar,
225/// and never records CLI telemetry, so the runner hooks are inert. NAPI and
226/// future Rust embedders use this runner; the CLI keeps its own runner for the
227/// `fallow health` command path.
228#[derive(Debug, Clone, Copy, Default)]
229pub struct EngineHealthRunner;
230
231impl ProgrammaticHealthRunner for EngineHealthRunner {
232    fn run_programmatic_health(
233        &self,
234        options: &ComplexityOptions,
235    ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
236        let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
237        resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
238    }
239}
240
241fn run_programmatic_health_on_engine(
242    resolved: &ProgrammaticAnalysisContext,
243    options: &ComplexityOptions,
244) -> ProgrammaticResult<ProgrammaticHealthRun> {
245    let health_options = derive_programmatic_health_execution_options(resolved, options);
246    let result = fallow_engine::health::run_ungrouped_health(
247        &health_options,
248        resolved.workspace_roots.clone(),
249    )
250    .map_err(|_| generic_health_error("health"))?;
251
252    Ok(programmatic_health_run_from_engine_result(result))
253}
254
255fn programmatic_health_run_from_engine_result<GroupResolver>(
256    result: fallow_engine::health::HealthAnalysisResult<GroupResolver>,
257) -> ProgrammaticHealthRun {
258    let root = result.config.root.clone();
259    let next_step_facts = ProgrammaticHealthNextStepFacts {
260        suggestions_enabled: suggestions_enabled(),
261        offer_setup: setup_pointer_applicable(&root),
262        impact_digest: None,
263        audit_changed: fallow_engine::churn::is_git_repo(&root),
264    };
265    ProgrammaticHealthRun {
266        workspace_diagnostics: result.workspace_diagnostics.clone(),
267        analysis: ProgrammaticHealthAnalysis::from_engine(result.without_group_resolver()),
268        next_step_facts,
269        telemetry_analysis_run_id: None,
270    }
271}
272
273#[cfg(test)]
274pub(super) fn run_health_with_session(
275    options: &ComplexityOptions,
276    resolved: &ProgrammaticAnalysisContext,
277    session: &AnalysisSession,
278    changed_files: Option<&FxHashSet<PathBuf>>,
279) -> ProgrammaticResult<HealthProgrammaticOutput> {
280    run_health_with_session_artifacts(options, resolved, session, changed_files, None, None)
281}
282
283pub(super) fn run_health_with_session_artifacts(
284    options: &ComplexityOptions,
285    resolved: &ProgrammaticAnalysisContext,
286    session: &AnalysisSession,
287    changed_files: Option<&FxHashSet<PathBuf>>,
288    pre_computed_analysis: Option<DeadCodeAnalysisArtifacts>,
289    pre_computed_duplication: Option<DuplicationReport>,
290) -> ProgrammaticResult<HealthProgrammaticOutput> {
291    crate::validate_complexity_options(options)?;
292    let health_options = derive_programmatic_health_execution_options(resolved, options);
293    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
294    let result = fallow_engine::health::run_ungrouped_health_with_session_artifacts(
295        &health_options,
296        workspace_roots,
297        session,
298        changed_files.map(|files| files.iter().cloned().collect()),
299        pre_computed_analysis,
300        pre_computed_duplication,
301    )
302    .map_err(|_| generic_health_error("health"))?;
303
304    Ok(assemble_health_programmatic_output(
305        options,
306        programmatic_health_run_from_engine_result(result),
307    ))
308}
309
310fn generic_health_error(command: &str) -> ProgrammaticError {
311    let code = format!(
312        "FALLOW_{}_FAILED",
313        command.replace('-', "_").to_ascii_uppercase()
314    );
315    ProgrammaticError::new(format!("{command} failed"), 2)
316        .with_code(code)
317        .with_context(format!("fallow {command}"))
318        .with_help(format!(
319            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
320        ))
321}
322
323/// Run programmatic health / complexity through the engine-backed runner.
324///
325/// # Errors
326///
327/// Returns a structured programmatic error for invalid options or analysis
328/// failures.
329pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
330    run_health_with_runner(options, &EngineHealthRunner)
331}
332
333#[must_use]
334fn derive_programmatic_health_execution_options<'a>(
335    resolved: &'a ProgrammaticAnalysisContext,
336    options: &'a ComplexityOptions,
337) -> fallow_engine::health::HealthExecutionOptions<'a> {
338    let run = crate::derive_complexity_run_options(options);
339
340    fallow_engine::health::HealthExecutionOptions {
341        root: resolved.root(),
342        config_path: resolved.config_path(),
343        output: OutputFormat::Human,
344        no_cache: resolved.no_cache(),
345        threads: resolved.threads(),
346        quiet: true,
347        complexity_breakdown: run.complexity_breakdown,
348        thresholds: crate::thresholds_to_engine(run.thresholds),
349        top: run.top,
350        sort: crate::complexity_sort_to_engine(run.sort),
351        production: resolved.production_override().unwrap_or(false),
352        production_override: resolved.production_override(),
353        allow_remote_extends: resolved.allow_remote_extends(),
354        changed_since: resolved.changed_since(),
355        diff_index: resolved.diff_index(),
356        use_shared_diff_index: false,
357        workspace: resolved.workspace(),
358        changed_workspaces: resolved.changed_workspaces(),
359        baseline: None,
360        save_baseline: None,
361        baseline_mode: fallow_engine::baseline::HealthBaselineMode::Count,
362        baseline_mode_explicit: false,
363        complexity: run.sections.complexity,
364        file_scores: run.sections.file_scores,
365        coverage_gaps: run.sections.coverage_gaps,
366        config_activates_coverage_gaps: !run.sections.any_section,
367        hotspots: run.sections.hotspots,
368        ownership: run.sections.ownership,
369        targets: run.sections.targets,
370        css: run.css,
371        css_deep: run.css_deep,
372        force_full: run.sections.force_full,
373        score_only_output: run.sections.score_only_output,
374        enforce_coverage_gap_gate: true,
375        effort: run.effort.map(crate::target_effort_to_output),
376        score: run.sections.score,
377        gates: fallow_engine::health::HealthGateOptions::default(),
378        since: run.since,
379        min_commits: run.min_commits,
380        explain: resolved.explain_enabled(),
381        summary: false,
382        save_snapshot: None,
383        trend: false,
384        coverage_inputs: crate::coverage_inputs_to_engine(run.coverage_inputs),
385        performance: false,
386        runtime_coverage: None,
387        churn_file: None,
388        analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
389        group_by: None,
390        ownership_emails: run
391            .ownership_emails
392            .map(crate::ownership_email_mode_to_config),
393    }
394}
395
396/// Run programmatic health / complexity and return typed API output.
397///
398/// The concrete runner is injected while the health implementation is still
399/// being migrated out of the CLI crate. Runner-owned responsibilities are
400/// limited to typed analysis plus runtime facts; this API crate owns the final
401/// programmatic report assembly.
402///
403/// # Errors
404///
405/// Returns a structured programmatic error for invalid options or runner
406/// failures.
407pub fn run_complexity_with_runner(
408    options: &ComplexityOptions,
409    runner: &impl ProgrammaticHealthRunner,
410) -> ProgrammaticResult<HealthProgrammaticOutput> {
411    crate::validate_complexity_options(options)?;
412    Ok(assemble_health_programmatic_output(
413        options,
414        runner.run_programmatic_health(options)?,
415    ))
416}
417
418fn assemble_health_programmatic_output(
419    options: &ComplexityOptions,
420    run: ProgrammaticHealthRun,
421) -> HealthProgrammaticOutput {
422    let ProgrammaticHealthRun {
423        analysis,
424        workspace_diagnostics,
425        next_step_facts,
426        telemetry_analysis_run_id,
427    } = run;
428    let root = analysis.root.clone();
429    let next_steps =
430        fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
431            &analysis.report,
432            next_step_facts.suggestions_enabled,
433            next_step_facts.offer_setup,
434            next_step_facts.impact_digest,
435            next_step_facts.audit_changed,
436        ));
437    HealthProgrammaticOutput {
438        report: analysis.report,
439        grouping: analysis.grouping,
440        root,
441        elapsed: analysis.elapsed,
442        explain: options.analysis.explain,
443        workspace_diagnostics,
444        next_steps,
445        envelope_mode: root_envelope_mode(),
446        telemetry_analysis_run_id,
447    }
448}
449
450/// Alias for [`run_complexity_with_runner`] with a product-oriented name.
451///
452/// # Errors
453///
454/// Returns the same structured errors as [`run_complexity_with_runner`].
455pub fn run_health_with_runner(
456    options: &ComplexityOptions,
457    runner: &impl ProgrammaticHealthRunner,
458) -> ProgrammaticResult<HealthProgrammaticOutput> {
459    run_complexity_with_runner(options, runner)
460}
461
462const fn root_envelope_mode() -> RootEnvelopeMode {
463    RootEnvelopeMode::Tagged
464}
465
466#[cfg(test)]
467mod tests;