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
31pub 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 ¤t_snapshot,
67 base_snapshot.as_ref(),
68 );
69 let verdict = compute_programmatic_audit_verdict(
70 options.gate,
71 &summary,
72 &head.duplication,
73 ¤t_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(¤t.dead_code, base.map(|snapshot| &snapshot.dead_code));
589 let complexity = count_introduced(¤t.health, base.map(|snapshot| &snapshot.health));
590 let duplication = count_introduced(¤t.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(¤t_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(¤t_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(¤t_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}