Skip to main content

fallow_cli/
audit_output.rs

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/// Print audit results and return the appropriate exit code.
16#[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
165/// Format the scope context line.
166fn 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
180/// Print a dimmed vital-signs line summarizing warn-only findings.
181fn 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
206/// Build summary parts for the status line (shared between warn and fail).
207fn 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
224/// Print the final status line on stderr.
225fn 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}