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 let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
89
90 if has_any_findings {
91 if show_headers && std::io::stdout().is_terminal() && !crate::report::sink::is_redirected()
92 {
93 println!(
94 "{}",
95 "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
96 .dimmed()
97 );
98 println!();
99 }
100
101 if result.verdict != AuditVerdict::Fail && !quiet {
102 print_audit_vital_signs(result);
103 }
104
105 if has_check_issues && let Some(ref check) = result.check {
106 if show_headers {
107 eprintln!();
108 eprintln!("── Dead Code ──────────────────────────────────────");
109 }
110 crate::check::print_check_result(
111 check,
112 crate::check::PrintCheckOptions {
113 quiet,
114 explain,
115 regression_json: false,
116 group_by: None,
117 top: None,
118 summary: false,
119 summary_heading: true,
120 show_explain_tip: false,
121 },
122 );
123 }
124
125 if has_dupe_groups && let Some(ref dupes) = result.dupes {
126 if show_headers {
127 eprintln!();
128 eprintln!("── Duplication ────────────────────────────────────");
129 }
130 crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
131 }
132
133 if has_health_findings && let Some(ref health) = result.health {
134 if show_headers {
135 eprintln!();
136 eprintln!("── Complexity ─────────────────────────────────────");
137 }
138 crate::health::print_health_result(
139 health,
140 crate::health::HealthPrintOptions {
141 quiet,
142 explain,
143 min_score: None,
144 min_severity: None,
145 report_only: false,
146 summary: false,
147 summary_heading: true,
148 show_explain_tip: false,
149 skip_score_and_trend: false,
150 },
151 );
152 }
153 }
154
155 if !has_dupe_groups && let Some(ref dupes) = result.dupes {
156 crate::dupes::print_default_ignore_note(dupes, quiet);
157 crate::dupes::print_min_occurrences_note(dupes, quiet);
158 }
159
160 if !quiet {
161 print_audit_status_line(result);
162 }
163}
164
165fn short_base_ref(base_ref: &str) -> &str {
168 if base_ref.len() == 40 && base_ref.bytes().all(|b| b.is_ascii_hexdigit()) {
169 &base_ref[..12]
170 } else {
171 base_ref
172 }
173}
174
175fn format_scope_line(result: &AuditResult) -> String {
179 format_scope_line_parts(
180 result.changed_files_count,
181 &result.base_ref,
182 result.base_description.as_deref(),
183 result.head_sha.as_deref(),
184 )
185}
186
187fn format_scope_line_parts(
188 changed_files_count: usize,
189 base_ref: &str,
190 base_description: Option<&str>,
191 head_sha: Option<&str>,
192) -> String {
193 let sha_suffix = head_sha.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
194 let base_display = match base_description {
195 Some(description) => format!("{} ({description})", short_base_ref(base_ref)),
196 None => base_ref.to_string(),
197 };
198 format!(
199 "Audit scope: {} changed file{} vs {}{}",
200 changed_files_count,
201 plural(changed_files_count),
202 base_display,
203 sha_suffix
204 )
205}
206
207fn print_audit_vital_signs(result: &AuditResult) {
209 let line = build_vital_sign_parts(&result.summary).join(" \u{00b7} ");
210 outln!(
211 "{} {} {}",
212 "\u{25a0}".dimmed(),
213 "Metrics:".dimmed(),
214 line.dimmed()
215 );
216}
217
218fn build_vital_sign_parts(summary: &AuditSummary) -> Vec<String> {
219 let mut parts = Vec::new();
220 parts.push(format!("dead code {}", summary.dead_code_issues));
221 if let Some(max) = summary.max_cyclomatic {
222 parts.push(format!(
223 "complexity {} (warn, max cyclomatic: {max})",
224 summary.complexity_findings
225 ));
226 } else {
227 parts.push(format!("complexity {}", summary.complexity_findings));
228 }
229 parts.push(format!("duplication {}", summary.duplication_clone_groups));
230 parts
231}
232
233fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
235 let mut parts = Vec::new();
236 if summary.dead_code_issues > 0 {
237 let n = summary.dead_code_issues;
238 parts.push(format!("dead code: {n} issue{}", plural(n)));
239 }
240 if summary.complexity_findings > 0 {
241 let n = summary.complexity_findings;
242 parts.push(format!("complexity: {n} finding{}", plural(n)));
243 }
244 if summary.duplication_clone_groups > 0 {
245 let n = summary.duplication_clone_groups;
246 parts.push(format!("duplication: {n} clone group{}", plural(n)));
247 }
248 parts
249}
250
251fn print_audit_status_line(result: &AuditResult) {
253 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
254 let n = result.changed_files_count;
255 let files_str = format!("{n} changed file{}", plural(n));
256
257 match result.verdict {
258 AuditVerdict::Pass => {
259 eprintln!(
260 "{}",
261 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
262 .green()
263 .bold()
264 );
265 }
266 AuditVerdict::Warn => {
267 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
268 eprintln!(
269 "{}",
270 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
271 .green()
272 .bold()
273 );
274 }
275 AuditVerdict::Fail => {
276 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
277 eprintln!(
278 "{}",
279 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
280 .red()
281 .bold()
282 );
283 }
284 }
285
286 if !matches!(result.attribution.gate, AuditGate::All) {
287 let inherited = result.attribution.dead_code_inherited
288 + result.attribution.complexity_inherited
289 + result.attribution.duplication_inherited;
290 if inherited > 0 {
291 eprintln!(
292 " {}",
293 format!(
294 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
295 plural(inherited)
296 )
297 .dimmed()
298 );
299 }
300 }
301 if result.performance {
302 eprintln!(
303 " {}",
304 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
305 );
306 }
307}
308
309fn print_audit_json(result: &AuditResult) -> ExitCode {
310 let mut obj = serde_json::Map::new();
311 insert_audit_json_header(&mut obj, result);
312
313 if let Some(ref check) = result.check
314 && let Err(code) = insert_audit_dead_code_json(&mut obj, result, check)
315 {
316 return code;
317 }
318
319 if let Some(ref dupes) = result.dupes
320 && let Err(code) = insert_audit_duplication_json(&mut obj, result, dupes)
321 {
322 return code;
323 }
324
325 if let Some(ref health) = result.health
326 && let Err(code) = insert_audit_health_json(&mut obj, result, health)
327 {
328 return code;
329 }
330
331 insert_audit_next_steps_json(&mut obj, result);
332
333 let mut output = serde_json::Value::Object(obj);
334 crate::output_envelope::apply_root_kind(&mut output, "audit");
335 report::harmonize_multi_kind_suppress_line_actions(&mut output);
336 crate::output_envelope::attach_telemetry_meta(&mut output);
337 report::emit_json(&output, "audit")
338}
339
340#[expect(
341 clippy::cast_possible_truncation,
342 reason = "elapsed milliseconds won't exceed u64::MAX"
343)]
344fn insert_audit_json_header(
345 obj: &mut serde_json::Map<String, serde_json::Value>,
346 result: &AuditResult,
347) {
348 obj.insert(
349 "schema_version".into(),
350 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
351 );
352 obj.insert(
353 "version".into(),
354 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
355 );
356 obj.insert(
357 "command".into(),
358 serde_json::Value::String("audit".to_string()),
359 );
360 obj.insert(
361 "verdict".into(),
362 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
363 );
364 obj.insert(
365 "changed_files_count".into(),
366 serde_json::Value::Number(result.changed_files_count.into()),
367 );
368 obj.insert(
369 "base_ref".into(),
370 serde_json::Value::String(result.base_ref.clone()),
371 );
372 if let Some(ref description) = result.base_description {
373 obj.insert(
374 "base_description".into(),
375 serde_json::Value::String(description.clone()),
376 );
377 }
378 if let Some(ref sha) = result.head_sha {
379 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
380 }
381 obj.insert(
382 "elapsed_ms".into(),
383 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
384 );
385 if result.performance {
386 obj.insert(
387 "base_snapshot_skipped".into(),
388 serde_json::Value::Bool(result.base_snapshot_skipped),
389 );
390 }
391
392 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
393 obj.insert("summary".into(), summary_val);
394 }
395 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
396 obj.insert("attribution".into(), attribution_val);
397 }
398}
399
400fn insert_audit_dead_code_json(
401 obj: &mut serde_json::Map<String, serde_json::Value>,
402 result: &AuditResult,
403 check: &crate::check::CheckResult,
404) -> Result<(), ExitCode> {
405 match report::build_check_json_payload_with_config_fixable(
406 &check.results,
407 &check.config.root,
408 check.elapsed,
409 check.config_fixable,
410 ) {
411 Ok(mut json) => {
412 if let Some(ref base) = result.base_snapshot {
413 annotate_dead_code_json(
414 &mut json,
415 &check.results,
416 &check.config.root,
417 &base.dead_code,
418 );
419 }
420 obj.insert("dead_code".into(), json);
421 Ok(())
422 }
423 Err(e) => Err(emit_error(
424 &format!("JSON serialization error: {e}"),
425 2,
426 OutputFormat::Json,
427 )),
428 }
429}
430
431fn insert_audit_duplication_json(
432 obj: &mut serde_json::Map<String, serde_json::Value>,
433 result: &AuditResult,
434 dupes: &crate::dupes::DupesResult,
435) -> Result<(), ExitCode> {
436 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
437 match serde_json::to_value(&payload) {
438 Ok(mut json) => {
439 let root_prefix = format!("{}/", dupes.config.root.display());
440 report::strip_root_prefix(&mut json, &root_prefix);
441 if let Some(ref base) = result.base_snapshot {
442 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
443 }
444 obj.insert("duplication".into(), json);
445 Ok(())
446 }
447 Err(e) => Err(emit_error(
448 &format!("JSON serialization error: {e}"),
449 2,
450 OutputFormat::Json,
451 )),
452 }
453}
454
455fn insert_audit_health_json(
456 obj: &mut serde_json::Map<String, serde_json::Value>,
457 result: &AuditResult,
458 health: &crate::health::HealthResult,
459) -> Result<(), ExitCode> {
460 match serde_json::to_value(&health.report) {
461 Ok(mut json) => {
462 let root_prefix = format!("{}/", health.config.root.display());
463 report::strip_root_prefix(&mut json, &root_prefix);
464 if let Some(ref base) = result.base_snapshot {
465 annotate_health_json(&mut json, &health.report, &health.config.root, &base.health);
466 }
467 obj.insert("complexity".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_next_steps_json(
479 obj: &mut serde_json::Map<String, serde_json::Value>,
480 result: &AuditResult,
481) {
482 let next_steps = crate::report::suggestions::build_audit_next_steps(
483 result
484 .check
485 .as_ref()
486 .map(|check| (&check.results, check.config.root.as_path())),
487 result.health.as_ref().map(|health| &health.report),
488 );
489 if !next_steps.is_empty()
490 && let Ok(value) = serde_json::to_value(&next_steps)
491 {
492 obj.insert("next_steps".into(), value);
493 }
494}
495
496fn print_audit_sarif(result: &AuditResult) -> ExitCode {
497 let mut all_runs = Vec::new();
498
499 if let Some(ref check) = result.check {
500 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
501 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
502 all_runs.extend(runs.iter().cloned());
503 }
504 }
505
506 if let Some(ref dupes) = result.dupes
507 && !dupes.report.clone_groups.is_empty()
508 {
509 let run = serde_json::json!({
510 "tool": {
511 "driver": {
512 "name": "fallow",
513 "version": env!("CARGO_PKG_VERSION"),
514 "informationUri": "https://github.com/fallow-rs/fallow",
515 }
516 },
517 "automationDetails": { "id": "fallow/audit/dupes" },
518 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
519 serde_json::json!({
520 "ruleId": "fallow/code-duplication",
521 "level": "warning",
522 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
523 })
524 }).collect::<Vec<_>>()
525 });
526 all_runs.push(run);
527 }
528
529 if let Some(ref health) = result.health {
530 let sarif = report::build_health_sarif(&health.report, &health.config.root);
531 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
532 all_runs.extend(runs.iter().cloned());
533 }
534 }
535
536 let combined = serde_json::json!({
537 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
538 "version": "2.1.0",
539 "runs": all_runs,
540 });
541
542 report::emit_json(&combined, "SARIF audit")
543}
544
545fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
546 let value = build_audit_codeclimate(result);
547 report::emit_json(&value, "CodeClimate audit")
548}
549
550#[expect(
551 clippy::expect_used,
552 reason = "CodeClimate issue envelope contains only infallibly serializable fields"
553)]
554fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
555 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
556
557 if let Some(ref check) = result.check {
558 all_issues.extend(report::build_codeclimate(
559 &check.results,
560 &check.config.root,
561 &check.config.rules,
562 ));
563 }
564
565 if let Some(ref dupes) = result.dupes {
566 all_issues.extend(report::build_duplication_codeclimate(
567 &dupes.report,
568 &dupes.config.root,
569 ));
570 }
571
572 if let Some(ref health) = result.health {
573 all_issues.extend(report::build_health_codeclimate(
574 &health.report,
575 &health.config.root,
576 ));
577 }
578
579 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
580}
581
582#[cfg(test)]
583mod tests {
584 use crate::audit::AuditSummary;
585
586 use super::{
587 build_status_parts, build_vital_sign_parts, format_scope_line_parts, short_base_ref,
588 };
589
590 #[test]
591 fn short_base_ref_abbreviates_full_sha() {
592 assert_eq!(
593 short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
594 "611d151e8250"
595 );
596 }
597
598 #[test]
599 fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
600 assert_eq!(short_base_ref("main"), "main");
601 assert_eq!(short_base_ref("origin/main"), "origin/main");
602 assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
603 assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
605 assert_eq!(
607 short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
608 "611d151e8250146426ff3178e94207f8a8d3ccZZ"
609 );
610 }
611
612 #[test]
613 fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
614 assert_eq!(
615 format_scope_line_parts(
616 1,
617 "611d151e8250146426ff3178e94207f8a8d3cc7b",
618 Some("merge-base with origin/main"),
619 Some("HEADSHA")
620 ),
621 "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
622 );
623 assert_eq!(
624 format_scope_line_parts(3, "origin/main", None, None),
625 "Audit scope: 3 changed files vs origin/main"
626 );
627 }
628
629 #[test]
630 fn build_status_parts_describes_only_non_empty_categories() {
631 let summary = AuditSummary {
632 dead_code_issues: 1,
633 dead_code_has_errors: true,
634 complexity_findings: 2,
635 max_cyclomatic: Some(12),
636 duplication_clone_groups: 3,
637 };
638
639 assert_eq!(
640 build_status_parts(&summary),
641 vec![
642 "dead code: 1 issue".to_string(),
643 "complexity: 2 findings".to_string(),
644 "duplication: 3 clone groups".to_string(),
645 ]
646 );
647
648 let empty = AuditSummary {
649 dead_code_issues: 0,
650 dead_code_has_errors: false,
651 complexity_findings: 0,
652 max_cyclomatic: None,
653 duplication_clone_groups: 0,
654 };
655 assert!(build_status_parts(&empty).is_empty());
656 }
657
658 #[test]
659 fn build_vital_sign_parts_includes_warn_threshold_when_present() {
660 let summary = AuditSummary {
661 dead_code_issues: 0,
662 dead_code_has_errors: false,
663 complexity_findings: 2,
664 max_cyclomatic: Some(18),
665 duplication_clone_groups: 1,
666 };
667
668 assert_eq!(
669 build_vital_sign_parts(&summary),
670 vec![
671 "dead code 0".to_string(),
672 "complexity 2 (warn, max cyclomatic: 18)".to_string(),
673 "duplication 1".to_string(),
674 ]
675 );
676 }
677
678 #[test]
679 fn build_vital_sign_parts_omits_threshold_when_absent() {
680 let summary = AuditSummary {
681 dead_code_issues: 3,
682 dead_code_has_errors: false,
683 complexity_findings: 0,
684 max_cyclomatic: None,
685 duplication_clone_groups: 0,
686 };
687
688 assert_eq!(
689 build_vital_sign_parts(&summary),
690 vec![
691 "dead code 3".to_string(),
692 "complexity 0".to_string(),
693 "duplication 0".to_string(),
694 ]
695 );
696 }
697}