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/// Abbreviate a 40-char hex SHA to 12 chars for display; leave anything else
166/// (branch names, refspecs, the literal user typed for `--base`) untouched.
167fn 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
175/// Format the scope context line. When the base ref was auto-detected (or set
176/// via `FALLOW_AUDIT_BASE`), append the provenance so the comparison target is
177/// checkable, e.g. `vs a1b2c3d4e5f6 (merge-base with origin/main)`.
178fn 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
207/// Print a dimmed vital-signs line summarizing warn-only findings.
208fn 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
233/// Build summary parts for the status line (shared between warn and fail).
234fn 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
251/// Print the final status line on stderr.
252fn 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        // Not 40 chars, so not treated as a SHA.
568        assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
569        // 40 chars but contains a non-hex character: left untouched.
570        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}