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};
10use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
11
12use crate::error::emit_error;
13use crate::report;
14use crate::report::plural;
15use crate::report::sink::outln;
16
17use super::keys::{annotate_dead_code_json, annotate_dupes_json, annotate_health_json};
18use super::{AuditResult, AuditSummary, AuditVerdict};
19
20#[must_use]
22pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
23 let output = result.output;
24
25 let format_exit = match output {
26 OutputFormat::Json => print_audit_json(result),
27 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
28 print_audit_human(result, quiet, explain, output);
29 ExitCode::SUCCESS
30 }
31 OutputFormat::Sarif => print_audit_sarif(result),
32 OutputFormat::CodeClimate => print_audit_codeclimate(result),
33 OutputFormat::PrCommentGithub => {
34 let value = build_audit_codeclimate(result);
35 report::ci::pr_comment::print_pr_comment(
36 "audit",
37 report::ci::pr_comment::Provider::Github,
38 &value,
39 )
40 }
41 OutputFormat::PrCommentGitlab => {
42 let value = build_audit_codeclimate(result);
43 report::ci::pr_comment::print_pr_comment(
44 "audit",
45 report::ci::pr_comment::Provider::Gitlab,
46 &value,
47 )
48 }
49 OutputFormat::ReviewGithub => {
50 let value = build_audit_codeclimate(result);
51 report::ci::review::print_review_envelope(
52 "audit",
53 report::ci::pr_comment::Provider::Github,
54 &value,
55 )
56 }
57 OutputFormat::ReviewGitlab => {
58 let value = build_audit_codeclimate(result);
59 report::ci::review::print_review_envelope(
60 "audit",
61 report::ci::pr_comment::Provider::Gitlab,
62 &value,
63 )
64 }
65 OutputFormat::Badge => {
66 eprintln!("Error: badge format is not supported for the audit command");
67 return ExitCode::from(2);
68 }
69 };
70
71 if format_exit != ExitCode::SUCCESS {
72 return format_exit;
73 }
74
75 match result.verdict {
76 AuditVerdict::Fail => ExitCode::from(1),
77 AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
78 }
79}
80
81fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
82 let show_headers = matches!(output, OutputFormat::Human) && !quiet;
83
84 if !quiet {
85 let scope = format_scope_line(result);
86 eprintln!();
87 eprintln!("{scope}");
88 }
89
90 let has_check_issues = result.summary.dead_code_issues > 0;
91 let has_health_findings = result.summary.complexity_findings > 0;
92 let has_dupe_groups = result.summary.duplication_clone_groups > 0;
93
94 if has_check_issues || has_health_findings || has_dupe_groups {
95 print_audit_findings(result, quiet, explain, show_headers);
96 }
97
98 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
99 crate::dupes::print_default_ignore_note(dupes, quiet);
100 crate::dupes::print_min_occurrences_note(dupes, quiet);
101 }
102
103 if !quiet {
104 print_audit_status_line(result);
105 }
106}
107
108pub fn print_audit_findings(result: &AuditResult, quiet: bool, explain: bool, show_headers: bool) {
111 print_audit_explain_tip(show_headers);
112
113 if result.verdict != AuditVerdict::Fail && !quiet {
114 print_audit_vital_signs(result);
115 }
116
117 if result.summary.dead_code_issues > 0
118 && let Some(ref check) = result.check
119 {
120 print_audit_section_header(
121 show_headers,
122 "── Dead Code ──────────────────────────────────────",
123 );
124 crate::check::print_check_result(
125 check,
126 crate::check::PrintCheckOptions {
127 quiet,
128 explain,
129 regression_json: false,
130 group_by: None,
131 top: None,
132 summary: false,
133 summary_heading: true,
134 show_explain_tip: false,
135 },
136 );
137 }
138
139 if result.summary.duplication_clone_groups > 0
140 && let Some(ref dupes) = result.dupes
141 {
142 print_audit_section_header(
143 show_headers,
144 "── Duplication ────────────────────────────────────",
145 );
146 crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
147 }
148
149 if result.summary.complexity_findings > 0
150 && let Some(ref health) = result.health
151 {
152 print_audit_section_header(
153 show_headers,
154 "── Complexity ─────────────────────────────────────",
155 );
156 crate::health::print_health_result(
157 health,
158 crate::health::HealthPrintOptions {
159 quiet,
160 explain,
161 gates: fallow_engine::HealthGateOptions::default(),
162 summary: false,
163 summary_heading: true,
164 show_explain_tip: false,
165 skip_score_and_trend: false,
166 },
167 );
168 }
169}
170
171fn print_audit_explain_tip(show_headers: bool) {
173 if show_headers && std::io::stdout().is_terminal() && !crate::report::sink::is_redirected() {
174 println!(
175 "{}",
176 "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
177 .dimmed()
178 );
179 println!();
180 }
181}
182
183fn print_audit_section_header(show_headers: bool, header: &str) {
185 if show_headers {
186 eprintln!();
187 eprintln!("{header}");
188 }
189}
190
191fn short_base_ref(base_ref: &str) -> &str {
194 if base_ref.len() == 40 && base_ref.bytes().all(|b| b.is_ascii_hexdigit()) {
195 &base_ref[..12]
196 } else {
197 base_ref
198 }
199}
200
201fn format_scope_line(result: &AuditResult) -> String {
205 format_scope_line_parts(
206 result.changed_files_count,
207 &result.base_ref,
208 result.base_description.as_deref(),
209 result.head_sha.as_deref(),
210 )
211}
212
213fn format_scope_line_parts(
214 changed_files_count: usize,
215 base_ref: &str,
216 base_description: Option<&str>,
217 head_sha: Option<&str>,
218) -> String {
219 let sha_suffix = head_sha.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
220 let base_display = match base_description {
221 Some(description) => format!("{} ({description})", short_base_ref(base_ref)),
222 None => base_ref.to_string(),
223 };
224 format!(
225 "Audit scope: {} changed file{} vs {}{}",
226 changed_files_count,
227 plural(changed_files_count),
228 base_display,
229 sha_suffix
230 )
231}
232
233fn print_audit_vital_signs(result: &AuditResult) {
235 let line = build_vital_sign_parts(&result.summary).join(" \u{00b7} ");
236 outln!(
237 "{} {} {}",
238 "\u{25a0}".dimmed(),
239 "Metrics:".dimmed(),
240 line.dimmed()
241 );
242}
243
244fn build_vital_sign_parts(summary: &AuditSummary) -> Vec<String> {
245 let mut parts = Vec::new();
246 parts.push(format!("dead code {}", summary.dead_code_issues));
247 if let Some(max) = summary.max_cyclomatic {
248 parts.push(format!(
249 "complexity {} (warn, max cyclomatic: {max})",
250 summary.complexity_findings
251 ));
252 } else {
253 parts.push(format!("complexity {}", summary.complexity_findings));
254 }
255 parts.push(format!("duplication {}", summary.duplication_clone_groups));
256 parts
257}
258
259fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
261 let mut parts = Vec::new();
262 if summary.dead_code_issues > 0 {
263 let n = summary.dead_code_issues;
264 parts.push(format!("dead code: {n} issue{}", plural(n)));
265 }
266 if summary.complexity_findings > 0 {
267 let n = summary.complexity_findings;
268 parts.push(format!("complexity: {n} finding{}", plural(n)));
269 }
270 if summary.duplication_clone_groups > 0 {
271 let n = summary.duplication_clone_groups;
272 parts.push(format!("duplication: {n} clone group{}", plural(n)));
273 }
274 parts
275}
276
277fn print_audit_status_line(result: &AuditResult) {
279 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
280 let n = result.changed_files_count;
281 let files_str = format!("{n} changed file{}", plural(n));
282
283 match result.verdict {
284 AuditVerdict::Pass => {
285 eprintln!(
286 "{}",
287 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
288 .green()
289 .bold()
290 );
291 }
292 AuditVerdict::Warn => {
293 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
294 eprintln!(
295 "{}",
296 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
297 .green()
298 .bold()
299 );
300 }
301 AuditVerdict::Fail => {
302 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
303 eprintln!(
304 "{}",
305 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
306 .red()
307 .bold()
308 );
309 }
310 }
311
312 if !matches!(result.attribution.gate, AuditGate::All) {
313 let inherited = result.attribution.dead_code_inherited
314 + result.attribution.complexity_inherited
315 + result.attribution.duplication_inherited;
316 if inherited > 0 {
317 eprintln!(
318 " {}",
319 format!(
320 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
321 plural(inherited)
322 )
323 .dimmed()
324 );
325 }
326 }
327 if result.performance {
328 eprintln!(
329 " {}",
330 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
331 );
332 }
333}
334
335fn print_audit_json(result: &AuditResult) -> ExitCode {
336 let mut output = match build_audit_json_output(result) {
337 Ok(output) => output,
338 Err(code) => return code,
339 };
340 report::harmonize_multi_kind_suppress_line_actions(&mut output);
341 report::emit_json(&output, "audit")
342}
343
344fn build_audit_json_output(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
345 let dead_code = result
346 .check
347 .as_ref()
348 .map(|check| build_audit_dead_code_json(result, check))
349 .transpose()?;
350 let duplication = result
351 .dupes
352 .as_ref()
353 .map(|dupes| build_audit_duplication_json(result, dupes))
354 .transpose()?;
355 let complexity = result
356 .health
357 .as_ref()
358 .map(|health| build_audit_health_json(result, health))
359 .transpose()?;
360
361 fallow_api::serialize_audit_json(
362 AuditJsonOutputInput {
363 header: audit_json_header_input(result),
364 dead_code,
365 duplication,
366 complexity,
367 next_steps: audit_next_steps(result),
368 },
369 fallow_output::RootEnvelopeMode::Tagged,
370 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
371 )
372 .map_err(|err| {
373 emit_error(
374 &format!("JSON serialization error: {err}"),
375 2,
376 OutputFormat::Json,
377 )
378 })
379}
380
381fn elapsed_ms_for_output(elapsed: std::time::Duration) -> u64 {
382 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
383}
384
385fn changed_files_count_for_output(changed_files_count: usize) -> u32 {
386 u32::try_from(changed_files_count).unwrap_or(u32::MAX)
387}
388
389pub fn audit_json_header_input(result: &AuditResult) -> AuditJsonHeaderInput {
390 AuditJsonHeaderInput {
391 schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
392 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
393 verdict: result.verdict,
394 changed_files_count: changed_files_count_for_output(result.changed_files_count),
395 base_ref: result.base_ref.clone(),
396 base_description: result.base_description.clone(),
397 head_sha: result.head_sha.clone(),
398 elapsed_ms: ElapsedMs(elapsed_ms_for_output(result.elapsed)),
399 base_snapshot_skipped: result.performance.then_some(result.base_snapshot_skipped),
400 summary: result.summary.clone(),
401 attribution: result.attribution.clone(),
402 }
403}
404
405pub fn insert_audit_dead_code_json(
406 obj: &mut serde_json::Map<String, serde_json::Value>,
407 result: &AuditResult,
408 check: &crate::check::CheckResult,
409) -> Result<(), ExitCode> {
410 let json = build_audit_dead_code_json(result, check)?;
411 obj.insert("dead_code".into(), json);
412 Ok(())
413}
414
415fn build_audit_dead_code_json(
416 result: &AuditResult,
417 check: &crate::check::CheckResult,
418) -> Result<serde_json::Value, ExitCode> {
419 match report::api_check_json_payload_with_config_fixable(
420 &check.results,
421 &check.config.root,
422 check.elapsed,
423 check.config_fixable,
424 ) {
425 Ok(mut json) => {
426 if let Some(ref base) = result.base_snapshot {
427 annotate_dead_code_json(
428 &mut json,
429 &check.results,
430 &check.config.root,
431 &base.dead_code,
432 );
433 }
434 Ok(json)
435 }
436 Err(e) => Err(emit_error(
437 &format!("JSON serialization error: {e}"),
438 2,
439 OutputFormat::Json,
440 )),
441 }
442}
443
444pub fn insert_audit_duplication_json(
445 obj: &mut serde_json::Map<String, serde_json::Value>,
446 result: &AuditResult,
447 dupes: &crate::dupes::DupesResult,
448) -> Result<(), ExitCode> {
449 let json = build_audit_duplication_json(result, dupes)?;
450 obj.insert("duplication".into(), json);
451 Ok(())
452}
453
454fn build_audit_duplication_json(
455 result: &AuditResult,
456 dupes: &crate::dupes::DupesResult,
457) -> Result<serde_json::Value, ExitCode> {
458 let payload = DupesReportPayload::from_report(&dupes.report);
459 match serde_json::to_value(&payload) {
460 Ok(mut json) => {
461 let root_prefix = format!("{}/", dupes.config.root.display());
462 report::strip_root_prefix(&mut json, &root_prefix);
463 if let Some(ref base) = result.base_snapshot {
464 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
465 }
466 Ok(json)
467 }
468 Err(e) => Err(emit_error(
469 &format!("JSON serialization error: {e}"),
470 2,
471 OutputFormat::Json,
472 )),
473 }
474}
475
476pub fn insert_audit_health_json(
477 obj: &mut serde_json::Map<String, serde_json::Value>,
478 result: &AuditResult,
479 health: &crate::health::HealthResult,
480) -> Result<(), ExitCode> {
481 let json = build_audit_health_json(result, health)?;
482 obj.insert("complexity".into(), json);
483 Ok(())
484}
485
486fn build_audit_health_json(
487 result: &AuditResult,
488 health: &crate::health::HealthResult,
489) -> Result<serde_json::Value, ExitCode> {
490 match serde_json::to_value(&health.report) {
491 Ok(mut json) => {
492 let root_prefix = format!("{}/", health.config.root.display());
493 report::strip_root_prefix(&mut json, &root_prefix);
494 if let Some(ref base) = result.base_snapshot {
495 annotate_health_json(&mut json, &health.report, &health.config.root, &base.health);
496 }
497 Ok(json)
498 }
499 Err(e) => Err(emit_error(
500 &format!("JSON serialization error: {e}"),
501 2,
502 OutputFormat::Json,
503 )),
504 }
505}
506
507fn audit_next_steps(result: &AuditResult) -> Vec<fallow_types::output::NextStep> {
508 let input = fallow_output::build_audit_next_steps_input(
509 result
510 .check
511 .as_ref()
512 .map(|check| (&check.results, check.config.root.as_path())),
513 result.health.as_ref().map(|health| &health.report),
514 crate::report::suggestions::suggestions_enabled(),
515 );
516 fallow_output::build_audit_next_steps(&input)
517}
518
519fn print_audit_sarif(result: &AuditResult) -> ExitCode {
520 let check_sarif = result.check.as_ref().map(|check| {
521 report::api_sarif_document(&check.results, &check.config.root, &check.config.rules)
522 });
523 let health_sarif = result
524 .health
525 .as_ref()
526 .map(|health| report::api_health_sarif_document(&health.report, &health.config.root));
527 let combined = fallow_api::build_audit_sarif(AuditSarifOutputInput {
528 dead_code: check_sarif.as_ref(),
529 duplication: result.dupes.as_ref().map(|dupes| &dupes.report),
530 health: health_sarif.as_ref(),
531 });
532
533 report::emit_json(&combined, "SARIF audit")
534}
535
536fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
537 let value = build_audit_codeclimate(result);
538 report::emit_json(&value, "CodeClimate audit")
539}
540
541fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
542 fallow_api::build_audit_codeclimate(AuditCodeClimateOutputInput {
543 dead_code: result.check.as_ref().map_or_else(Vec::new, |check| {
544 fallow_api::build_codeclimate(&check.results, &check.config.root, &check.config.rules)
545 }),
546 duplication: result.dupes.as_ref().map_or_else(Vec::new, |dupes| {
547 fallow_api::build_duplication_codeclimate(&dupes.report, &dupes.config.root)
548 }),
549 health: result.health.as_ref().map_or_else(Vec::new, |health| {
550 fallow_api::build_health_codeclimate(&health.report, &health.config.root)
551 }),
552 })
553}
554
555#[cfg(test)]
556mod tests {
557 use std::process::ExitCode;
558 use std::time::Duration;
559
560 use fallow_config::{AuditGate, OutputFormat};
561
562 use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
563
564 use super::{
565 build_audit_codeclimate, build_audit_json_output, build_status_parts,
566 build_vital_sign_parts, format_scope_line_parts, print_audit_result, short_base_ref,
567 };
568
569 fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
570 AuditResult {
571 verdict,
572 summary: AuditSummary {
573 dead_code_issues: 0,
574 dead_code_has_errors: false,
575 complexity_findings: 0,
576 max_cyclomatic: None,
577 duplication_clone_groups: 0,
578 },
579 attribution: AuditAttribution {
580 gate: AuditGate::NewOnly,
581 ..AuditAttribution::default()
582 },
583 base_snapshot: None,
584 base_snapshot_skipped: false,
585 changed_files_count: 0,
586 changed_files: Vec::new(),
587 base_ref: "origin/main".to_string(),
588 base_description: None,
589 head_sha: None,
590 output,
591 performance: false,
592 check: None,
593 dupes: None,
594 health: None,
595 elapsed: Duration::ZERO,
596 review_deltas: None,
597 weakening_signals: Vec::new(),
598 routing: None,
599 decision_surface: None,
600 graph_snapshot_hash: None,
601 change_anchors: Vec::new(),
602 }
603 }
604
605 #[test]
606 fn short_base_ref_abbreviates_full_sha() {
607 assert_eq!(
608 short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
609 "611d151e8250"
610 );
611 }
612
613 #[test]
614 fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
615 assert_eq!(short_base_ref("main"), "main");
616 assert_eq!(short_base_ref("origin/main"), "origin/main");
617 assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
618 assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
620 assert_eq!(
622 short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
623 "611d151e8250146426ff3178e94207f8a8d3ccZZ"
624 );
625 }
626
627 #[test]
628 fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
629 assert_eq!(
630 format_scope_line_parts(
631 1,
632 "611d151e8250146426ff3178e94207f8a8d3cc7b",
633 Some("merge-base with origin/main"),
634 Some("HEADSHA")
635 ),
636 "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
637 );
638 assert_eq!(
639 format_scope_line_parts(3, "origin/main", None, None),
640 "Audit scope: 3 changed files vs origin/main"
641 );
642 }
643
644 #[test]
645 fn build_status_parts_describes_only_non_empty_categories() {
646 let summary = AuditSummary {
647 dead_code_issues: 1,
648 dead_code_has_errors: true,
649 complexity_findings: 2,
650 max_cyclomatic: Some(12),
651 duplication_clone_groups: 3,
652 };
653
654 assert_eq!(
655 build_status_parts(&summary),
656 vec![
657 "dead code: 1 issue".to_string(),
658 "complexity: 2 findings".to_string(),
659 "duplication: 3 clone groups".to_string(),
660 ]
661 );
662
663 let empty = AuditSummary {
664 dead_code_issues: 0,
665 dead_code_has_errors: false,
666 complexity_findings: 0,
667 max_cyclomatic: None,
668 duplication_clone_groups: 0,
669 };
670 assert!(build_status_parts(&empty).is_empty());
671 }
672
673 #[test]
674 fn build_vital_sign_parts_includes_warn_threshold_when_present() {
675 let summary = AuditSummary {
676 dead_code_issues: 0,
677 dead_code_has_errors: false,
678 complexity_findings: 2,
679 max_cyclomatic: Some(18),
680 duplication_clone_groups: 1,
681 };
682
683 assert_eq!(
684 build_vital_sign_parts(&summary),
685 vec![
686 "dead code 0".to_string(),
687 "complexity 2 (warn, max cyclomatic: 18)".to_string(),
688 "duplication 1".to_string(),
689 ]
690 );
691 }
692
693 #[test]
694 fn build_vital_sign_parts_omits_threshold_when_absent() {
695 let summary = AuditSummary {
696 dead_code_issues: 3,
697 dead_code_has_errors: false,
698 complexity_findings: 0,
699 max_cyclomatic: None,
700 duplication_clone_groups: 0,
701 };
702
703 assert_eq!(
704 build_vital_sign_parts(&summary),
705 vec![
706 "dead code 3".to_string(),
707 "complexity 0".to_string(),
708 "duplication 0".to_string(),
709 ]
710 );
711 }
712
713 #[test]
714 fn build_audit_codeclimate_returns_empty_issue_list_without_findings() {
715 let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
716
717 assert_eq!(build_audit_codeclimate(&result), serde_json::json!([]));
718 }
719
720 #[test]
721 fn print_audit_result_rejects_badge_format() {
722 let result = audit_result(AuditVerdict::Pass, OutputFormat::Badge);
723
724 assert_eq!(print_audit_result(&result, true, false), ExitCode::from(2));
725 }
726
727 #[test]
728 fn print_audit_result_maps_fail_verdict_to_error_exit() {
729 let result = audit_result(AuditVerdict::Fail, OutputFormat::Human);
730
731 assert_eq!(print_audit_result(&result, true, false), ExitCode::from(1));
732 }
733
734 fn audit_result_with_findings(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
735 let mut result = audit_result(verdict, output);
736 result.summary = AuditSummary {
737 dead_code_issues: 2,
738 dead_code_has_errors: true,
739 complexity_findings: 1,
740 max_cyclomatic: Some(14),
741 duplication_clone_groups: 3,
742 };
743 result.changed_files_count = 4;
744 result
745 }
746
747 #[test]
748 fn print_audit_json_emits_optional_header_fields() {
749 let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
750 result.base_description = Some("merge-base with origin/main".to_string());
751 result.head_sha = Some("abc123".to_string());
752 result.performance = true;
753 result.base_snapshot_skipped = true;
754 result.changed_files_count = 5;
755
756 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
760 }
761
762 #[test]
763 fn build_audit_json_output_uses_typed_audit_contract() {
764 let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
765 result.base_description = Some("merge-base with origin/main".to_string());
766 result.head_sha = Some("abc123".to_string());
767 result.performance = true;
768 result.base_snapshot_skipped = true;
769 result.changed_files_count = 5;
770
771 let json = build_audit_json_output(&result).expect("audit JSON should build");
772
773 assert_eq!(json["kind"], "audit");
774 assert_eq!(json["command"], "audit");
775 assert_eq!(json["base_description"], "merge-base with origin/main");
776 assert_eq!(json["head_sha"], "abc123");
777 assert_eq!(json["base_snapshot_skipped"], true);
778 assert_eq!(json["changed_files_count"], 5);
779 }
780
781 #[test]
782 fn print_audit_result_renders_sarif_skeleton_without_findings() {
783 let result = audit_result(AuditVerdict::Pass, OutputFormat::Sarif);
784
785 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
786 }
787
788 #[test]
789 fn print_audit_result_renders_codeclimate_without_findings() {
790 let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
791
792 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
793 }
794
795 #[test]
796 fn print_audit_result_renders_pr_comment_for_both_providers() {
797 for format in [OutputFormat::PrCommentGithub, OutputFormat::PrCommentGitlab] {
798 let result = audit_result(AuditVerdict::Pass, format);
799 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
800 }
801 }
802
803 #[test]
804 fn print_audit_result_renders_review_envelope_for_both_providers() {
805 for format in [OutputFormat::ReviewGithub, OutputFormat::ReviewGitlab] {
806 let result = audit_result(AuditVerdict::Pass, format);
807 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
808 }
809 }
810
811 #[test]
812 fn print_audit_result_compact_and_markdown_use_human_path() {
813 for format in [OutputFormat::Compact, OutputFormat::Markdown] {
814 let result = audit_result(AuditVerdict::Pass, format);
815 assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
816 }
817 }
818
819 #[test]
820 fn print_audit_result_human_pass_renders_scope_and_status_line() {
821 let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Human);
822 result.changed_files_count = 2;
823
824 assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
826 }
827
828 #[test]
829 fn print_audit_result_human_warn_renders_vital_signs_and_notes() {
830 let mut result = audit_result_with_findings(AuditVerdict::Warn, OutputFormat::Human);
831 result.attribution = AuditAttribution {
832 gate: AuditGate::NewOnly,
833 dead_code_inherited: 2,
834 complexity_inherited: 1,
835 duplication_inherited: 0,
836 ..AuditAttribution::default()
837 };
838 result.performance = true;
839
840 assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
843 }
844
845 #[test]
846 fn print_audit_result_human_fail_renders_red_status_line() {
847 let result = audit_result_with_findings(AuditVerdict::Fail, OutputFormat::Human);
848
849 assert_eq!(print_audit_result(&result, false, false), ExitCode::from(1));
851 }
852}