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
89    if has_check_issues || has_health_findings || has_dupe_groups {
90        print_audit_findings(result, quiet, explain, show_headers);
91    }
92
93    if !has_dupe_groups && let Some(ref dupes) = result.dupes {
94        crate::dupes::print_default_ignore_note(dupes, quiet);
95        crate::dupes::print_min_occurrences_note(dupes, quiet);
96    }
97
98    if !quiet {
99        print_audit_status_line(result);
100    }
101}
102
103/// Print the per-analysis findings sections (dead code, duplication, complexity)
104/// plus the explain tip and vital signs, with section headers when enabled.
105fn print_audit_findings(result: &AuditResult, quiet: bool, explain: bool, show_headers: bool) {
106    print_audit_explain_tip(show_headers);
107
108    if result.verdict != AuditVerdict::Fail && !quiet {
109        print_audit_vital_signs(result);
110    }
111
112    if result.summary.dead_code_issues > 0
113        && let Some(ref check) = result.check
114    {
115        print_audit_section_header(
116            show_headers,
117            "── Dead Code ──────────────────────────────────────",
118        );
119        crate::check::print_check_result(
120            check,
121            crate::check::PrintCheckOptions {
122                quiet,
123                explain,
124                regression_json: false,
125                group_by: None,
126                top: None,
127                summary: false,
128                summary_heading: true,
129                show_explain_tip: false,
130            },
131        );
132    }
133
134    if result.summary.duplication_clone_groups > 0
135        && let Some(ref dupes) = result.dupes
136    {
137        print_audit_section_header(
138            show_headers,
139            "── Duplication ────────────────────────────────────",
140        );
141        crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
142    }
143
144    if result.summary.complexity_findings > 0
145        && let Some(ref health) = result.health
146    {
147        print_audit_section_header(
148            show_headers,
149            "── Complexity ─────────────────────────────────────",
150        );
151        crate::health::print_health_result(
152            health,
153            crate::health::HealthPrintOptions {
154                quiet,
155                explain,
156                min_score: None,
157                min_severity: None,
158                report_only: false,
159                summary: false,
160                summary_heading: true,
161                show_explain_tip: false,
162                skip_score_and_trend: false,
163            },
164        );
165    }
166}
167
168/// Print the TTY-only explain tip above the findings sections.
169fn print_audit_explain_tip(show_headers: bool) {
170    if show_headers && std::io::stdout().is_terminal() && !crate::report::sink::is_redirected() {
171        println!(
172            "{}",
173            "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
174                .dimmed()
175        );
176        println!();
177    }
178}
179
180/// Emit a blank line followed by a section header when headers are enabled.
181fn print_audit_section_header(show_headers: bool, header: &str) {
182    if show_headers {
183        eprintln!();
184        eprintln!("{header}");
185    }
186}
187
188/// Abbreviate a 40-char hex SHA to 12 chars for display; leave anything else
189/// (branch names, refspecs, the literal user typed for `--base`) untouched.
190fn short_base_ref(base_ref: &str) -> &str {
191    if base_ref.len() == 40 && base_ref.bytes().all(|b| b.is_ascii_hexdigit()) {
192        &base_ref[..12]
193    } else {
194        base_ref
195    }
196}
197
198/// Format the scope context line. When the base ref was auto-detected (or set
199/// via `FALLOW_AUDIT_BASE`), append the provenance so the comparison target is
200/// checkable, e.g. `vs a1b2c3d4e5f6 (merge-base with origin/main)`.
201fn format_scope_line(result: &AuditResult) -> String {
202    format_scope_line_parts(
203        result.changed_files_count,
204        &result.base_ref,
205        result.base_description.as_deref(),
206        result.head_sha.as_deref(),
207    )
208}
209
210fn format_scope_line_parts(
211    changed_files_count: usize,
212    base_ref: &str,
213    base_description: Option<&str>,
214    head_sha: Option<&str>,
215) -> String {
216    let sha_suffix = head_sha.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
217    let base_display = match base_description {
218        Some(description) => format!("{} ({description})", short_base_ref(base_ref)),
219        None => base_ref.to_string(),
220    };
221    format!(
222        "Audit scope: {} changed file{} vs {}{}",
223        changed_files_count,
224        plural(changed_files_count),
225        base_display,
226        sha_suffix
227    )
228}
229
230/// Print a dimmed vital-signs line summarizing warn-only findings.
231fn print_audit_vital_signs(result: &AuditResult) {
232    let line = build_vital_sign_parts(&result.summary).join(" \u{00b7} ");
233    outln!(
234        "{} {} {}",
235        "\u{25a0}".dimmed(),
236        "Metrics:".dimmed(),
237        line.dimmed()
238    );
239}
240
241fn build_vital_sign_parts(summary: &AuditSummary) -> Vec<String> {
242    let mut parts = Vec::new();
243    parts.push(format!("dead code {}", summary.dead_code_issues));
244    if let Some(max) = summary.max_cyclomatic {
245        parts.push(format!(
246            "complexity {} (warn, max cyclomatic: {max})",
247            summary.complexity_findings
248        ));
249    } else {
250        parts.push(format!("complexity {}", summary.complexity_findings));
251    }
252    parts.push(format!("duplication {}", summary.duplication_clone_groups));
253    parts
254}
255
256/// Build summary parts for the status line (shared between warn and fail).
257fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
258    let mut parts = Vec::new();
259    if summary.dead_code_issues > 0 {
260        let n = summary.dead_code_issues;
261        parts.push(format!("dead code: {n} issue{}", plural(n)));
262    }
263    if summary.complexity_findings > 0 {
264        let n = summary.complexity_findings;
265        parts.push(format!("complexity: {n} finding{}", plural(n)));
266    }
267    if summary.duplication_clone_groups > 0 {
268        let n = summary.duplication_clone_groups;
269        parts.push(format!("duplication: {n} clone group{}", plural(n)));
270    }
271    parts
272}
273
274/// Print the final status line on stderr.
275fn print_audit_status_line(result: &AuditResult) {
276    let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
277    let n = result.changed_files_count;
278    let files_str = format!("{n} changed file{}", plural(n));
279
280    match result.verdict {
281        AuditVerdict::Pass => {
282            eprintln!(
283                "{}",
284                format!("\u{2713} No issues in {files_str} ({elapsed_str})")
285                    .green()
286                    .bold()
287            );
288        }
289        AuditVerdict::Warn => {
290            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
291            eprintln!(
292                "{}",
293                format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
294                    .green()
295                    .bold()
296            );
297        }
298        AuditVerdict::Fail => {
299            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
300            eprintln!(
301                "{}",
302                format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
303                    .red()
304                    .bold()
305            );
306        }
307    }
308
309    if !matches!(result.attribution.gate, AuditGate::All) {
310        let inherited = result.attribution.dead_code_inherited
311            + result.attribution.complexity_inherited
312            + result.attribution.duplication_inherited;
313        if inherited > 0 {
314            eprintln!(
315                "  {}",
316                format!(
317                    "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
318                    plural(inherited)
319                )
320                .dimmed()
321            );
322        }
323    }
324    if result.performance {
325        eprintln!(
326            "  {}",
327            format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
328        );
329    }
330}
331
332fn print_audit_json(result: &AuditResult) -> ExitCode {
333    let mut obj = serde_json::Map::new();
334    insert_audit_json_header(&mut obj, result);
335
336    if let Some(ref check) = result.check
337        && let Err(code) = insert_audit_dead_code_json(&mut obj, result, check)
338    {
339        return code;
340    }
341
342    if let Some(ref dupes) = result.dupes
343        && let Err(code) = insert_audit_duplication_json(&mut obj, result, dupes)
344    {
345        return code;
346    }
347
348    if let Some(ref health) = result.health
349        && let Err(code) = insert_audit_health_json(&mut obj, result, health)
350    {
351        return code;
352    }
353
354    insert_audit_next_steps_json(&mut obj, result);
355
356    let mut output = serde_json::Value::Object(obj);
357    crate::output_envelope::apply_root_kind(&mut output, "audit");
358    report::harmonize_multi_kind_suppress_line_actions(&mut output);
359    crate::output_envelope::attach_telemetry_meta(&mut output);
360    report::emit_json(&output, "audit")
361}
362
363#[expect(
364    clippy::cast_possible_truncation,
365    reason = "elapsed milliseconds won't exceed u64::MAX"
366)]
367fn insert_audit_json_header(
368    obj: &mut serde_json::Map<String, serde_json::Value>,
369    result: &AuditResult,
370) {
371    obj.insert(
372        "schema_version".into(),
373        serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
374    );
375    obj.insert(
376        "version".into(),
377        serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
378    );
379    obj.insert(
380        "command".into(),
381        serde_json::Value::String("audit".to_string()),
382    );
383    obj.insert(
384        "verdict".into(),
385        serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
386    );
387    obj.insert(
388        "changed_files_count".into(),
389        serde_json::Value::Number(result.changed_files_count.into()),
390    );
391    obj.insert(
392        "base_ref".into(),
393        serde_json::Value::String(result.base_ref.clone()),
394    );
395    if let Some(ref description) = result.base_description {
396        obj.insert(
397            "base_description".into(),
398            serde_json::Value::String(description.clone()),
399        );
400    }
401    if let Some(ref sha) = result.head_sha {
402        obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
403    }
404    obj.insert(
405        "elapsed_ms".into(),
406        serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
407    );
408    if result.performance {
409        obj.insert(
410            "base_snapshot_skipped".into(),
411            serde_json::Value::Bool(result.base_snapshot_skipped),
412        );
413    }
414
415    if let Ok(summary_val) = serde_json::to_value(&result.summary) {
416        obj.insert("summary".into(), summary_val);
417    }
418    if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
419        obj.insert("attribution".into(), attribution_val);
420    }
421}
422
423fn insert_audit_dead_code_json(
424    obj: &mut serde_json::Map<String, serde_json::Value>,
425    result: &AuditResult,
426    check: &crate::check::CheckResult,
427) -> Result<(), ExitCode> {
428    match report::build_check_json_payload_with_config_fixable(
429        &check.results,
430        &check.config.root,
431        check.elapsed,
432        check.config_fixable,
433    ) {
434        Ok(mut json) => {
435            if let Some(ref base) = result.base_snapshot {
436                annotate_dead_code_json(
437                    &mut json,
438                    &check.results,
439                    &check.config.root,
440                    &base.dead_code,
441                );
442            }
443            obj.insert("dead_code".into(), json);
444            Ok(())
445        }
446        Err(e) => Err(emit_error(
447            &format!("JSON serialization error: {e}"),
448            2,
449            OutputFormat::Json,
450        )),
451    }
452}
453
454fn insert_audit_duplication_json(
455    obj: &mut serde_json::Map<String, serde_json::Value>,
456    result: &AuditResult,
457    dupes: &crate::dupes::DupesResult,
458) -> Result<(), ExitCode> {
459    let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
460    match serde_json::to_value(&payload) {
461        Ok(mut json) => {
462            let root_prefix = format!("{}/", dupes.config.root.display());
463            report::strip_root_prefix(&mut json, &root_prefix);
464            if let Some(ref base) = result.base_snapshot {
465                annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
466            }
467            obj.insert("duplication".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_health_json(
479    obj: &mut serde_json::Map<String, serde_json::Value>,
480    result: &AuditResult,
481    health: &crate::health::HealthResult,
482) -> Result<(), ExitCode> {
483    match serde_json::to_value(&health.report) {
484        Ok(mut json) => {
485            let root_prefix = format!("{}/", health.config.root.display());
486            report::strip_root_prefix(&mut json, &root_prefix);
487            if let Some(ref base) = result.base_snapshot {
488                annotate_health_json(&mut json, &health.report, &health.config.root, &base.health);
489            }
490            obj.insert("complexity".into(), json);
491            Ok(())
492        }
493        Err(e) => Err(emit_error(
494            &format!("JSON serialization error: {e}"),
495            2,
496            OutputFormat::Json,
497        )),
498    }
499}
500
501fn insert_audit_next_steps_json(
502    obj: &mut serde_json::Map<String, serde_json::Value>,
503    result: &AuditResult,
504) {
505    let next_steps = crate::report::suggestions::build_audit_next_steps(
506        result
507            .check
508            .as_ref()
509            .map(|check| (&check.results, check.config.root.as_path())),
510        result.health.as_ref().map(|health| &health.report),
511    );
512    if !next_steps.is_empty()
513        && let Ok(value) = serde_json::to_value(&next_steps)
514    {
515        obj.insert("next_steps".into(), value);
516    }
517}
518
519fn print_audit_sarif(result: &AuditResult) -> ExitCode {
520    let mut all_runs = Vec::new();
521
522    if let Some(ref check) = result.check {
523        let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
524        if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
525            all_runs.extend(runs.iter().cloned());
526        }
527    }
528
529    if let Some(ref dupes) = result.dupes
530        && !dupes.report.clone_groups.is_empty()
531    {
532        let run = serde_json::json!({
533            "tool": {
534                "driver": {
535                    "name": "fallow",
536                    "version": env!("CARGO_PKG_VERSION"),
537                    "informationUri": "https://github.com/fallow-rs/fallow",
538                }
539            },
540            "automationDetails": { "id": "fallow/audit/dupes" },
541            "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
542                serde_json::json!({
543                    "ruleId": "fallow/code-duplication",
544                    "level": "warning",
545                    "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
546                })
547            }).collect::<Vec<_>>()
548        });
549        all_runs.push(run);
550    }
551
552    if let Some(ref health) = result.health {
553        let sarif = report::build_health_sarif(&health.report, &health.config.root);
554        if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
555            all_runs.extend(runs.iter().cloned());
556        }
557    }
558
559    let combined = serde_json::json!({
560        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
561        "version": "2.1.0",
562        "runs": all_runs,
563    });
564
565    report::emit_json(&combined, "SARIF audit")
566}
567
568fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
569    let value = build_audit_codeclimate(result);
570    report::emit_json(&value, "CodeClimate audit")
571}
572
573#[expect(
574    clippy::expect_used,
575    reason = "CodeClimate issue envelope contains only infallibly serializable fields"
576)]
577fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
578    let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
579
580    if let Some(ref check) = result.check {
581        all_issues.extend(report::build_codeclimate(
582            &check.results,
583            &check.config.root,
584            &check.config.rules,
585        ));
586    }
587
588    if let Some(ref dupes) = result.dupes {
589        all_issues.extend(report::build_duplication_codeclimate(
590            &dupes.report,
591            &dupes.config.root,
592        ));
593    }
594
595    if let Some(ref health) = result.health {
596        all_issues.extend(report::build_health_codeclimate(
597            &health.report,
598            &health.config.root,
599        ));
600    }
601
602    serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
603}
604
605#[cfg(test)]
606mod tests {
607    use std::process::ExitCode;
608    use std::time::Duration;
609
610    use fallow_config::{AuditGate, OutputFormat};
611
612    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
613
614    use super::{
615        build_audit_codeclimate, build_status_parts, build_vital_sign_parts,
616        format_scope_line_parts, print_audit_result, short_base_ref,
617    };
618
619    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
620        AuditResult {
621            verdict,
622            summary: AuditSummary {
623                dead_code_issues: 0,
624                dead_code_has_errors: false,
625                complexity_findings: 0,
626                max_cyclomatic: None,
627                duplication_clone_groups: 0,
628            },
629            attribution: AuditAttribution {
630                gate: AuditGate::NewOnly,
631                ..AuditAttribution::default()
632            },
633            base_snapshot: None,
634            base_snapshot_skipped: false,
635            changed_files_count: 0,
636            changed_files: Vec::new(),
637            base_ref: "origin/main".to_string(),
638            base_description: None,
639            head_sha: None,
640            output,
641            performance: false,
642            check: None,
643            dupes: None,
644            health: None,
645            elapsed: Duration::ZERO,
646        }
647    }
648
649    #[test]
650    fn short_base_ref_abbreviates_full_sha() {
651        assert_eq!(
652            short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
653            "611d151e8250"
654        );
655    }
656
657    #[test]
658    fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
659        assert_eq!(short_base_ref("main"), "main");
660        assert_eq!(short_base_ref("origin/main"), "origin/main");
661        assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
662        // Not 40 chars, so not treated as a SHA.
663        assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
664        // 40 chars but contains a non-hex character: left untouched.
665        assert_eq!(
666            short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
667            "611d151e8250146426ff3178e94207f8a8d3ccZZ"
668        );
669    }
670
671    #[test]
672    fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
673        assert_eq!(
674            format_scope_line_parts(
675                1,
676                "611d151e8250146426ff3178e94207f8a8d3cc7b",
677                Some("merge-base with origin/main"),
678                Some("HEADSHA")
679            ),
680            "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
681        );
682        assert_eq!(
683            format_scope_line_parts(3, "origin/main", None, None),
684            "Audit scope: 3 changed files vs origin/main"
685        );
686    }
687
688    #[test]
689    fn build_status_parts_describes_only_non_empty_categories() {
690        let summary = AuditSummary {
691            dead_code_issues: 1,
692            dead_code_has_errors: true,
693            complexity_findings: 2,
694            max_cyclomatic: Some(12),
695            duplication_clone_groups: 3,
696        };
697
698        assert_eq!(
699            build_status_parts(&summary),
700            vec![
701                "dead code: 1 issue".to_string(),
702                "complexity: 2 findings".to_string(),
703                "duplication: 3 clone groups".to_string(),
704            ]
705        );
706
707        let empty = AuditSummary {
708            dead_code_issues: 0,
709            dead_code_has_errors: false,
710            complexity_findings: 0,
711            max_cyclomatic: None,
712            duplication_clone_groups: 0,
713        };
714        assert!(build_status_parts(&empty).is_empty());
715    }
716
717    #[test]
718    fn build_vital_sign_parts_includes_warn_threshold_when_present() {
719        let summary = AuditSummary {
720            dead_code_issues: 0,
721            dead_code_has_errors: false,
722            complexity_findings: 2,
723            max_cyclomatic: Some(18),
724            duplication_clone_groups: 1,
725        };
726
727        assert_eq!(
728            build_vital_sign_parts(&summary),
729            vec![
730                "dead code 0".to_string(),
731                "complexity 2 (warn, max cyclomatic: 18)".to_string(),
732                "duplication 1".to_string(),
733            ]
734        );
735    }
736
737    #[test]
738    fn build_vital_sign_parts_omits_threshold_when_absent() {
739        let summary = AuditSummary {
740            dead_code_issues: 3,
741            dead_code_has_errors: false,
742            complexity_findings: 0,
743            max_cyclomatic: None,
744            duplication_clone_groups: 0,
745        };
746
747        assert_eq!(
748            build_vital_sign_parts(&summary),
749            vec![
750                "dead code 3".to_string(),
751                "complexity 0".to_string(),
752                "duplication 0".to_string(),
753            ]
754        );
755    }
756
757    #[test]
758    fn build_audit_codeclimate_returns_empty_issue_list_without_findings() {
759        let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
760
761        assert_eq!(build_audit_codeclimate(&result), serde_json::json!([]));
762    }
763
764    #[test]
765    fn print_audit_result_rejects_badge_format() {
766        let result = audit_result(AuditVerdict::Pass, OutputFormat::Badge);
767
768        assert_eq!(print_audit_result(&result, true, false), ExitCode::from(2));
769    }
770
771    #[test]
772    fn print_audit_result_maps_fail_verdict_to_error_exit() {
773        let result = audit_result(AuditVerdict::Fail, OutputFormat::Human);
774
775        assert_eq!(print_audit_result(&result, true, false), ExitCode::from(1));
776    }
777}