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 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        // Not 40 chars, so not treated as a SHA.
555        assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
556        // 40 chars but contains a non-hex character: left untouched.
557        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}