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 mut output = serde_json::Value::Object(obj);
441 crate::output_envelope::apply_root_kind(&mut output, "audit");
442 report::harmonize_multi_kind_suppress_line_actions(&mut output);
443 crate::output_envelope::attach_telemetry_meta(&mut output);
444 report::emit_json(&output, "audit")
445}
446
447fn print_audit_sarif(result: &AuditResult) -> ExitCode {
448 let mut all_runs = Vec::new();
449
450 if let Some(ref check) = result.check {
451 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
452 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
453 all_runs.extend(runs.iter().cloned());
454 }
455 }
456
457 if let Some(ref dupes) = result.dupes
458 && !dupes.report.clone_groups.is_empty()
459 {
460 let run = serde_json::json!({
461 "tool": {
462 "driver": {
463 "name": "fallow",
464 "version": env!("CARGO_PKG_VERSION"),
465 "informationUri": "https://github.com/fallow-rs/fallow",
466 }
467 },
468 "automationDetails": { "id": "fallow/audit/dupes" },
469 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
470 serde_json::json!({
471 "ruleId": "fallow/code-duplication",
472 "level": "warning",
473 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
474 })
475 }).collect::<Vec<_>>()
476 });
477 all_runs.push(run);
478 }
479
480 if let Some(ref health) = result.health {
481 let sarif = report::build_health_sarif(&health.report, &health.config.root);
482 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
483 all_runs.extend(runs.iter().cloned());
484 }
485 }
486
487 let combined = serde_json::json!({
488 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
489 "version": "2.1.0",
490 "runs": all_runs,
491 });
492
493 report::emit_json(&combined, "SARIF audit")
494}
495
496fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
497 let value = build_audit_codeclimate(result);
498 report::emit_json(&value, "CodeClimate audit")
499}
500
501#[expect(
502 clippy::expect_used,
503 reason = "CodeClimate issue envelope contains only infallibly serializable fields"
504)]
505fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
506 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
507
508 if let Some(ref check) = result.check {
509 all_issues.extend(report::build_codeclimate(
510 &check.results,
511 &check.config.root,
512 &check.config.rules,
513 ));
514 }
515
516 if let Some(ref dupes) = result.dupes {
517 all_issues.extend(report::build_duplication_codeclimate(
518 &dupes.report,
519 &dupes.config.root,
520 ));
521 }
522
523 if let Some(ref health) = result.health {
524 all_issues.extend(report::build_health_codeclimate(
525 &health.report,
526 &health.config.root,
527 ));
528 }
529
530 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
531}
532
533#[cfg(test)]
534mod tests {
535 use crate::audit::AuditSummary;
536
537 use super::{
538 build_status_parts, build_vital_sign_parts, format_scope_line_parts, short_base_ref,
539 };
540
541 #[test]
542 fn short_base_ref_abbreviates_full_sha() {
543 assert_eq!(
544 short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
545 "611d151e8250"
546 );
547 }
548
549 #[test]
550 fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
551 assert_eq!(short_base_ref("main"), "main");
552 assert_eq!(short_base_ref("origin/main"), "origin/main");
553 assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
554 assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
556 assert_eq!(
558 short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
559 "611d151e8250146426ff3178e94207f8a8d3ccZZ"
560 );
561 }
562
563 #[test]
564 fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
565 assert_eq!(
566 format_scope_line_parts(
567 1,
568 "611d151e8250146426ff3178e94207f8a8d3cc7b",
569 Some("merge-base with origin/main"),
570 Some("HEADSHA")
571 ),
572 "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
573 );
574 assert_eq!(
575 format_scope_line_parts(3, "origin/main", None, None),
576 "Audit scope: 3 changed files vs origin/main"
577 );
578 }
579
580 #[test]
581 fn build_status_parts_describes_only_non_empty_categories() {
582 let summary = AuditSummary {
583 dead_code_issues: 1,
584 dead_code_has_errors: true,
585 complexity_findings: 2,
586 max_cyclomatic: Some(12),
587 duplication_clone_groups: 3,
588 };
589
590 assert_eq!(
591 build_status_parts(&summary),
592 vec![
593 "dead code: 1 issue".to_string(),
594 "complexity: 2 findings".to_string(),
595 "duplication: 3 clone groups".to_string(),
596 ]
597 );
598
599 let empty = AuditSummary {
600 dead_code_issues: 0,
601 dead_code_has_errors: false,
602 complexity_findings: 0,
603 max_cyclomatic: None,
604 duplication_clone_groups: 0,
605 };
606 assert!(build_status_parts(&empty).is_empty());
607 }
608
609 #[test]
610 fn build_vital_sign_parts_includes_warn_threshold_when_present() {
611 let summary = AuditSummary {
612 dead_code_issues: 0,
613 dead_code_has_errors: false,
614 complexity_findings: 2,
615 max_cyclomatic: Some(18),
616 duplication_clone_groups: 1,
617 };
618
619 assert_eq!(
620 build_vital_sign_parts(&summary),
621 vec![
622 "dead code 0".to_string(),
623 "complexity 2 (warn, max cyclomatic: 18)".to_string(),
624 "duplication 1".to_string(),
625 ]
626 );
627 }
628
629 #[test]
630 fn build_vital_sign_parts_omits_threshold_when_absent() {
631 let summary = AuditSummary {
632 dead_code_issues: 3,
633 dead_code_has_errors: false,
634 complexity_findings: 0,
635 max_cyclomatic: None,
636 duplication_clone_groups: 0,
637 };
638
639 assert_eq!(
640 build_vital_sign_parts(&summary),
641 vec![
642 "dead code 3".to_string(),
643 "complexity 0".to_string(),
644 "duplication 0".to_string(),
645 ]
646 );
647 }
648}