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