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 format_scope_line(result: &AuditResult) -> String {
167 let sha_suffix = result
168 .head_sha
169 .as_ref()
170 .map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
171 format!(
172 "Audit scope: {} changed file{} vs {}{}",
173 result.changed_files_count,
174 plural(result.changed_files_count),
175 result.base_ref,
176 sha_suffix
177 )
178}
179
180fn print_audit_vital_signs(result: &AuditResult) {
182 let mut parts = Vec::new();
183 parts.push(format!("dead code {}", result.summary.dead_code_issues));
184 if let Some(max) = result.summary.max_cyclomatic {
185 parts.push(format!(
186 "complexity {} (warn, max cyclomatic: {max})",
187 result.summary.complexity_findings
188 ));
189 } else {
190 parts.push(format!("complexity {}", result.summary.complexity_findings));
191 }
192 parts.push(format!(
193 "duplication {}",
194 result.summary.duplication_clone_groups
195 ));
196
197 let line = parts.join(" \u{00b7} ");
198 outln!(
199 "{} {} {}",
200 "\u{25a0}".dimmed(),
201 "Metrics:".dimmed(),
202 line.dimmed()
203 );
204}
205
206fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
208 let mut parts = Vec::new();
209 if summary.dead_code_issues > 0 {
210 let n = summary.dead_code_issues;
211 parts.push(format!("dead code: {n} issue{}", plural(n)));
212 }
213 if summary.complexity_findings > 0 {
214 let n = summary.complexity_findings;
215 parts.push(format!("complexity: {n} finding{}", plural(n)));
216 }
217 if summary.duplication_clone_groups > 0 {
218 let n = summary.duplication_clone_groups;
219 parts.push(format!("duplication: {n} clone group{}", plural(n)));
220 }
221 parts
222}
223
224fn print_audit_status_line(result: &AuditResult) {
226 let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
227 let n = result.changed_files_count;
228 let files_str = format!("{n} changed file{}", plural(n));
229
230 match result.verdict {
231 AuditVerdict::Pass => {
232 eprintln!(
233 "{}",
234 format!("\u{2713} No issues in {files_str} ({elapsed_str})")
235 .green()
236 .bold()
237 );
238 }
239 AuditVerdict::Warn => {
240 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
241 eprintln!(
242 "{}",
243 format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
244 .green()
245 .bold()
246 );
247 }
248 AuditVerdict::Fail => {
249 let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
250 eprintln!(
251 "{}",
252 format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
253 .red()
254 .bold()
255 );
256 }
257 }
258
259 if !matches!(result.attribution.gate, AuditGate::All) {
260 let inherited = result.attribution.dead_code_inherited
261 + result.attribution.complexity_inherited
262 + result.attribution.duplication_inherited;
263 if inherited > 0 {
264 eprintln!(
265 " {}",
266 format!(
267 "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
268 plural(inherited)
269 )
270 .dimmed()
271 );
272 }
273 }
274 if result.performance {
275 eprintln!(
276 " {}",
277 format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
278 );
279 }
280}
281
282#[expect(
283 clippy::cast_possible_truncation,
284 reason = "elapsed milliseconds won't exceed u64::MAX"
285)]
286fn print_audit_json(result: &AuditResult) -> ExitCode {
287 let mut obj = serde_json::Map::new();
288 obj.insert(
289 "schema_version".into(),
290 serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
291 );
292 obj.insert(
293 "version".into(),
294 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
295 );
296 obj.insert(
297 "command".into(),
298 serde_json::Value::String("audit".to_string()),
299 );
300 obj.insert(
301 "verdict".into(),
302 serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
303 );
304 obj.insert(
305 "changed_files_count".into(),
306 serde_json::Value::Number(result.changed_files_count.into()),
307 );
308 obj.insert(
309 "base_ref".into(),
310 serde_json::Value::String(result.base_ref.clone()),
311 );
312 if let Some(ref sha) = result.head_sha {
313 obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
314 }
315 obj.insert(
316 "elapsed_ms".into(),
317 serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
318 );
319 if result.performance {
320 obj.insert(
321 "base_snapshot_skipped".into(),
322 serde_json::Value::Bool(result.base_snapshot_skipped),
323 );
324 }
325
326 if let Ok(summary_val) = serde_json::to_value(&result.summary) {
327 obj.insert("summary".into(), summary_val);
328 }
329 if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
330 obj.insert("attribution".into(), attribution_val);
331 }
332
333 if let Some(ref check) = result.check {
334 match report::build_check_json_payload_with_config_fixable(
335 &check.results,
336 &check.config.root,
337 check.elapsed,
338 check.config_fixable,
339 ) {
340 Ok(mut json) => {
341 if let Some(ref base) = result.base_snapshot {
342 annotate_dead_code_json(
343 &mut json,
344 &check.results,
345 &check.config.root,
346 &base.dead_code,
347 );
348 }
349 obj.insert("dead_code".into(), json);
350 }
351 Err(e) => {
352 return emit_error(
353 &format!("JSON serialization error: {e}"),
354 2,
355 OutputFormat::Json,
356 );
357 }
358 }
359 }
360
361 if let Some(ref dupes) = result.dupes {
362 let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
363 match serde_json::to_value(&payload) {
364 Ok(mut json) => {
365 let root_prefix = format!("{}/", dupes.config.root.display());
366 report::strip_root_prefix(&mut json, &root_prefix);
367 if let Some(ref base) = result.base_snapshot {
368 annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
369 }
370 obj.insert("duplication".into(), json);
371 }
372 Err(e) => {
373 return emit_error(
374 &format!("JSON serialization error: {e}"),
375 2,
376 OutputFormat::Json,
377 );
378 }
379 }
380 }
381
382 if let Some(ref health) = result.health {
383 match serde_json::to_value(&health.report) {
384 Ok(mut json) => {
385 let root_prefix = format!("{}/", health.config.root.display());
386 report::strip_root_prefix(&mut json, &root_prefix);
387 if let Some(ref base) = result.base_snapshot {
388 annotate_health_json(
389 &mut json,
390 &health.report,
391 &health.config.root,
392 &base.health,
393 );
394 }
395 obj.insert("complexity".into(), json);
396 }
397 Err(e) => {
398 return emit_error(
399 &format!("JSON serialization error: {e}"),
400 2,
401 OutputFormat::Json,
402 );
403 }
404 }
405 }
406
407 let mut output = serde_json::Value::Object(obj);
408 crate::output_envelope::apply_root_kind(&mut output, "audit");
409 report::harmonize_multi_kind_suppress_line_actions(&mut output);
410 report::emit_json(&output, "audit")
411}
412
413fn print_audit_sarif(result: &AuditResult) -> ExitCode {
414 let mut all_runs = Vec::new();
415
416 if let Some(ref check) = result.check {
417 let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
418 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
419 all_runs.extend(runs.iter().cloned());
420 }
421 }
422
423 if let Some(ref dupes) = result.dupes
424 && !dupes.report.clone_groups.is_empty()
425 {
426 let run = serde_json::json!({
427 "tool": {
428 "driver": {
429 "name": "fallow",
430 "version": env!("CARGO_PKG_VERSION"),
431 "informationUri": "https://github.com/fallow-rs/fallow",
432 }
433 },
434 "automationDetails": { "id": "fallow/audit/dupes" },
435 "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
436 serde_json::json!({
437 "ruleId": "fallow/code-duplication",
438 "level": "warning",
439 "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
440 })
441 }).collect::<Vec<_>>()
442 });
443 all_runs.push(run);
444 }
445
446 if let Some(ref health) = result.health {
447 let sarif = report::build_health_sarif(&health.report, &health.config.root);
448 if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
449 all_runs.extend(runs.iter().cloned());
450 }
451 }
452
453 let combined = serde_json::json!({
454 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
455 "version": "2.1.0",
456 "runs": all_runs,
457 });
458
459 report::emit_json(&combined, "SARIF audit")
460}
461
462fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
463 let value = build_audit_codeclimate(result);
464 report::emit_json(&value, "CodeClimate audit")
465}
466
467#[expect(
468 clippy::expect_used,
469 reason = "CodeClimate issue envelope contains only infallibly serializable fields"
470)]
471fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
472 let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
473
474 if let Some(ref check) = result.check {
475 all_issues.extend(report::build_codeclimate(
476 &check.results,
477 &check.config.root,
478 &check.config.rules,
479 ));
480 }
481
482 if let Some(ref dupes) = result.dupes {
483 all_issues.extend(report::build_duplication_codeclimate(
484 &dupes.report,
485 &dupes.config.root,
486 ));
487 }
488
489 if let Some(ref health) = result.health {
490 all_issues.extend(report::build_health_codeclimate(
491 &health.report,
492 &health.config.root,
493 ));
494 }
495
496 serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
497}