1use std::io::IsTerminal;
2use std::process::ExitCode;
3
4use colored::Colorize;
5use fallow_api::{
6 AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput, AuditSarifOutputInput,
7 DupesReportPayload,
8};
9use fallow_config::{AuditGate, OutputFormat, RulesConfig, Severity};
10use fallow_output::PrDecisionConclusion;
11use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
12use fallow_types::results::AnalysisResults;
13
14use crate::error::emit_error;
15use crate::report;
16use crate::report::plural;
17use crate::report::sink::outln;
18
19use super::keys::{
20 annotate_dead_code_json, annotate_dupes_json, annotate_health_json, styling_finding_key,
21};
22use super::{AuditResult, AuditSummary, AuditVerdict};
23
24#[must_use]
26pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
27 let format_exit = print_audit_format(result, quiet, explain);
28
29 if format_exit != ExitCode::SUCCESS {
30 return format_exit;
31 }
32
33 match result.verdict {
34 AuditVerdict::Fail => ExitCode::from(1),
35 AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
36 }
37}
38
39fn audit_decision_conclusion(verdict: AuditVerdict) -> PrDecisionConclusion {
40 match verdict {
41 AuditVerdict::Pass => PrDecisionConclusion::Success,
42 AuditVerdict::Warn => PrDecisionConclusion::Neutral,
43 AuditVerdict::Fail => PrDecisionConclusion::Failure,
44 }
45}
46
47fn print_audit_format(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
48 match result.output {
49 OutputFormat::Json => print_audit_json(result),
50 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
51 print_audit_human(result, quiet, explain, result.output);
52 ExitCode::SUCCESS
53 }
54 OutputFormat::Sarif => print_audit_sarif(result),
55 OutputFormat::CodeClimate => print_audit_codeclimate(result),
56 OutputFormat::PrCommentGithub => {
57 print_audit_pr_comment(result, report::ci::pr_comment::Provider::Github)
58 }
59 OutputFormat::PrCommentGitlab => {
60 print_audit_pr_comment(result, report::ci::pr_comment::Provider::Gitlab)
61 }
62 OutputFormat::ReviewGithub => {
63 print_audit_review(result, report::ci::pr_comment::Provider::Github)
64 }
65 OutputFormat::ReviewGitlab => {
66 print_audit_review(result, report::ci::pr_comment::Provider::Gitlab)
67 }
68 OutputFormat::Badge => {
69 eprintln!("Error: badge format is not supported for the audit command");
70 ExitCode::from(2)
71 }
72 }
73}
74
75fn print_audit_pr_comment(
76 result: &AuditResult,
77 provider: report::ci::pr_comment::Provider,
78) -> ExitCode {
79 let value = build_audit_codeclimate(result);
80 report::ci::pr_comment::print_pr_comment_with_conclusion(
81 "audit",
82 provider,
83 &value,
84 audit_decision_conclusion(result.verdict),
85 )
86}
87
88fn print_audit_review(
89 result: &AuditResult,
90 provider: report::ci::pr_comment::Provider,
91) -> ExitCode {
92 let value = build_audit_codeclimate(result);
93 report::ci::review::print_review_envelope("audit", provider, &value)
94}
95
96fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
97 let show_headers = matches!(output, OutputFormat::Human) && !quiet;
98
99 if !quiet {
100 let scope = format_scope_line(result);
101 eprintln!();
102 eprintln!("{scope}");
103 }
104
105 let has_check_issues = result.summary.dead_code_issues > 0;
106 let has_health_findings = result.summary.complexity_findings > 0;
107 let has_dupe_groups = result.summary.duplication_clone_groups > 0;
108 let has_styling = result.health.as_ref().is_some_and(|h| {
112 !h.report.styling_findings.is_empty()
113 || h.report
114 .css_analytics
115 .as_ref()
116 .is_some_and(|c| styling_candidate_count(&c.summary) > 0)
117 });
118
119 if has_check_issues || has_health_findings || has_dupe_groups || has_styling {
120 print_audit_findings(result, quiet, explain, show_headers);
121 }
122
123 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
124 crate::dupes::print_default_ignore_note(dupes, quiet);
125 crate::dupes::print_min_occurrences_note(dupes, quiet);
126 }
127
128 if !quiet {
129 print_audit_status_line(result);
130 }
131}
132
133pub fn print_audit_findings(result: &AuditResult, quiet: bool, explain: bool, show_headers: bool) {
136 print_audit_explain_tip(show_headers);
137
138 if result.verdict != AuditVerdict::Fail && !quiet {
139 print_audit_vital_signs(result);
140 }
141
142 if result.summary.dead_code_issues > 0
143 && let Some(ref check) = result.check
144 {
145 print_audit_dead_code_section(check, quiet, explain, show_headers);
146 }
147
148 if result.summary.duplication_clone_groups > 0
149 && let Some(ref dupes) = result.dupes
150 {
151 print_audit_duplication_section(dupes, quiet, explain, show_headers);
152 }
153
154 if result.summary.complexity_findings > 0
155 && let Some(ref health) = result.health
156 {
157 print_audit_complexity_section(health, quiet, explain, show_headers);
158 }
159
160 print_audit_styling_summary(result, show_headers);
161}
162
163fn print_audit_dead_code_section(
164 check: &crate::check::CheckResult,
165 quiet: bool,
166 explain: bool,
167 show_headers: bool,
168) {
169 print_audit_section_header(
170 show_headers,
171 "── Dead Code ──────────────────────────────────────",
172 );
173 crate::check::print_check_result(
174 check,
175 crate::check::PrintCheckOptions {
176 quiet,
177 explain,
178 regression_json: false,
179 group_by: None,
180 top: None,
181 summary: false,
182 summary_heading: true,
183 show_explain_tip: false,
184 },
185 );
186}
187
188fn print_audit_duplication_section(
189 dupes: &crate::dupes::DupesResult,
190 quiet: bool,
191 explain: bool,
192 show_headers: bool,
193) {
194 print_audit_section_header(
195 show_headers,
196 "── Duplication ────────────────────────────────────",
197 );
198 crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
199}
200
201fn print_audit_complexity_section(
202 health: &crate::health::HealthResult,
203 quiet: bool,
204 explain: bool,
205 show_headers: bool,
206) {
207 print_audit_section_header(
208 show_headers,
209 "── Complexity ─────────────────────────────────────",
210 );
211 crate::health::print_health_result(
212 health,
213 crate::health::HealthPrintOptions {
214 quiet,
215 explain,
216 gates: fallow_engine::health::HealthGateOptions::default(),
217 summary: false,
218 summary_heading: true,
219 show_explain_tip: false,
220 skip_score_and_trend: false,
221 css_requested: false,
222 },
223 );
224}
225
226fn styling_candidate_count(s: &fallow_output::CssAnalyticsSummary) -> u32 {
230 [
231 s.tailwind_arbitrary_values,
232 s.duplicate_declaration_blocks,
233 s.unreferenced_css_classes,
234 s.unused_theme_tokens,
235 s.unused_font_faces,
236 s.unused_property_registrations,
237 s.unused_layers,
238 s.scoped_unused_classes,
239 s.keyframes_unreferenced,
240 s.keyframes_undefined,
241 s.unresolved_class_references,
242 ]
243 .into_iter()
244 .fold(0u32, u32::saturating_add)
245}
246
247fn print_audit_styling_summary(result: &AuditResult, show_headers: bool) {
252 const FIX_CONFIDENTLY_TOP_N: usize = 5;
254 const VERIFY_FIRST_TOP_N: usize = 3;
255 let Some(ref health) = result.health else {
256 return;
257 };
258 let findings = &health.report.styling_findings;
259 let descriptive = health
260 .report
261 .css_analytics
262 .as_ref()
263 .map_or(0, |css| styling_candidate_count(&css.summary));
264 if findings.is_empty() && descriptive == 0 {
265 return;
266 }
267 print_audit_section_header(
268 show_headers,
269 "── Styling ────────────────────────────────────────",
270 );
271 if findings.is_empty() {
272 let noun = if descriptive == 1 {
274 "candidate"
275 } else {
276 "candidates"
277 };
278 outln!(
279 " {descriptive} styling {noun} {}",
280 "(run `fallow health --css` for detail)".dimmed()
281 );
282 return;
283 }
284 let rules = &health.config.rules;
285 let groups = build_audit_styling_groups(rules, findings);
286 let show_group_labels = !groups.fix_confidently.is_empty() && !groups.verify_first.is_empty();
287 print_audit_styling_group(
288 result,
289 rules,
290 "Fix confidently",
291 &groups.fix_confidently,
292 FIX_CONFIDENTLY_TOP_N.max(groups.gated_count),
293 show_group_labels,
294 );
295 print_audit_styling_group(
296 result,
297 rules,
298 "Verify first",
299 &groups.verify_first,
300 VERIFY_FIRST_TOP_N.max(groups.gated_count),
301 show_group_labels,
302 );
303 outln!(
304 " {}",
305 "(run `fallow audit --format json` for full styling detail)".dimmed()
306 );
307}
308
309struct AuditStylingGroups<'a> {
310 gated_count: usize,
311 fix_confidently: Vec<&'a fallow_output::StylingFinding>,
312 verify_first: Vec<&'a fallow_output::StylingFinding>,
313}
314
315fn build_audit_styling_groups<'a>(
316 rules: &RulesConfig,
317 findings: &'a [fallow_output::StylingFinding],
318) -> AuditStylingGroups<'a> {
319 let mut sorted: Vec<_> = findings.iter().collect();
320 sort_audit_styling_findings(rules, &mut sorted);
321 let gated_count = sorted
322 .iter()
323 .filter(|finding| styling_finding_is_error_gated(rules, &finding.code))
324 .count();
325 let fix_confidently = sorted
326 .iter()
327 .copied()
328 .filter(|finding| styling_finding_is_fix_confidently(finding))
329 .collect();
330 let verify_first = sorted
331 .iter()
332 .copied()
333 .filter(|finding| !styling_finding_is_fix_confidently(finding))
334 .collect();
335
336 AuditStylingGroups {
337 gated_count,
338 fix_confidently,
339 verify_first,
340 }
341}
342
343fn sort_audit_styling_findings(
344 rules: &RulesConfig,
345 findings: &mut [&fallow_output::StylingFinding],
346) {
347 findings.sort_by(|a, b| {
348 styling_finding_is_error_gated(rules, &b.code)
349 .cmp(&styling_finding_is_error_gated(rules, &a.code))
350 .then_with(|| a.path.cmp(&b.path))
351 .then_with(|| a.line.cmp(&b.line))
352 .then_with(|| a.code.cmp(&b.code))
353 .then_with(|| a.value.cmp(&b.value))
354 });
355}
356
357fn print_audit_styling_group(
358 result: &AuditResult,
359 rules: &RulesConfig,
360 label: &str,
361 findings: &[&fallow_output::StylingFinding],
362 top_n: usize,
363 show_label: bool,
364) {
365 if findings.is_empty() {
366 return;
367 }
368 if show_label {
369 outln!(" {}", label.bold());
370 }
371 let gated_count = findings
372 .iter()
373 .filter(|finding| styling_finding_is_error_gated(rules, &finding.code))
374 .count();
375 let visible_count = top_n.max(gated_count);
376 let indent = if show_label { " " } else { " " };
377 for finding in findings.iter().take(visible_count) {
378 outln!(
379 "{}{} {} {} {}",
380 indent,
381 format!("{}:{}", finding.path, finding.line).dimmed(),
382 finding.code,
383 finding.value,
384 styling_finding_audit_context(result, finding).dimmed()
385 );
386 }
387 let hidden = findings.len().saturating_sub(visible_count);
388 if hidden > 0 {
389 let noun = if hidden == 1 { "finding" } else { "findings" };
390 outln!(
391 "{}{}",
392 indent,
393 format!("+ {hidden} more styling {noun}").dimmed()
394 );
395 }
396}
397
398fn styling_finding_is_fix_confidently(finding: &fallow_output::StylingFinding) -> bool {
399 matches!(
400 finding.agent_disposition,
401 Some(fallow_output::StylingAgentDisposition::FixConfidently)
402 ) || matches!(
403 finding.confidence,
404 Some(fallow_output::StylingFindingConfidence::High)
405 )
406}
407
408fn styling_finding_is_error_gated(rules: &RulesConfig, code: &str) -> bool {
409 let (_, severity) = styling_finding_rule_context(rules, code);
410 severity == Severity::Error
411}
412
413fn styling_finding_rule_context(rules: &RulesConfig, code: &str) -> (String, Severity) {
414 let severity = match code {
415 "css-token-drift" => rules.css_token_drift,
416 "css-duplicate-block" => rules.css_duplicate_block,
417 "css-selector-complexity" => rules.css_selector_complexity,
418 "css-dead-surface" => rules.css_dead_surface,
419 "css-broken-reference" => rules.css_broken_reference,
420 _ => Severity::Warn,
421 };
422 (format!("rules.{code}"), severity)
423}
424
425fn styling_finding_audit_context(
426 result: &AuditResult,
427 finding: &fallow_output::StylingFinding,
428) -> String {
429 let Some(health) = result.health.as_ref() else {
430 let (rule, severity) = styling_finding_rule_context(&RulesConfig::default(), &finding.code);
431 return styling_finding_audit_context_label(severity, &rule, None, result.attribution.gate);
432 };
433 let (rule, severity) = styling_finding_rule_context(&health.config.rules, &finding.code);
434 let base_state = result.base_snapshot.as_ref().map(|snapshot| {
435 let key = styling_finding_key(finding, &health.config.root);
436 if snapshot.styling.contains(&key) {
437 format!(
438 "inherited styling debt from {}",
439 short_base_ref(&result.base_ref)
440 )
441 } else {
442 format!(
443 "introduced design-system drift since {}",
444 short_base_ref(&result.base_ref)
445 )
446 }
447 });
448 styling_finding_audit_context_label(severity, &rule, base_state, result.attribution.gate)
449}
450
451fn styling_finding_audit_context_label(
452 severity: Severity,
453 rule: &str,
454 base_state: Option<String>,
455 gate: AuditGate,
456) -> String {
457 let severity_label = match severity {
458 Severity::Off => "off",
459 Severity::Warn => "warn",
460 Severity::Error => "error",
461 };
462 let prefix = match (severity, gate, base_state.as_deref()) {
463 (Severity::Error, AuditGate::NewOnly, Some(state)) if state.starts_with("inherited") => {
464 "not gated"
465 }
466 (Severity::Error, _, _) => "gated",
467 _ => "advisory",
468 };
469 match base_state {
470 Some(state) => format!("({prefix}: {rule}={severity_label}, {state})"),
471 None => format!("({prefix}: {rule}={severity_label})"),
472 }
473}
474
475fn print_audit_explain_tip(show_headers: bool) {
477 if show_headers && std::io::stdout().is_terminal() && !crate::report::sink::is_redirected() {
478 println!(
479 "{}",
480 "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
481 .dimmed()
482 );
483 println!();
484 }
485}
486
487fn print_audit_section_header(show_headers: bool, header: &str) {
489 if show_headers {
490 eprintln!();
491 eprintln!("{header}");
492 }
493}
494
495fn short_base_ref(base_ref: &str) -> &str {
498 if base_ref.len() == 40 && base_ref.bytes().all(|b| b.is_ascii_hexdigit()) {
499 &base_ref[..12]
500 } else {
501 base_ref
502 }
503}
504
505fn format_scope_line(result: &AuditResult) -> String {
509 format_scope_line_parts(
510 result.changed_files_count,
511 &result.base_ref,
512 result.base_description.as_deref(),
513 result.head_sha.as_deref(),
514 )
515}
516
517fn format_scope_line_parts(
518 changed_files_count: usize,
519 base_ref: &str,
520 base_description: Option<&str>,
521 head_sha: Option<&str>,
522) -> String {
523 let sha_suffix = head_sha.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
524 let base_display = match base_description {
525 Some(description) => format!("{} ({description})", short_base_ref(base_ref)),
526 None => base_ref.to_string(),
527 };
528 format!(
529 "Audit scope: {} changed file{} vs {}{}",
530 changed_files_count,
531 plural(changed_files_count),
532 base_display,
533 sha_suffix
534 )
535}
536
537fn print_audit_vital_signs(result: &AuditResult) {
539 let line = build_vital_sign_parts(&result.summary).join(" \u{00b7} ");
540 outln!(
541 "{} {} {}",
542 "\u{25a0}".dimmed(),
543 "Metrics:".dimmed(),
544 line.dimmed()
545 );
546}
547
548fn build_vital_sign_parts(summary: &AuditSummary) -> Vec<String> {
549 let mut parts = Vec::new();
550 parts.push(format!("dead code {}", summary.dead_code_issues));
551 if let Some(max) = summary.max_cyclomatic {
552 parts.push(format!(
553 "complexity {} (warn, max cyclomatic: {max})",
554 summary.complexity_findings
555 ));
556 } else {
557 parts.push(format!("complexity {}", summary.complexity_findings));
558 }
559 parts.push(format!("duplication {}", summary.duplication_clone_groups));
560 parts
561}
562
563fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
565 let mut parts = Vec::new();
566 if summary.dead_code_issues > 0 {
567 let n = summary.dead_code_issues;
568 parts.push(format!("dead code: {n} issue{}", plural(n)));
569 }
570 if summary.complexity_findings > 0 {
571 let n = summary.complexity_findings;
572 parts.push(format!("complexity: {n} finding{}", plural(n)));
573 }
574 if summary.duplication_clone_groups > 0 {
575 let n = summary.duplication_clone_groups;
576 parts.push(format!("duplication: {n} clone group{}", plural(n)));
577 }
578 parts
579}
580
581fn print_audit_status_line(result: &AuditResult) {
583 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
584 let n = result.changed_files_count;
585 let files_str = format!("{n} changed file{}", plural(n));
586
587 match result.verdict {
588 AuditVerdict::Pass => {
589 eprintln!(
590 "{}",
591 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
592 .green()
593 .bold()
594 );
595 }
596 AuditVerdict::Warn => {
597 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
598 eprintln!(
599 "{}",
600 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
601 .green()
602 .bold()
603 );
604 }
605 AuditVerdict::Fail => {
606 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
607 eprintln!(
608 "{}",
609 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
610 .red()
611 .bold()
612 );
613 }
614 }
615
616 if !matches!(result.attribution.gate, AuditGate::All) {
617 let inherited = result.attribution.dead_code_inherited
618 + result.attribution.complexity_inherited
619 + result.attribution.duplication_inherited;
620 if inherited > 0 {
621 eprintln!(
622 " {}",
623 format!(
624 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
625 plural(inherited)
626 )
627 .dimmed()
628 );
629 }
630 }
631 if result.performance {
632 eprintln!(
633 " {}",
634 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
635 );
636 }
637}
638
639fn print_audit_json(result: &AuditResult) -> ExitCode {
640 let output = match build_audit_json_output(result) {
641 Ok(output) => output,
642 Err(code) => return code,
643 };
644 report::emit_json(&output, "audit")
645}
646
647fn build_audit_json_output(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
648 let mut check_results = result.check.as_ref().map(|check| check.results.clone());
649 let mut health_report = result.health.as_ref().map(|health| health.report.clone());
650 fallow_output::harmonize_dead_code_health_suppress_line_actions(
651 check_results.as_mut(),
652 health_report.as_mut(),
653 );
654
655 let dead_code = match (result.check.as_ref(), check_results.as_ref()) {
656 (Some(check), Some(results)) => Some(build_audit_dead_code_json_with_results(
657 result, check, results,
658 )?),
659 _ => None,
660 };
661 let duplication = result
662 .dupes
663 .as_ref()
664 .map(|dupes| build_audit_duplication_json(result, dupes))
665 .transpose()?;
666 let complexity = match (result.health.as_ref(), health_report.as_ref()) {
667 (Some(health), Some(report)) => {
668 Some(build_audit_health_json_with_report(result, health, report)?)
669 }
670 _ => None,
671 };
672
673 fallow_api::serialize_audit_json(
674 AuditJsonOutputInput {
675 header: audit_json_header_input(result),
676 dead_code,
677 duplication,
678 complexity,
679 next_steps: audit_next_steps(result),
680 },
681 crate::output_runtime::current_root_envelope_mode(),
682 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
683 )
684 .map_err(|err| {
685 emit_error(
686 &format!("JSON serialization error: {err}"),
687 2,
688 OutputFormat::Json,
689 )
690 })
691}
692
693fn elapsed_ms_for_output(elapsed: std::time::Duration) -> u64 {
694 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
695}
696
697fn changed_files_count_for_output(changed_files_count: usize) -> u32 {
698 u32::try_from(changed_files_count).unwrap_or(u32::MAX)
699}
700
701pub fn audit_json_header_input(result: &AuditResult) -> AuditJsonHeaderInput {
702 AuditJsonHeaderInput {
703 schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
704 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
705 verdict: result.verdict,
706 changed_files_count: changed_files_count_for_output(result.changed_files_count),
707 base_ref: result.base_ref.clone(),
708 base_description: result.base_description.clone(),
709 head_sha: result.head_sha.clone(),
710 elapsed_ms: ElapsedMs(elapsed_ms_for_output(result.elapsed)),
711 base_snapshot_skipped: result.performance.then_some(result.base_snapshot_skipped),
712 summary: result.summary.clone(),
713 attribution: result.attribution.clone(),
714 }
715}
716
717pub fn insert_audit_dead_code_json(
718 obj: &mut serde_json::Map<String, serde_json::Value>,
719 result: &AuditResult,
720 check: &crate::check::CheckResult,
721) -> Result<(), ExitCode> {
722 let json = build_audit_dead_code_json(result, check)?;
723 obj.insert("dead_code".into(), json);
724 Ok(())
725}
726
727fn build_audit_dead_code_json(
728 result: &AuditResult,
729 check: &crate::check::CheckResult,
730) -> Result<serde_json::Value, ExitCode> {
731 build_audit_dead_code_json_with_results(result, check, &check.results)
732}
733
734fn build_audit_dead_code_json_with_results(
735 result: &AuditResult,
736 check: &crate::check::CheckResult,
737 results: &AnalysisResults,
738) -> Result<serde_json::Value, ExitCode> {
739 match report::api_check_json_payload_with_config_fixable(
740 results,
741 &check.config.root,
742 check.elapsed,
743 check.config_fixable,
744 ) {
745 Ok(mut json) => {
746 if let Some(ref base) = result.base_snapshot {
747 annotate_dead_code_json(&mut json, results, &check.config.root, &base.dead_code);
748 }
749 Ok(json)
750 }
751 Err(e) => Err(emit_error(
752 &format!("JSON serialization error: {e}"),
753 2,
754 OutputFormat::Json,
755 )),
756 }
757}
758
759pub fn insert_audit_duplication_json(
760 obj: &mut serde_json::Map<String, serde_json::Value>,
761 result: &AuditResult,
762 dupes: &crate::dupes::DupesResult,
763) -> Result<(), ExitCode> {
764 let json = build_audit_duplication_json(result, dupes)?;
765 obj.insert("duplication".into(), json);
766 Ok(())
767}
768
769fn build_audit_duplication_json(
770 result: &AuditResult,
771 dupes: &crate::dupes::DupesResult,
772) -> Result<serde_json::Value, ExitCode> {
773 let payload = DupesReportPayload::from_report(&dupes.report);
774 match serde_json::to_value(&payload) {
775 Ok(mut json) => {
776 let root_prefix = format!("{}/", dupes.config.root.display());
777 report::strip_root_prefix(&mut json, &root_prefix);
778 if let Some(ref base) = result.base_snapshot {
779 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
780 }
781 Ok(json)
782 }
783 Err(e) => Err(emit_error(
784 &format!("JSON serialization error: {e}"),
785 2,
786 OutputFormat::Json,
787 )),
788 }
789}
790
791pub fn insert_audit_health_json(
792 obj: &mut serde_json::Map<String, serde_json::Value>,
793 result: &AuditResult,
794 health: &crate::health::HealthResult,
795) -> Result<(), ExitCode> {
796 let json = build_audit_health_json(result, health)?;
797 obj.insert("complexity".into(), json);
798 Ok(())
799}
800
801fn build_audit_health_json(
802 result: &AuditResult,
803 health: &crate::health::HealthResult,
804) -> Result<serde_json::Value, ExitCode> {
805 build_audit_health_json_with_report(result, health, &health.report)
806}
807
808fn build_audit_health_json_with_report(
809 result: &AuditResult,
810 health: &crate::health::HealthResult,
811 report: &fallow_output::HealthReport,
812) -> Result<serde_json::Value, ExitCode> {
813 match serde_json::to_value(report) {
814 Ok(mut json) => {
815 let root_prefix = format!("{}/", health.config.root.display());
816 report::strip_root_prefix(&mut json, &root_prefix);
817 if let Some(ref base) = result.base_snapshot {
818 annotate_health_json(&mut json, report, &health.config.root, &base.health);
819 }
820 Ok(json)
821 }
822 Err(e) => Err(emit_error(
823 &format!("JSON serialization error: {e}"),
824 2,
825 OutputFormat::Json,
826 )),
827 }
828}
829
830fn audit_next_steps(result: &AuditResult) -> Vec<fallow_types::output::NextStep> {
831 let input = fallow_output::build_audit_next_steps_input(
832 result
833 .check
834 .as_ref()
835 .map(|check| (&check.results, check.config.root.as_path())),
836 result.health.as_ref().map(|health| &health.report),
837 crate::report::suggestions::suggestions_enabled(),
838 );
839 fallow_output::build_audit_next_steps(&input)
840}
841
842fn print_audit_sarif(result: &AuditResult) -> ExitCode {
843 let check_sarif = result.check.as_ref().map(|check| {
844 report::api_sarif_document(&check.results, &check.config.root, &check.config.rules)
845 });
846 let health_sarif = result
847 .health
848 .as_ref()
849 .map(|health| report::api_health_sarif_document(&health.report, &health.config.root));
850 let combined = fallow_api::build_audit_sarif(AuditSarifOutputInput {
851 dead_code: check_sarif.as_ref(),
852 duplication: result.dupes.as_ref().map(|dupes| &dupes.report),
853 health: health_sarif.as_ref(),
854 });
855
856 report::emit_json(&combined, "SARIF audit")
857}
858
859fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
860 let value = build_audit_codeclimate(result);
861 report::emit_json(&value, "CodeClimate audit")
862}
863
864fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
865 fallow_api::build_audit_codeclimate(AuditCodeClimateOutputInput {
866 dead_code: result.check.as_ref().map_or_else(Vec::new, |check| {
867 fallow_api::build_codeclimate(&check.results, &check.config.root, &check.config.rules)
868 }),
869 duplication: result.dupes.as_ref().map_or_else(Vec::new, |dupes| {
870 fallow_api::build_duplication_codeclimate(&dupes.report, &dupes.config.root)
871 }),
872 health: result.health.as_ref().map_or_else(Vec::new, |health| {
873 fallow_api::build_health_codeclimate(&health.report, &health.config.root)
874 }),
875 })
876}
877
878#[cfg(test)]
879mod tests {
880 use std::process::ExitCode;
881 use std::time::Duration;
882
883 use fallow_config::{AuditGate, OutputFormat};
884 use fallow_output::PrDecisionConclusion;
885
886 use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
887
888 use super::{
889 audit_decision_conclusion, build_audit_codeclimate, build_audit_json_output,
890 build_status_parts, build_vital_sign_parts, format_scope_line_parts, print_audit_result,
891 short_base_ref, styling_finding_audit_context_label,
892 };
893
894 fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
895 AuditResult {
896 verdict,
897 summary: AuditSummary {
898 dead_code_issues: 0,
899 dead_code_has_errors: false,
900 complexity_findings: 0,
901 max_cyclomatic: None,
902 duplication_clone_groups: 0,
903 },
904 attribution: AuditAttribution {
905 gate: AuditGate::NewOnly,
906 ..AuditAttribution::default()
907 },
908 base_snapshot: None,
909 base_snapshot_skipped: false,
910 changed_files_count: 0,
911 changed_files: Vec::new(),
912 base_ref: "origin/main".to_string(),
913 base_description: None,
914 head_sha: None,
915 output,
916 performance: false,
917 check: None,
918 dupes: None,
919 health: None,
920 elapsed: Duration::ZERO,
921 review_deltas: None,
922 weakening_signals: Vec::new(),
923 routing: None,
924 decision_surface: None,
925 graph_snapshot_hash: None,
926 change_anchors: Vec::new(),
927 }
928 }
929
930 #[test]
931 fn short_base_ref_abbreviates_full_sha() {
932 assert_eq!(
933 short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
934 "611d151e8250"
935 );
936 }
937
938 #[test]
939 fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
940 assert_eq!(short_base_ref("main"), "main");
941 assert_eq!(short_base_ref("origin/main"), "origin/main");
942 assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
943 assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
945 assert_eq!(
947 short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
948 "611d151e8250146426ff3178e94207f8a8d3ccZZ"
949 );
950 }
951
952 #[test]
953 fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
954 assert_eq!(
955 format_scope_line_parts(
956 1,
957 "611d151e8250146426ff3178e94207f8a8d3cc7b",
958 Some("merge-base with origin/main"),
959 Some("HEADSHA")
960 ),
961 "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
962 );
963 assert_eq!(
964 format_scope_line_parts(3, "origin/main", None, None),
965 "Audit scope: 3 changed files vs origin/main"
966 );
967 }
968
969 #[test]
970 fn styling_finding_audit_context_label_explains_gate_state() {
971 assert_eq!(
972 styling_finding_audit_context_label(
973 fallow_config::Severity::Error,
974 "rules.css-selector-complexity",
975 Some("introduced design-system drift since HEAD".to_string()),
976 AuditGate::NewOnly,
977 ),
978 "(gated: rules.css-selector-complexity=error, introduced design-system drift since HEAD)"
979 );
980 assert_eq!(
981 styling_finding_audit_context_label(
982 fallow_config::Severity::Error,
983 "rules.css-selector-complexity",
984 Some("inherited styling debt from HEAD".to_string()),
985 AuditGate::NewOnly,
986 ),
987 "(not gated: rules.css-selector-complexity=error, inherited styling debt from HEAD)"
988 );
989 assert_eq!(
990 styling_finding_audit_context_label(
991 fallow_config::Severity::Warn,
992 "rules.css-selector-complexity",
993 None,
994 AuditGate::All,
995 ),
996 "(advisory: rules.css-selector-complexity=warn)"
997 );
998 }
999
1000 #[test]
1001 fn build_status_parts_describes_only_non_empty_categories() {
1002 let summary = AuditSummary {
1003 dead_code_issues: 1,
1004 dead_code_has_errors: true,
1005 complexity_findings: 2,
1006 max_cyclomatic: Some(12),
1007 duplication_clone_groups: 3,
1008 };
1009
1010 assert_eq!(
1011 build_status_parts(&summary),
1012 vec![
1013 "dead code: 1 issue".to_string(),
1014 "complexity: 2 findings".to_string(),
1015 "duplication: 3 clone groups".to_string(),
1016 ]
1017 );
1018
1019 let empty = AuditSummary {
1020 dead_code_issues: 0,
1021 dead_code_has_errors: false,
1022 complexity_findings: 0,
1023 max_cyclomatic: None,
1024 duplication_clone_groups: 0,
1025 };
1026 assert!(build_status_parts(&empty).is_empty());
1027 }
1028
1029 #[test]
1030 fn build_vital_sign_parts_includes_warn_threshold_when_present() {
1031 let summary = AuditSummary {
1032 dead_code_issues: 0,
1033 dead_code_has_errors: false,
1034 complexity_findings: 2,
1035 max_cyclomatic: Some(18),
1036 duplication_clone_groups: 1,
1037 };
1038
1039 assert_eq!(
1040 build_vital_sign_parts(&summary),
1041 vec![
1042 "dead code 0".to_string(),
1043 "complexity 2 (warn, max cyclomatic: 18)".to_string(),
1044 "duplication 1".to_string(),
1045 ]
1046 );
1047 }
1048
1049 #[test]
1050 fn build_vital_sign_parts_omits_threshold_when_absent() {
1051 let summary = AuditSummary {
1052 dead_code_issues: 3,
1053 dead_code_has_errors: false,
1054 complexity_findings: 0,
1055 max_cyclomatic: None,
1056 duplication_clone_groups: 0,
1057 };
1058
1059 assert_eq!(
1060 build_vital_sign_parts(&summary),
1061 vec![
1062 "dead code 3".to_string(),
1063 "complexity 0".to_string(),
1064 "duplication 0".to_string(),
1065 ]
1066 );
1067 }
1068
1069 #[test]
1070 fn build_audit_codeclimate_returns_empty_issue_list_without_findings() {
1071 let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
1072
1073 assert_eq!(build_audit_codeclimate(&result), serde_json::json!([]));
1074 }
1075
1076 #[test]
1077 fn print_audit_result_rejects_badge_format() {
1078 let result = audit_result(AuditVerdict::Pass, OutputFormat::Badge);
1079
1080 assert_eq!(print_audit_result(&result, true, false), ExitCode::from(2));
1081 }
1082
1083 #[test]
1084 fn print_audit_result_maps_fail_verdict_to_error_exit() {
1085 let result = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1086
1087 assert_eq!(print_audit_result(&result, true, false), ExitCode::from(1));
1088 }
1089
1090 #[test]
1091 fn audit_verdict_maps_to_pr_decision_conclusion() {
1092 assert_eq!(
1093 audit_decision_conclusion(AuditVerdict::Pass),
1094 PrDecisionConclusion::Success
1095 );
1096 assert_eq!(
1097 audit_decision_conclusion(AuditVerdict::Warn),
1098 PrDecisionConclusion::Neutral
1099 );
1100 assert_eq!(
1101 audit_decision_conclusion(AuditVerdict::Fail),
1102 PrDecisionConclusion::Failure
1103 );
1104 }
1105
1106 fn audit_result_with_findings(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1107 let mut result = audit_result(verdict, output);
1108 result.summary = AuditSummary {
1109 dead_code_issues: 2,
1110 dead_code_has_errors: true,
1111 complexity_findings: 1,
1112 max_cyclomatic: Some(14),
1113 duplication_clone_groups: 3,
1114 };
1115 result.changed_files_count = 4;
1116 result
1117 }
1118
1119 #[test]
1120 fn print_audit_json_emits_optional_header_fields() {
1121 let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
1122 result.base_description = Some("merge-base with origin/main".to_string());
1123 result.head_sha = Some("abc123".to_string());
1124 result.performance = true;
1125 result.base_snapshot_skipped = true;
1126 result.changed_files_count = 5;
1127
1128 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1132 }
1133
1134 #[test]
1135 fn build_audit_json_output_uses_typed_audit_contract() {
1136 let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
1137 result.base_description = Some("merge-base with origin/main".to_string());
1138 result.head_sha = Some("abc123".to_string());
1139 result.performance = true;
1140 result.base_snapshot_skipped = true;
1141 result.changed_files_count = 5;
1142
1143 let json = build_audit_json_output(&result).expect("audit JSON should build");
1144
1145 assert_eq!(json["kind"], "audit");
1146 assert_eq!(json["command"], "audit");
1147 assert_eq!(json["base_description"], "merge-base with origin/main");
1148 assert_eq!(json["head_sha"], "abc123");
1149 assert_eq!(json["base_snapshot_skipped"], true);
1150 assert_eq!(json["changed_files_count"], 5);
1151 }
1152
1153 #[test]
1154 fn print_audit_result_renders_sarif_skeleton_without_findings() {
1155 let result = audit_result(AuditVerdict::Pass, OutputFormat::Sarif);
1156
1157 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1158 }
1159
1160 #[test]
1161 fn print_audit_result_renders_codeclimate_without_findings() {
1162 let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
1163
1164 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1165 }
1166
1167 #[test]
1168 fn print_audit_result_renders_pr_comment_for_both_providers() {
1169 for format in [OutputFormat::PrCommentGithub, OutputFormat::PrCommentGitlab] {
1170 let result = audit_result(AuditVerdict::Pass, format);
1171 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1172 }
1173 }
1174
1175 #[test]
1176 fn print_audit_result_renders_review_envelope_for_both_providers() {
1177 for format in [OutputFormat::ReviewGithub, OutputFormat::ReviewGitlab] {
1178 let result = audit_result(AuditVerdict::Pass, format);
1179 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1180 }
1181 }
1182
1183 #[test]
1184 fn print_audit_result_compact_and_markdown_use_human_path() {
1185 for format in [OutputFormat::Compact, OutputFormat::Markdown] {
1186 let result = audit_result(AuditVerdict::Pass, format);
1187 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1188 }
1189 }
1190
1191 #[test]
1192 fn print_audit_result_human_pass_renders_scope_and_status_line() {
1193 let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Human);
1194 result.changed_files_count = 2;
1195
1196 assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
1198 }
1199
1200 #[test]
1201 fn print_audit_result_human_warn_renders_vital_signs_and_notes() {
1202 let mut result = audit_result_with_findings(AuditVerdict::Warn, OutputFormat::Human);
1203 result.attribution = AuditAttribution {
1204 gate: AuditGate::NewOnly,
1205 dead_code_inherited: 2,
1206 complexity_inherited: 1,
1207 duplication_inherited: 0,
1208 ..AuditAttribution::default()
1209 };
1210 result.performance = true;
1211
1212 assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
1215 }
1216
1217 #[test]
1218 fn print_audit_result_human_fail_renders_red_status_line() {
1219 let result = audit_result_with_findings(AuditVerdict::Fail, OutputFormat::Human);
1220
1221 assert_eq!(print_audit_result(&result, false, false), ExitCode::from(1));
1223 }
1224}