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