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