Skip to main content

fallow_api/runtime/
audit.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use fallow_config::AuditGate;
5use fallow_engine::{
6    dead_code::DeadCodeAnalysisArtifacts,
7    project_analysis::ProjectAnalysisArtifactOptions,
8    repo_refs::{self, ResolvedAuditBase, TemporaryBaseWorktree},
9    session::AnalysisSession,
10};
11use fallow_output::build_audit_next_steps;
12use fallow_types::output::NextStep;
13use rustc_hash::FxHashSet;
14
15use crate::{
16    AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
17    AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
18    DeadCodeOptions, DuplicationOptions, ProgrammaticError,
19    analysis_context::{
20        ProgrammaticAnalysisContext, changed_files_for_run,
21        resolve_programmatic_analysis_context_deferred_workspace,
22    },
23};
24
25use super::{
26    ProgrammaticResult, health_may_consume_dead_code_artifacts,
27    health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
28    run_dead_code, run_duplication, run_health, run_health_with_session_artifacts,
29};
30
31/// Run changed-code audit through typed programmatic runners.
32///
33/// # Errors
34///
35/// Returns a structured error for invalid options, base-ref discovery failures,
36/// unsupported CLI-only audit surfaces, or analysis failures.
37pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
38    validate_audit_api_options(options)?;
39    let start = Instant::now();
40    let resolved_base = resolve_audit_base_ref(options)?;
41    let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
42    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&analysis)?;
43    let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
44    let changed_files_count = changed_files.len();
45
46    if changed_files.is_empty() {
47        return Ok(empty_audit_output(
48            options,
49            resolved_base,
50            resolved.root(),
51            changed_files_count,
52            start.elapsed(),
53        ));
54    }
55
56    let head =
57        run_audit_subanalyses_with_context(options, &analysis, &resolved, Some(&changed_files))?;
58    let current_snapshot = snapshot_from_analyses(&head);
59    let base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
60        Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
61    } else {
62        None
63    };
64    let summary = build_programmatic_audit_summary(&head);
65    let attribution = compute_programmatic_audit_attribution(
66        options.gate,
67        &current_snapshot,
68        base_snapshot.as_ref(),
69    );
70    let verdict = compute_programmatic_audit_verdict(
71        options.gate,
72        &summary,
73        &head.duplication,
74        &current_snapshot,
75        base_snapshot.as_ref(),
76    );
77    let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
78
79    Ok(AuditProgrammaticOutput {
80        verdict,
81        summary,
82        attribution,
83        changed_files_count,
84        base_ref: resolved_base.git_ref,
85        base_description: resolved_base.description,
86        head_sha: repo_refs::short_head_sha(resolved.root()),
87        elapsed: start.elapsed(),
88        base_snapshot_skipped: None,
89        base_snapshot,
90        dead_code: Some(head.dead_code),
91        duplication: Some(head.duplication),
92        complexity: Some(head.complexity),
93        next_steps,
94        envelope_mode: root_envelope_mode(),
95        telemetry_analysis_run_id: None,
96    })
97}
98
99fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
100    if let Err(err) =
101        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
102    {
103        return Err(ProgrammaticError::new(err, 2)
104            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
105            .with_context("audit.coverageRoot"));
106    }
107    if options.runtime_coverage.is_some() {
108        return Err(ProgrammaticError::new(
109            "programmatic audit does not yet support runtime coverage; use the CLI path",
110            2,
111        )
112        .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
113        .with_context("audit.runtimeCoverage"));
114    }
115    Ok(())
116}
117
118pub(super) fn resolve_audit_base_ref(
119    options: &AuditOptions,
120) -> ProgrammaticResult<ResolvedAuditBase> {
121    if let Some(ref_str) = options
122        .base
123        .as_deref()
124        .or(options.analysis.changed_since.as_deref())
125    {
126        validate_git_ref(ref_str, "audit.base")?;
127        return Ok(ResolvedAuditBase {
128            git_ref: (*ref_str).to_string(),
129            description: None,
130        });
131    }
132    if let Some(env_ref) = audit_base_env_override() {
133        validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
134        return Ok(ResolvedAuditBase {
135            description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
136            git_ref: env_ref,
137        });
138    }
139    let root = options
140        .analysis
141        .root
142        .clone()
143        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
144    repo_refs::auto_detect_audit_base_ref(&root).ok_or_else(|| {
145        ProgrammaticError::new(
146            "could not detect base branch. Set audit.base to specify the comparison target",
147            2,
148        )
149        .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
150        .with_context("audit.base")
151    })
152}
153
154fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
155    AnalysisOptions {
156        changed_since: Some(base_ref.to_string()),
157        production: options.production,
158        production_override: options.production.then_some(true),
159        ..options.analysis.clone()
160    }
161}
162
163fn analysis_with_production(
164    analysis: &AnalysisOptions,
165    production_override: Option<bool>,
166) -> AnalysisOptions {
167    AnalysisOptions {
168        production: production_override.unwrap_or(analysis.production),
169        production_override: production_override.or(analysis.production_override),
170        ..analysis.clone()
171    }
172}
173
174fn empty_audit_output(
175    options: &AuditOptions,
176    base: ResolvedAuditBase,
177    root: &Path,
178    changed_files_count: usize,
179    elapsed: std::time::Duration,
180) -> AuditProgrammaticOutput {
181    AuditProgrammaticOutput {
182        verdict: AuditVerdict::Pass,
183        summary: AuditSummary {
184            dead_code_issues: 0,
185            dead_code_has_errors: false,
186            complexity_findings: 0,
187            max_cyclomatic: None,
188            duplication_clone_groups: 0,
189        },
190        attribution: AuditAttribution {
191            gate: options.gate,
192            ..AuditAttribution::default()
193        },
194        changed_files_count,
195        base_ref: base.git_ref,
196        base_description: base.description,
197        head_sha: repo_refs::short_head_sha(root),
198        elapsed,
199        base_snapshot_skipped: None,
200        base_snapshot: None,
201        dead_code: None,
202        duplication: None,
203        complexity: None,
204        next_steps: Vec::new(),
205        envelope_mode: root_envelope_mode(),
206        telemetry_analysis_run_id: None,
207    }
208}
209
210struct AuditSubanalyses {
211    dead_code: crate::DeadCodeProgrammaticOutput,
212    duplication: crate::DuplicationProgrammaticOutput,
213    complexity: crate::HealthProgrammaticOutput,
214}
215
216struct AuditSubanalysisOptions {
217    dead_code: DeadCodeOptions,
218    duplication: DuplicationOptions,
219    complexity: ComplexityOptions,
220}
221
222fn audit_subanalysis_options(
223    options: &AuditOptions,
224    analysis: &AnalysisOptions,
225) -> AuditSubanalysisOptions {
226    AuditSubanalysisOptions {
227        dead_code: DeadCodeOptions {
228            analysis: analysis_with_production(analysis, options.production_dead_code),
229            filters: DeadCodeFilters::default(),
230            files: Vec::new(),
231            include_entry_exports: options.include_entry_exports,
232        },
233        duplication: DuplicationOptions {
234            analysis: analysis_with_production(analysis, options.production_dupes),
235            ..DuplicationOptions::default()
236        },
237        complexity: ComplexityOptions {
238            analysis: analysis_with_production(analysis, options.production_health),
239            max_crap: options.max_crap,
240            complexity: true,
241            css: options.css.unwrap_or(true),
242            css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
243            coverage: options.coverage.clone(),
244            coverage_root: options.coverage_root.clone(),
245            ..ComplexityOptions::default()
246        },
247    }
248}
249
250fn run_audit_subanalyses(
251    options: &AuditOptions,
252    analysis: &AnalysisOptions,
253    changed_files: Option<&FxHashSet<PathBuf>>,
254) -> ProgrammaticResult<AuditSubanalyses> {
255    let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
256    run_audit_subanalyses_with_context(options, analysis, &resolved, changed_files)
257}
258
259fn run_audit_subanalyses_with_context(
260    options: &AuditOptions,
261    analysis: &AnalysisOptions,
262    resolved: &ProgrammaticAnalysisContext,
263    changed_files: Option<&FxHashSet<PathBuf>>,
264) -> ProgrammaticResult<AuditSubanalyses> {
265    let subanalysis_options = audit_subanalysis_options(options, analysis);
266    let production_modes = resolve_effective_production_modes(
267        resolved,
268        options.production_dead_code,
269        options.production_health,
270        options.production_dupes,
271    )?;
272
273    if production_modes.dead_code == production_modes.dupes
274        && production_modes.dead_code == production_modes.health
275    {
276        return run_shared_project_audit_subanalyses(&subanalysis_options, resolved, changed_files);
277    }
278
279    if production_modes.dead_code == production_modes.health {
280        return run_shared_dead_code_health_audit_subanalyses(
281            &subanalysis_options,
282            resolved,
283            changed_files,
284        );
285    }
286
287    if production_modes.dead_code == production_modes.dupes {
288        return run_shared_dead_code_dupes_audit_subanalyses(
289            &subanalysis_options,
290            resolved,
291            changed_files,
292        );
293    }
294
295    Ok(AuditSubanalyses {
296        dead_code: run_dead_code(&subanalysis_options.dead_code)?,
297        duplication: run_duplication(&subanalysis_options.duplication)?,
298        complexity: run_health(&subanalysis_options.complexity)?,
299    })
300}
301
302fn run_shared_project_audit_subanalyses(
303    options: &AuditSubanalysisOptions,
304    resolved: &ProgrammaticAnalysisContext,
305    changed_files: Option<&FxHashSet<PathBuf>>,
306) -> ProgrammaticResult<AuditSubanalyses> {
307    resolved.install(|| {
308        let session = super::dead_code::load_dead_code_session(&options.dead_code, resolved)?;
309        run_all_audit_subanalyses_with_project_artifacts(
310            &options.dead_code,
311            &options.duplication,
312            &options.complexity,
313            resolved,
314            &session,
315            changed_files,
316        )
317    })
318}
319
320fn run_shared_dead_code_health_audit_subanalyses(
321    options: &AuditSubanalysisOptions,
322    resolved: &ProgrammaticAnalysisContext,
323    changed_files: Option<&FxHashSet<PathBuf>>,
324) -> ProgrammaticResult<AuditSubanalyses> {
325    resolved.install(|| {
326        let dead_code_options = &options.dead_code;
327        let duplication_options = &options.duplication;
328        let complexity_options = &options.complexity;
329        let session = super::dead_code::load_dead_code_session(dead_code_options, resolved)?;
330        let (dead_code, complexity) = run_dead_code_and_health_with_session(
331            dead_code_options,
332            complexity_options,
333            resolved,
334            &session,
335            changed_files,
336        )?;
337        Ok(AuditSubanalyses {
338            dead_code,
339            duplication: run_duplication(duplication_options)?,
340            complexity,
341        })
342    })
343}
344
345fn run_shared_dead_code_dupes_audit_subanalyses(
346    options: &AuditSubanalysisOptions,
347    resolved: &ProgrammaticAnalysisContext,
348    changed_files: Option<&FxHashSet<PathBuf>>,
349) -> ProgrammaticResult<AuditSubanalyses> {
350    resolved.install(|| {
351        let session = super::dead_code::load_dead_code_session(&options.dead_code, resolved)?;
352        let (dead_code, duplication, _, _) =
353            run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
354                dead_code_options: &options.dead_code,
355                duplication_options: &options.duplication,
356                resolved,
357                session: &session,
358                changed_files,
359                retain_dead_code_artifacts: false,
360                retain_duplication_artifacts: false,
361            })?;
362        Ok(AuditSubanalyses {
363            dead_code,
364            duplication,
365            complexity: run_health(&options.complexity)?,
366        })
367    })
368}
369
370fn run_dead_code_and_duplication_with_project_artifacts(
371    input: ProjectArtifactAuditInput<'_>,
372) -> ProgrammaticResult<(
373    crate::DeadCodeProgrammaticOutput,
374    crate::DuplicationProgrammaticOutput,
375    Option<DeadCodeAnalysisArtifacts>,
376    Option<fallow_engine::duplicates::DuplicationReport>,
377)> {
378    let dupes_config = super::duplication::build_dupes_config(
379        input.duplication_options,
380        &input.session.config().duplicates,
381    );
382    let section_start = Instant::now();
383    let project = input
384        .session
385        .analyze_project_with_artifacts(
386            &dupes_config,
387            ProjectAnalysisArtifactOptions {
388                retain_complexity_artifacts: input.retain_dead_code_artifacts,
389                retain_graph: input.retain_dead_code_artifacts,
390                changed_files: input.changed_files.cloned(),
391                collect_source_fingerprints: false,
392            },
393        )
394        .map_err(|err| {
395            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
396                .with_code("FALLOW_AUDIT_FAILED")
397                .with_context("audit")
398        })?;
399    let duplication_artifacts = input
400        .retain_duplication_artifacts
401        .then(|| project.duplication.clone());
402    let dead_code = super::dead_code::run_dead_code_from_artifacts(
403        input.dead_code_options,
404        input.resolved,
405        input.session,
406        input.changed_files,
407        project.dead_code,
408        section_start,
409    )?;
410    let duplication = super::duplication::run_duplication_report_with_session(
411        input.duplication_options,
412        input.resolved,
413        input.session,
414        project.duplication,
415        section_start,
416    )?;
417    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
418        output: dead_code,
419        artifacts,
420    } = dead_code;
421    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
422    Ok((
423        dead_code,
424        duplication,
425        dead_code_artifacts,
426        duplication_artifacts,
427    ))
428}
429
430#[derive(Clone, Copy)]
431struct ProjectArtifactAuditInput<'a> {
432    dead_code_options: &'a DeadCodeOptions,
433    duplication_options: &'a DuplicationOptions,
434    resolved: &'a ProgrammaticAnalysisContext,
435    session: &'a AnalysisSession,
436    changed_files: Option<&'a FxHashSet<PathBuf>>,
437    retain_dead_code_artifacts: bool,
438    retain_duplication_artifacts: bool,
439}
440
441fn run_all_audit_subanalyses_with_project_artifacts(
442    dead_code_options: &DeadCodeOptions,
443    duplication_options: &DuplicationOptions,
444    complexity_options: &ComplexityOptions,
445    resolved: &ProgrammaticAnalysisContext,
446    session: &AnalysisSession,
447    changed_files: Option<&FxHashSet<PathBuf>>,
448) -> ProgrammaticResult<AuditSubanalyses> {
449    let retain_dead_code_artifacts =
450        health_may_consume_dead_code_artifacts(complexity_options, session.config());
451    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
452    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
453        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
454            dead_code_options,
455            duplication_options,
456            resolved,
457            session,
458            changed_files,
459            retain_dead_code_artifacts,
460            retain_duplication_artifacts,
461        })?;
462    let complexity = run_health_with_session_artifacts(
463        complexity_options,
464        resolved,
465        session,
466        changed_files,
467        dead_code_artifacts,
468        duplication_artifacts,
469    )?;
470    Ok(AuditSubanalyses {
471        dead_code,
472        duplication,
473        complexity,
474    })
475}
476
477fn run_dead_code_and_health_with_session(
478    dead_code_options: &DeadCodeOptions,
479    complexity_options: &ComplexityOptions,
480    resolved: &ProgrammaticAnalysisContext,
481    session: &AnalysisSession,
482    changed_files: Option<&FxHashSet<PathBuf>>,
483) -> ProgrammaticResult<(
484    crate::DeadCodeProgrammaticOutput,
485    crate::HealthProgrammaticOutput,
486)> {
487    let reuse_dead_code_artifacts =
488        health_may_consume_dead_code_artifacts(complexity_options, session.config());
489    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
490        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
491            dead_code_options,
492            resolved,
493            session,
494            changed_files,
495            |_| {},
496            Instant::now(),
497        )?;
498        (dead_code.output, Some(dead_code.artifacts))
499    } else {
500        (
501            super::dead_code::run_dead_code_with_session(
502                dead_code_options,
503                resolved,
504                session,
505                changed_files,
506                |_| {},
507                Instant::now(),
508            )?,
509            None,
510        )
511    };
512    let complexity = run_health_with_session_artifacts(
513        complexity_options,
514        resolved,
515        session,
516        changed_files,
517        dead_code_artifacts,
518        None,
519    )?;
520    Ok((dead_code, complexity))
521}
522
523fn build_programmatic_audit_summary(analyses: &AuditSubanalyses) -> AuditSummary {
524    let dead_code_issues = analyses.dead_code.output.results.total_issues();
525    AuditSummary {
526        dead_code_issues,
527        dead_code_has_errors: dead_code_issues > 0,
528        complexity_findings: analyses.complexity.report.findings.len(),
529        max_cyclomatic: analyses
530            .complexity
531            .report
532            .findings
533            .iter()
534            .map(|finding| finding.cyclomatic)
535            .max(),
536        duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
537    }
538}
539
540fn compute_programmatic_audit_verdict(
541    gate: AuditGate,
542    summary: &AuditSummary,
543    duplication: &crate::DuplicationProgrammaticOutput,
544    current: &AuditProgrammaticKeySnapshot,
545    base: Option<&AuditProgrammaticKeySnapshot>,
546) -> AuditVerdict {
547    if matches!(gate, AuditGate::NewOnly) {
548        return compute_programmatic_introduced_verdict(summary, duplication, current, base);
549    }
550    if summary.dead_code_has_errors || summary.complexity_findings > 0 {
551        return AuditVerdict::Fail;
552    }
553    if summary.duplication_clone_groups > 0 {
554        let pct = duplication.output.report.stats.duplication_percentage;
555        if duplication.threshold > 0.0 && pct > duplication.threshold {
556            return AuditVerdict::Fail;
557        }
558        return AuditVerdict::Warn;
559    }
560    AuditVerdict::Pass
561}
562
563fn compute_programmatic_introduced_verdict(
564    summary: &AuditSummary,
565    duplication: &crate::DuplicationProgrammaticOutput,
566    current: &AuditProgrammaticKeySnapshot,
567    base: Option<&AuditProgrammaticKeySnapshot>,
568) -> AuditVerdict {
569    let attribution = compute_programmatic_audit_attribution(AuditGate::NewOnly, current, base);
570    if attribution.dead_code_introduced > 0 || attribution.complexity_introduced > 0 {
571        return AuditVerdict::Fail;
572    }
573    if attribution.duplication_introduced > 0 {
574        let pct = duplication.output.report.stats.duplication_percentage;
575        if duplication.threshold > 0.0 && pct > duplication.threshold {
576            return AuditVerdict::Fail;
577        }
578        return AuditVerdict::Warn;
579    }
580    if summary.dead_code_issues == 0
581        && summary.complexity_findings == 0
582        && summary.duplication_clone_groups == 0
583    {
584        return AuditVerdict::Pass;
585    }
586    AuditVerdict::Pass
587}
588
589fn compute_programmatic_audit_attribution(
590    gate: AuditGate,
591    current: &AuditProgrammaticKeySnapshot,
592    base: Option<&AuditProgrammaticKeySnapshot>,
593) -> AuditAttribution {
594    let dead_code = count_introduced(&current.dead_code, base.map(|snapshot| &snapshot.dead_code));
595    let complexity = count_introduced(&current.health, base.map(|snapshot| &snapshot.health));
596    let duplication = count_introduced(&current.dupes, base.map(|snapshot| &snapshot.dupes));
597    AuditAttribution {
598        gate,
599        dead_code_introduced: dead_code.0,
600        dead_code_inherited: dead_code.1,
601        complexity_introduced: complexity.0,
602        complexity_inherited: complexity.1,
603        duplication_introduced: duplication.0,
604        duplication_inherited: duplication.1,
605    }
606}
607
608fn count_introduced(
609    keys: &rustc_hash::FxHashSet<String>,
610    base: Option<&rustc_hash::FxHashSet<String>>,
611) -> (usize, usize) {
612    let Some(base) = base else {
613        return (0, 0);
614    };
615    keys.iter().fold((0, 0), |(introduced, inherited), key| {
616        if base.contains(key) {
617            (introduced, inherited + 1)
618        } else {
619            (introduced + 1, inherited)
620        }
621    })
622}
623
624fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditProgrammaticKeySnapshot {
625    AuditProgrammaticKeySnapshot {
626        dead_code: crate::audit_keys::dead_code_keys(
627            &analyses.dead_code.output.results,
628            &analyses.dead_code.root,
629        ),
630        health: crate::audit_keys::health_keys(
631            &analyses.complexity.report,
632            &analyses.complexity.root,
633        ),
634        dupes: analyses
635            .duplication
636            .output
637            .report
638            .clone_groups
639            .iter()
640            .map(|group| {
641                crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
642            })
643            .collect(),
644    }
645}
646
647fn compute_base_snapshot(
648    options: &AuditOptions,
649    base_ref: &str,
650) -> ProgrammaticResult<AuditProgrammaticKeySnapshot> {
651    let current_root = analysis_root_from_options(options)?;
652    let worktree = TemporaryBaseWorktree::create(&current_root, base_ref).map_err(|err| {
653        ProgrammaticError::new(err.to_string(), 2)
654            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
655            .with_context("audit.base")
656    })?;
657    let base_root = repo_refs::base_analysis_root(&current_root, worktree.path());
658    let current_config_path = options
659        .analysis
660        .config_path
661        .clone()
662        .or_else(|| fallow_config::FallowConfig::find_config_path(&current_root));
663    let base_analysis = AnalysisOptions {
664        root: Some(base_root),
665        config_path: current_config_path,
666        changed_since: None,
667        explain: false,
668        ..options.analysis.clone()
669    };
670    let base = run_audit_subanalyses(options, &base_analysis, None)?;
671    Ok(snapshot_from_analyses(&base))
672}
673
674fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
675    match options.analysis.root.clone() {
676        Some(root) => Ok(root),
677        None => std::env::current_dir().map_err(|err| {
678            ProgrammaticError::new(
679                format!("failed to resolve current working directory: {err}"),
680                2,
681            )
682            .with_code("FALLOW_CWD_UNAVAILABLE")
683            .with_context("analysis.root")
684        }),
685    }
686}
687
688fn audit_next_steps(
689    dead_code: &crate::DeadCodeProgrammaticOutput,
690    complexity: &crate::HealthProgrammaticOutput,
691) -> Vec<NextStep> {
692    let input = fallow_output::build_audit_next_steps_input(
693        Some((&dead_code.output.results, dead_code.root.as_path())),
694        Some(&complexity.report),
695        crate::next_steps::suggestions_enabled(),
696    );
697    build_audit_next_steps(&input)
698}
699
700fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
701    fallow_engine::validate::validate_git_ref(value)
702        .map(|_| ())
703        .map_err(|err| {
704            ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
705                .with_code("FALLOW_INVALID_GIT_REF")
706                .with_context(context)
707        })
708}
709
710fn audit_base_env_override() -> Option<String> {
711    std::env::var("FALLOW_AUDIT_BASE")
712        .ok()
713        .map(|value| value.trim().to_string())
714        .filter(|value| !value.is_empty())
715}
716
717#[cfg(test)]
718mod tests {
719    use std::process::Command;
720
721    use fallow_config::{AuditGate, FallowConfig, HealthConfig};
722    use fallow_types::output_format::OutputFormat;
723
724    use super::*;
725
726    fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
727        FallowConfig {
728            health: HealthConfig {
729                max_crap,
730                ..HealthConfig::default()
731            },
732            ..FallowConfig::default()
733        }
734        .resolve(
735            std::env::temp_dir().join("fallow-api-runtime-test"),
736            OutputFormat::Json,
737            1,
738            true,
739            true,
740            None,
741        )
742    }
743
744    #[test]
745    fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
746        let options = ComplexityOptions {
747            complexity: true,
748            ..ComplexityOptions::default()
749        };
750        let config = resolved_config_with_max_crap(0.0);
751
752        assert!(!health_may_consume_dead_code_artifacts(&options, &config));
753    }
754
755    #[test]
756    fn audit_health_artifact_reuse_tracks_config_max_crap() {
757        let options = ComplexityOptions {
758            complexity: true,
759            ..ComplexityOptions::default()
760        };
761        let config = resolved_config_with_max_crap(30.0);
762
763        assert!(health_may_consume_dead_code_artifacts(&options, &config));
764    }
765
766    #[test]
767    fn audit_health_artifact_reuse_tracks_file_score_inputs() {
768        let config = resolved_config_with_max_crap(0.0);
769        for options in [
770            ComplexityOptions {
771                file_scores: true,
772                ..ComplexityOptions::default()
773            },
774            ComplexityOptions {
775                coverage_gaps: true,
776                ..ComplexityOptions::default()
777            },
778            ComplexityOptions {
779                targets: true,
780                ..ComplexityOptions::default()
781            },
782            ComplexityOptions {
783                score: true,
784                ..ComplexityOptions::default()
785            },
786            ComplexityOptions {
787                max_crap: Some(30.0),
788                complexity: true,
789                ..ComplexityOptions::default()
790            },
791        ] {
792            assert!(health_may_consume_dead_code_artifacts(&options, &config));
793        }
794    }
795
796    #[test]
797    fn audit_health_duplication_reuse_tracks_score_and_targets() {
798        for options in [
799            ComplexityOptions {
800                score: true,
801                ..ComplexityOptions::default()
802            },
803            ComplexityOptions {
804                targets: true,
805                ..ComplexityOptions::default()
806            },
807        ] {
808            assert!(health_may_consume_duplication_report(&options));
809        }
810
811        assert!(!health_may_consume_duplication_report(&ComplexityOptions {
812            complexity: true,
813            ..ComplexityOptions::default()
814        }));
815    }
816
817    #[test]
818    fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
819        let project = audit_fixture();
820        let output = run_audit(&AuditOptions {
821            analysis: AnalysisOptions {
822                root: Some(project.path().to_path_buf()),
823                no_cache: true,
824                explain: true,
825                ..AnalysisOptions::default()
826            },
827            base: Some("HEAD".to_string()),
828            gate: AuditGate::NewOnly,
829            ..AuditOptions::default()
830        })
831        .expect("audit output");
832
833        assert_eq!(output.verdict, AuditVerdict::Fail);
834        assert_eq!(output.summary.dead_code_issues, 1);
835        assert_eq!(output.attribution.dead_code_introduced, 1);
836        assert!(output.base_snapshot.is_some());
837
838        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
839        assert_eq!(
840            json["dead_code"]["unused_files"][0]["path"],
841            "src/feature.ts"
842        );
843        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
844    }
845
846    #[test]
847    fn empty_audit_output_uses_resolved_root_for_head_sha() {
848        let project = audit_fixture();
849        let output = empty_audit_output(
850            &AuditOptions {
851                analysis: AnalysisOptions {
852                    root: None,
853                    ..AnalysisOptions::default()
854                },
855                base: Some("HEAD".to_string()),
856                gate: AuditGate::NewOnly,
857                ..AuditOptions::default()
858            },
859            ResolvedAuditBase {
860                git_ref: "HEAD".to_string(),
861                description: None,
862            },
863            project.path(),
864            0,
865            std::time::Duration::ZERO,
866        );
867
868        assert!(output.head_sha.is_some());
869    }
870
871    fn audit_fixture() -> tempfile::TempDir {
872        let project = tempfile::tempdir().expect("project");
873        std::fs::create_dir_all(project.path().join("src")).expect("create src");
874        std::fs::write(
875            project.path().join("package.json"),
876            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
877        )
878        .expect("write package");
879        std::fs::write(
880            project.path().join("src/index.ts"),
881            "console.log('entry');\n",
882        )
883        .expect("write entry");
884        git(project.path(), &["init"]);
885        git(project.path(), &["add", "."]);
886        git(
887            project.path(),
888            &[
889                "-c",
890                "user.email=test@example.com",
891                "-c",
892                "user.name=Test",
893                "-c",
894                "commit.gpgsign=false",
895                "commit",
896                "-m",
897                "initial",
898            ],
899        );
900        std::fs::write(
901            project.path().join("src/feature.ts"),
902            "export const unused = 1;\n",
903        )
904        .expect("write changed source");
905        project
906    }
907
908    fn git(root: &Path, args: &[&str]) {
909        let status = Command::new("git")
910            .args(args)
911            .current_dir(root)
912            .status()
913            .expect("git command");
914        assert!(status.success(), "git {args:?} failed");
915    }
916}