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
309#[expect(
310 clippy::cast_possible_truncation,
311 reason = "elapsed milliseconds won't exceed u64::MAX"
312)]
313fn print_audit_json(result: &AuditResult) -> ExitCode {
314 let mut obj = serde_json::Map::new();
315 obj.insert(
316 "schema_version".into(),
317 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
318 );
319 obj.insert(
320 "version".into(),
321 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
322 );
323 obj.insert(
324 "command".into(),
325 serde_json::Value::String("audit".to_string()),
326 );
327 obj.insert(
328 "verdict".into(),
329 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
330 );
331 obj.insert(
332 "changed_files_count".into(),
333 serde_json::Value::Number(result.changed_files_count.into()),
334 );
335 obj.insert(
336 "base_ref".into(),
337 serde_json::Value::String(result.base_ref.clone()),
338 );
339 if let Some(ref description) = result.base_description {
340 obj.insert(
341 "base_description".into(),
342 serde_json::Value::String(description.clone()),
343 );
344 }
345 if let Some(ref sha) = result.head_sha {
346 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
347 }
348 obj.insert(
349 "elapsed_ms".into(),
350 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
351 );
352 if result.performance {
353 obj.insert(
354 "base_snapshot_skipped".into(),
355 serde_json::Value::Bool(result.base_snapshot_skipped),
356 );
357 }
358
359 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
360 obj.insert("summary".into(), summary_val);
361 }
362 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
363 obj.insert("attribution".into(), attribution_val);
364 }
365
366 if let Some(ref check) = result.check {
367 match report::build_check_json_payload_with_config_fixable(
368 &check.results,
369 &check.config.root,
370 check.elapsed,
371 check.config_fixable,
372 ) {
373 Ok(mut json) => {
374 if let Some(ref base) = result.base_snapshot {
375 annotate_dead_code_json(
376 &mut json,
377 &check.results,
378 &check.config.root,
379 &base.dead_code,
380 );
381 }
382 obj.insert("dead_code".into(), json);
383 }
384 Err(e) => {
385 return emit_error(
386 &format!("JSON serialization error: {e}"),
387 2,
388 OutputFormat::Json,
389 );
390 }
391 }
392 }
393
394 if let Some(ref dupes) = result.dupes {
395 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
396 match serde_json::to_value(&payload) {
397 Ok(mut json) => {
398 let root_prefix = format!("{}/", dupes.config.root.display());
399 report::strip_root_prefix(&mut json, &root_prefix);
400 if let Some(ref base) = result.base_snapshot {
401 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
402 }
403 obj.insert("duplication".into(), json);
404 }
405 Err(e) => {
406 return emit_error(
407 &format!("JSON serialization error: {e}"),
408 2,
409 OutputFormat::Json,
410 );
411 }
412 }
413 }
414
415 if let Some(ref health) = result.health {
416 match serde_json::to_value(&health.report) {
417 Ok(mut json) => {
418 let root_prefix = format!("{}/", health.config.root.display());
419 report::strip_root_prefix(&mut json, &root_prefix);
420 if let Some(ref base) = result.base_snapshot {
421 annotate_health_json(
422 &mut json,
423 &health.report,
424 &health.config.root,
425 &base.health,
426 );
427 }
428 obj.insert("complexity".into(), json);
429 }
430 Err(e) => {
431 return emit_error(
432 &format!("JSON serialization error: {e}"),
433 2,
434 OutputFormat::Json,
435 );
436 }
437 }
438 }
439
440 let next_steps = crate::report::suggestions::build_audit_next_steps(
441 result
442 .check
443 .as_ref()
444 .map(|check| (&check.results, check.config.root.as_path())),
445 result.health.as_ref().map(|health| &health.report),
446 );
447 if !next_steps.is_empty()
448 && let Ok(value) = serde_json::to_value(&next_steps)
449 {
450 obj.insert("next_steps".into(), value);
451 }
452
453 let mut output = serde_json::Value::Object(obj);
454 crate::output_envelope::apply_root_kind(&mut output, "audit");
455 report::harmonize_multi_kind_suppress_line_actions(&mut output);
456 crate::output_envelope::attach_telemetry_meta(&mut output);
457 report::emit_json(&output, "audit")
458}
459
460fn print_audit_sarif(result: &AuditResult) -> ExitCode {
461 let mut all_runs = Vec::new();
462
463 if let Some(ref check) = result.check {
464 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
465 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
466 all_runs.extend(runs.iter().cloned());
467 }
468 }
469
470 if let Some(ref dupes) = result.dupes
471 && !dupes.report.clone_groups.is_empty()
472 {
473 let run = serde_json::json!({
474 "tool": {
475 "driver": {
476 "name": "fallow",
477 "version": env!("CARGO_PKG_VERSION"),
478 "informationUri": "https://github.com/fallow-rs/fallow",
479 }
480 },
481 "automationDetails": { "id": "fallow/audit/dupes" },
482 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
483 serde_json::json!({
484 "ruleId": "fallow/code-duplication",
485 "level": "warning",
486 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
487 })
488 }).collect::<Vec<_>>()
489 });
490 all_runs.push(run);
491 }
492
493 if let Some(ref health) = result.health {
494 let sarif = report::build_health_sarif(&health.report, &health.config.root);
495 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
496 all_runs.extend(runs.iter().cloned());
497 }
498 }
499
500 let combined = serde_json::json!({
501 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
502 "version": "2.1.0",
503 "runs": all_runs,
504 });
505
506 report::emit_json(&combined, "SARIF audit")
507}
508
509fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
510 let value = build_audit_codeclimate(result);
511 report::emit_json(&value, "CodeClimate audit")
512}
513
514#[expect(
515 clippy::expect_used,
516 reason = "CodeClimate issue envelope contains only infallibly serializable fields"
517)]
518fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
519 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
520
521 if let Some(ref check) = result.check {
522 all_issues.extend(report::build_codeclimate(
523 &check.results,
524 &check.config.root,
525 &check.config.rules,
526 ));
527 }
528
529 if let Some(ref dupes) = result.dupes {
530 all_issues.extend(report::build_duplication_codeclimate(
531 &dupes.report,
532 &dupes.config.root,
533 ));
534 }
535
536 if let Some(ref health) = result.health {
537 all_issues.extend(report::build_health_codeclimate(
538 &health.report,
539 &health.config.root,
540 ));
541 }
542
543 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
544}
545
546#[cfg(test)]
547mod tests {
548 use crate::audit::AuditSummary;
549
550 use super::{
551 build_status_parts, build_vital_sign_parts, format_scope_line_parts, short_base_ref,
552 };
553
554 #[test]
555 fn short_base_ref_abbreviates_full_sha() {
556 assert_eq!(
557 short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
558 "611d151e8250"
559 );
560 }
561
562 #[test]
563 fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
564 assert_eq!(short_base_ref("main"), "main");
565 assert_eq!(short_base_ref("origin/main"), "origin/main");
566 assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
567 assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
569 assert_eq!(
571 short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
572 "611d151e8250146426ff3178e94207f8a8d3ccZZ"
573 );
574 }
575
576 #[test]
577 fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
578 assert_eq!(
579 format_scope_line_parts(
580 1,
581 "611d151e8250146426ff3178e94207f8a8d3cc7b",
582 Some("merge-base with origin/main"),
583 Some("HEADSHA")
584 ),
585 "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
586 );
587 assert_eq!(
588 format_scope_line_parts(3, "origin/main", None, None),
589 "Audit scope: 3 changed files vs origin/main"
590 );
591 }
592
593 #[test]
594 fn build_status_parts_describes_only_non_empty_categories() {
595 let summary = AuditSummary {
596 dead_code_issues: 1,
597 dead_code_has_errors: true,
598 complexity_findings: 2,
599 max_cyclomatic: Some(12),
600 duplication_clone_groups: 3,
601 };
602
603 assert_eq!(
604 build_status_parts(&summary),
605 vec![
606 "dead code: 1 issue".to_string(),
607 "complexity: 2 findings".to_string(),
608 "duplication: 3 clone groups".to_string(),
609 ]
610 );
611
612 let empty = AuditSummary {
613 dead_code_issues: 0,
614 dead_code_has_errors: false,
615 complexity_findings: 0,
616 max_cyclomatic: None,
617 duplication_clone_groups: 0,
618 };
619 assert!(build_status_parts(&empty).is_empty());
620 }
621
622 #[test]
623 fn build_vital_sign_parts_includes_warn_threshold_when_present() {
624 let summary = AuditSummary {
625 dead_code_issues: 0,
626 dead_code_has_errors: false,
627 complexity_findings: 2,
628 max_cyclomatic: Some(18),
629 duplication_clone_groups: 1,
630 };
631
632 assert_eq!(
633 build_vital_sign_parts(&summary),
634 vec![
635 "dead code 0".to_string(),
636 "complexity 2 (warn, max cyclomatic: 18)".to_string(),
637 "duplication 1".to_string(),
638 ]
639 );
640 }
641
642 #[test]
643 fn build_vital_sign_parts_omits_threshold_when_absent() {
644 let summary = AuditSummary {
645 dead_code_issues: 3,
646 dead_code_has_errors: false,
647 complexity_findings: 0,
648 max_cyclomatic: None,
649 duplication_clone_groups: 0,
650 };
651
652 assert_eq!(
653 build_vital_sign_parts(&summary),
654 vec![
655 "dead code 3".to_string(),
656 "complexity 0".to_string(),
657 "duplication 0".to_string(),
658 ]
659 );
660 }
661}