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