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