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