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