Skip to main content

fallow_cli/
audit_output.rs

1use std::io::IsTerminal;
2use std::process::ExitCode;
3
4use colored::Colorize;
5use fallow_api::{
6    AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput, AuditSarifOutputInput,
7    DupesReportPayload,
8};
9use fallow_config::{AuditGate, OutputFormat};
10use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
11use fallow_types::results::AnalysisResults;
12
13use crate::error::emit_error;
14use crate::report;
15use crate::report::plural;
16use crate::report::sink::outln;
17
18use super::keys::{annotate_dead_code_json, annotate_dupes_json, annotate_health_json};
19use super::{AuditResult, AuditSummary, AuditVerdict};
20
21/// Print audit results and return the appropriate exit code.
22#[must_use]
23pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
24    let output = result.output;
25
26    let format_exit = match output {
27        OutputFormat::Json => print_audit_json(result),
28        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
29            print_audit_human(result, quiet, explain, output);
30            ExitCode::SUCCESS
31        }
32        OutputFormat::Sarif => print_audit_sarif(result),
33        OutputFormat::CodeClimate => print_audit_codeclimate(result),
34        OutputFormat::PrCommentGithub => {
35            let value = build_audit_codeclimate(result);
36            report::ci::pr_comment::print_pr_comment(
37                "audit",
38                report::ci::pr_comment::Provider::Github,
39                &value,
40            )
41        }
42        OutputFormat::PrCommentGitlab => {
43            let value = build_audit_codeclimate(result);
44            report::ci::pr_comment::print_pr_comment(
45                "audit",
46                report::ci::pr_comment::Provider::Gitlab,
47                &value,
48            )
49        }
50        OutputFormat::ReviewGithub => {
51            let value = build_audit_codeclimate(result);
52            report::ci::review::print_review_envelope(
53                "audit",
54                report::ci::pr_comment::Provider::Github,
55                &value,
56            )
57        }
58        OutputFormat::ReviewGitlab => {
59            let value = build_audit_codeclimate(result);
60            report::ci::review::print_review_envelope(
61                "audit",
62                report::ci::pr_comment::Provider::Gitlab,
63                &value,
64            )
65        }
66        OutputFormat::Badge => {
67            eprintln!("Error: badge format is not supported for the audit command");
68            return ExitCode::from(2);
69        }
70    };
71
72    if format_exit != ExitCode::SUCCESS {
73        return format_exit;
74    }
75
76    match result.verdict {
77        AuditVerdict::Fail => ExitCode::from(1),
78        AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
79    }
80}
81
82fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
83    let show_headers = matches!(output, OutputFormat::Human) && !quiet;
84
85    if !quiet {
86        let scope = format_scope_line(result);
87        eprintln!();
88        eprintln!("{scope}");
89    }
90
91    let has_check_issues = result.summary.dead_code_issues > 0;
92    let has_health_findings = result.summary.complexity_findings > 0;
93    let has_dupe_groups = result.summary.duplication_clone_groups > 0;
94
95    if has_check_issues || has_health_findings || has_dupe_groups {
96        print_audit_findings(result, quiet, explain, show_headers);
97    }
98
99    if !has_dupe_groups && let Some(ref dupes) = result.dupes {
100        crate::dupes::print_default_ignore_note(dupes, quiet);
101        crate::dupes::print_min_occurrences_note(dupes, quiet);
102    }
103
104    if !quiet {
105        print_audit_status_line(result);
106    }
107}
108
109/// Print the per-analysis findings sections (dead code, duplication, complexity)
110/// plus the explain tip and vital signs, with section headers when enabled.
111pub fn print_audit_findings(result: &AuditResult, quiet: bool, explain: bool, show_headers: bool) {
112    print_audit_explain_tip(show_headers);
113
114    if result.verdict != AuditVerdict::Fail && !quiet {
115        print_audit_vital_signs(result);
116    }
117
118    if result.summary.dead_code_issues > 0
119        && let Some(ref check) = result.check
120    {
121        print_audit_section_header(
122            show_headers,
123            "── Dead Code ──────────────────────────────────────",
124        );
125        crate::check::print_check_result(
126            check,
127            crate::check::PrintCheckOptions {
128                quiet,
129                explain,
130                regression_json: false,
131                group_by: None,
132                top: None,
133                summary: false,
134                summary_heading: true,
135                show_explain_tip: false,
136            },
137        );
138    }
139
140    if result.summary.duplication_clone_groups > 0
141        && let Some(ref dupes) = result.dupes
142    {
143        print_audit_section_header(
144            show_headers,
145            "── Duplication ────────────────────────────────────",
146        );
147        crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
148    }
149
150    if result.summary.complexity_findings > 0
151        && let Some(ref health) = result.health
152    {
153        print_audit_section_header(
154            show_headers,
155            "── Complexity ─────────────────────────────────────",
156        );
157        crate::health::print_health_result(
158            health,
159            crate::health::HealthPrintOptions {
160                quiet,
161                explain,
162                gates: fallow_engine::HealthGateOptions::default(),
163                summary: false,
164                summary_heading: true,
165                show_explain_tip: false,
166                skip_score_and_trend: false,
167                css_requested: false,
168            },
169        );
170    }
171}
172
173/// Print the TTY-only explain tip above the findings sections.
174fn print_audit_explain_tip(show_headers: bool) {
175    if show_headers && std::io::stdout().is_terminal() && !crate::report::sink::is_redirected() {
176        println!(
177            "{}",
178            "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
179                .dimmed()
180        );
181        println!();
182    }
183}
184
185/// Emit a blank line followed by a section header when headers are enabled.
186fn print_audit_section_header(show_headers: bool, header: &str) {
187    if show_headers {
188        eprintln!();
189        eprintln!("{header}");
190    }
191}
192
193/// Abbreviate a 40-char hex SHA to 12 chars for display; leave anything else
194/// (branch names, refspecs, the literal user typed for `--base`) untouched.
195fn short_base_ref(base_ref: &str) -> &str {
196    if base_ref.len() == 40 && base_ref.bytes().all(|b| b.is_ascii_hexdigit()) {
197        &base_ref[..12]
198    } else {
199        base_ref
200    }
201}
202
203/// Format the scope context line. When the base ref was auto-detected (or set
204/// via `FALLOW_AUDIT_BASE`), append the provenance so the comparison target is
205/// checkable, e.g. `vs a1b2c3d4e5f6 (merge-base with origin/main)`.
206fn format_scope_line(result: &AuditResult) -> String {
207    format_scope_line_parts(
208        result.changed_files_count,
209        &result.base_ref,
210        result.base_description.as_deref(),
211        result.head_sha.as_deref(),
212    )
213}
214
215fn format_scope_line_parts(
216    changed_files_count: usize,
217    base_ref: &str,
218    base_description: Option<&str>,
219    head_sha: Option<&str>,
220) -> String {
221    let sha_suffix = head_sha.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
222    let base_display = match base_description {
223        Some(description) => format!("{} ({description})", short_base_ref(base_ref)),
224        None => base_ref.to_string(),
225    };
226    format!(
227        "Audit scope: {} changed file{} vs {}{}",
228        changed_files_count,
229        plural(changed_files_count),
230        base_display,
231        sha_suffix
232    )
233}
234
235/// Print a dimmed vital-signs line summarizing warn-only findings.
236fn print_audit_vital_signs(result: &AuditResult) {
237    let line = build_vital_sign_parts(&result.summary).join(" \u{00b7} ");
238    outln!(
239        "{} {} {}",
240        "\u{25a0}".dimmed(),
241        "Metrics:".dimmed(),
242        line.dimmed()
243    );
244}
245
246fn build_vital_sign_parts(summary: &AuditSummary) -> Vec<String> {
247    let mut parts = Vec::new();
248    parts.push(format!("dead code {}", summary.dead_code_issues));
249    if let Some(max) = summary.max_cyclomatic {
250        parts.push(format!(
251            "complexity {} (warn, max cyclomatic: {max})",
252            summary.complexity_findings
253        ));
254    } else {
255        parts.push(format!("complexity {}", summary.complexity_findings));
256    }
257    parts.push(format!("duplication {}", summary.duplication_clone_groups));
258    parts
259}
260
261/// Build summary parts for the status line (shared between warn and fail).
262fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
263    let mut parts = Vec::new();
264    if summary.dead_code_issues > 0 {
265        let n = summary.dead_code_issues;
266        parts.push(format!("dead code: {n} issue{}", plural(n)));
267    }
268    if summary.complexity_findings > 0 {
269        let n = summary.complexity_findings;
270        parts.push(format!("complexity: {n} finding{}", plural(n)));
271    }
272    if summary.duplication_clone_groups > 0 {
273        let n = summary.duplication_clone_groups;
274        parts.push(format!("duplication: {n} clone group{}", plural(n)));
275    }
276    parts
277}
278
279/// Print the final status line on stderr.
280fn print_audit_status_line(result: &AuditResult) {
281    let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
282    let n = result.changed_files_count;
283    let files_str = format!("{n} changed file{}", plural(n));
284
285    match result.verdict {
286        AuditVerdict::Pass => {
287            eprintln!(
288                "{}",
289                format!("\u{2713} No issues in {files_str} ({elapsed_str})")
290                    .green()
291                    .bold()
292            );
293        }
294        AuditVerdict::Warn => {
295            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
296            eprintln!(
297                "{}",
298                format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
299                    .green()
300                    .bold()
301            );
302        }
303        AuditVerdict::Fail => {
304            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
305            eprintln!(
306                "{}",
307                format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
308                    .red()
309                    .bold()
310            );
311        }
312    }
313
314    if !matches!(result.attribution.gate, AuditGate::All) {
315        let inherited = result.attribution.dead_code_inherited
316            + result.attribution.complexity_inherited
317            + result.attribution.duplication_inherited;
318        if inherited > 0 {
319            eprintln!(
320                "  {}",
321                format!(
322                    "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
323                    plural(inherited)
324                )
325                .dimmed()
326            );
327        }
328    }
329    if result.performance {
330        eprintln!(
331            "  {}",
332            format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
333        );
334    }
335}
336
337fn print_audit_json(result: &AuditResult) -> ExitCode {
338    let output = match build_audit_json_output(result) {
339        Ok(output) => output,
340        Err(code) => return code,
341    };
342    report::emit_json(&output, "audit")
343}
344
345fn build_audit_json_output(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
346    let mut check_results = result.check.as_ref().map(|check| check.results.clone());
347    let mut health_report = result.health.as_ref().map(|health| health.report.clone());
348    fallow_output::harmonize_dead_code_health_suppress_line_actions(
349        check_results.as_mut(),
350        health_report.as_mut(),
351    );
352
353    let dead_code = match (result.check.as_ref(), check_results.as_ref()) {
354        (Some(check), Some(results)) => Some(build_audit_dead_code_json_with_results(
355            result, check, results,
356        )?),
357        _ => None,
358    };
359    let duplication = result
360        .dupes
361        .as_ref()
362        .map(|dupes| build_audit_duplication_json(result, dupes))
363        .transpose()?;
364    let complexity = match (result.health.as_ref(), health_report.as_ref()) {
365        (Some(health), Some(report)) => {
366            Some(build_audit_health_json_with_report(result, health, report)?)
367        }
368        _ => None,
369    };
370
371    fallow_api::serialize_audit_json(
372        AuditJsonOutputInput {
373            header: audit_json_header_input(result),
374            dead_code,
375            duplication,
376            complexity,
377            next_steps: audit_next_steps(result),
378        },
379        crate::output_runtime::current_root_envelope_mode(),
380        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
381    )
382    .map_err(|err| {
383        emit_error(
384            &format!("JSON serialization error: {err}"),
385            2,
386            OutputFormat::Json,
387        )
388    })
389}
390
391fn elapsed_ms_for_output(elapsed: std::time::Duration) -> u64 {
392    u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
393}
394
395fn changed_files_count_for_output(changed_files_count: usize) -> u32 {
396    u32::try_from(changed_files_count).unwrap_or(u32::MAX)
397}
398
399pub fn audit_json_header_input(result: &AuditResult) -> AuditJsonHeaderInput {
400    AuditJsonHeaderInput {
401        schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
402        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
403        verdict: result.verdict,
404        changed_files_count: changed_files_count_for_output(result.changed_files_count),
405        base_ref: result.base_ref.clone(),
406        base_description: result.base_description.clone(),
407        head_sha: result.head_sha.clone(),
408        elapsed_ms: ElapsedMs(elapsed_ms_for_output(result.elapsed)),
409        base_snapshot_skipped: result.performance.then_some(result.base_snapshot_skipped),
410        summary: result.summary.clone(),
411        attribution: result.attribution.clone(),
412    }
413}
414
415pub fn insert_audit_dead_code_json(
416    obj: &mut serde_json::Map<String, serde_json::Value>,
417    result: &AuditResult,
418    check: &crate::check::CheckResult,
419) -> Result<(), ExitCode> {
420    let json = build_audit_dead_code_json(result, check)?;
421    obj.insert("dead_code".into(), json);
422    Ok(())
423}
424
425fn build_audit_dead_code_json(
426    result: &AuditResult,
427    check: &crate::check::CheckResult,
428) -> Result<serde_json::Value, ExitCode> {
429    build_audit_dead_code_json_with_results(result, check, &check.results)
430}
431
432fn build_audit_dead_code_json_with_results(
433    result: &AuditResult,
434    check: &crate::check::CheckResult,
435    results: &AnalysisResults,
436) -> Result<serde_json::Value, ExitCode> {
437    match report::api_check_json_payload_with_config_fixable(
438        results,
439        &check.config.root,
440        check.elapsed,
441        check.config_fixable,
442    ) {
443        Ok(mut json) => {
444            if let Some(ref base) = result.base_snapshot {
445                annotate_dead_code_json(&mut json, results, &check.config.root, &base.dead_code);
446            }
447            Ok(json)
448        }
449        Err(e) => Err(emit_error(
450            &format!("JSON serialization error: {e}"),
451            2,
452            OutputFormat::Json,
453        )),
454    }
455}
456
457pub fn insert_audit_duplication_json(
458    obj: &mut serde_json::Map<String, serde_json::Value>,
459    result: &AuditResult,
460    dupes: &crate::dupes::DupesResult,
461) -> Result<(), ExitCode> {
462    let json = build_audit_duplication_json(result, dupes)?;
463    obj.insert("duplication".into(), json);
464    Ok(())
465}
466
467fn build_audit_duplication_json(
468    result: &AuditResult,
469    dupes: &crate::dupes::DupesResult,
470) -> Result<serde_json::Value, ExitCode> {
471    let payload = DupesReportPayload::from_report(&dupes.report);
472    match serde_json::to_value(&payload) {
473        Ok(mut json) => {
474            let root_prefix = format!("{}/", dupes.config.root.display());
475            report::strip_root_prefix(&mut json, &root_prefix);
476            if let Some(ref base) = result.base_snapshot {
477                annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
478            }
479            Ok(json)
480        }
481        Err(e) => Err(emit_error(
482            &format!("JSON serialization error: {e}"),
483            2,
484            OutputFormat::Json,
485        )),
486    }
487}
488
489pub fn insert_audit_health_json(
490    obj: &mut serde_json::Map<String, serde_json::Value>,
491    result: &AuditResult,
492    health: &crate::health::HealthResult,
493) -> Result<(), ExitCode> {
494    let json = build_audit_health_json(result, health)?;
495    obj.insert("complexity".into(), json);
496    Ok(())
497}
498
499fn build_audit_health_json(
500    result: &AuditResult,
501    health: &crate::health::HealthResult,
502) -> Result<serde_json::Value, ExitCode> {
503    build_audit_health_json_with_report(result, health, &health.report)
504}
505
506fn build_audit_health_json_with_report(
507    result: &AuditResult,
508    health: &crate::health::HealthResult,
509    report: &fallow_output::HealthReport,
510) -> Result<serde_json::Value, ExitCode> {
511    match serde_json::to_value(report) {
512        Ok(mut json) => {
513            let root_prefix = format!("{}/", health.config.root.display());
514            report::strip_root_prefix(&mut json, &root_prefix);
515            if let Some(ref base) = result.base_snapshot {
516                annotate_health_json(&mut json, report, &health.config.root, &base.health);
517            }
518            Ok(json)
519        }
520        Err(e) => Err(emit_error(
521            &format!("JSON serialization error: {e}"),
522            2,
523            OutputFormat::Json,
524        )),
525    }
526}
527
528fn audit_next_steps(result: &AuditResult) -> Vec<fallow_types::output::NextStep> {
529    let input = fallow_output::build_audit_next_steps_input(
530        result
531            .check
532            .as_ref()
533            .map(|check| (&check.results, check.config.root.as_path())),
534        result.health.as_ref().map(|health| &health.report),
535        crate::report::suggestions::suggestions_enabled(),
536    );
537    fallow_output::build_audit_next_steps(&input)
538}
539
540fn print_audit_sarif(result: &AuditResult) -> ExitCode {
541    let check_sarif = result.check.as_ref().map(|check| {
542        report::api_sarif_document(&check.results, &check.config.root, &check.config.rules)
543    });
544    let health_sarif = result
545        .health
546        .as_ref()
547        .map(|health| report::api_health_sarif_document(&health.report, &health.config.root));
548    let combined = fallow_api::build_audit_sarif(AuditSarifOutputInput {
549        dead_code: check_sarif.as_ref(),
550        duplication: result.dupes.as_ref().map(|dupes| &dupes.report),
551        health: health_sarif.as_ref(),
552    });
553
554    report::emit_json(&combined, "SARIF audit")
555}
556
557fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
558    let value = build_audit_codeclimate(result);
559    report::emit_json(&value, "CodeClimate audit")
560}
561
562fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
563    fallow_api::build_audit_codeclimate(AuditCodeClimateOutputInput {
564        dead_code: result.check.as_ref().map_or_else(Vec::new, |check| {
565            fallow_api::build_codeclimate(&check.results, &check.config.root, &check.config.rules)
566        }),
567        duplication: result.dupes.as_ref().map_or_else(Vec::new, |dupes| {
568            fallow_api::build_duplication_codeclimate(&dupes.report, &dupes.config.root)
569        }),
570        health: result.health.as_ref().map_or_else(Vec::new, |health| {
571            fallow_api::build_health_codeclimate(&health.report, &health.config.root)
572        }),
573    })
574}
575
576#[cfg(test)]
577mod tests {
578    use std::process::ExitCode;
579    use std::time::Duration;
580
581    use fallow_config::{AuditGate, OutputFormat};
582
583    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
584
585    use super::{
586        build_audit_codeclimate, build_audit_json_output, build_status_parts,
587        build_vital_sign_parts, format_scope_line_parts, print_audit_result, short_base_ref,
588    };
589
590    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
591        AuditResult {
592            verdict,
593            summary: AuditSummary {
594                dead_code_issues: 0,
595                dead_code_has_errors: false,
596                complexity_findings: 0,
597                max_cyclomatic: None,
598                duplication_clone_groups: 0,
599            },
600            attribution: AuditAttribution {
601                gate: AuditGate::NewOnly,
602                ..AuditAttribution::default()
603            },
604            base_snapshot: None,
605            base_snapshot_skipped: false,
606            changed_files_count: 0,
607            changed_files: Vec::new(),
608            base_ref: "origin/main".to_string(),
609            base_description: None,
610            head_sha: None,
611            output,
612            performance: false,
613            check: None,
614            dupes: None,
615            health: None,
616            elapsed: Duration::ZERO,
617            review_deltas: None,
618            weakening_signals: Vec::new(),
619            routing: None,
620            decision_surface: None,
621            graph_snapshot_hash: None,
622            change_anchors: Vec::new(),
623        }
624    }
625
626    #[test]
627    fn short_base_ref_abbreviates_full_sha() {
628        assert_eq!(
629            short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
630            "611d151e8250"
631        );
632    }
633
634    #[test]
635    fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
636        assert_eq!(short_base_ref("main"), "main");
637        assert_eq!(short_base_ref("origin/main"), "origin/main");
638        assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
639        // Not 40 chars, so not treated as a SHA.
640        assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
641        // 40 chars but contains a non-hex character: left untouched.
642        assert_eq!(
643            short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
644            "611d151e8250146426ff3178e94207f8a8d3ccZZ"
645        );
646    }
647
648    #[test]
649    fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
650        assert_eq!(
651            format_scope_line_parts(
652                1,
653                "611d151e8250146426ff3178e94207f8a8d3cc7b",
654                Some("merge-base with origin/main"),
655                Some("HEADSHA")
656            ),
657            "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
658        );
659        assert_eq!(
660            format_scope_line_parts(3, "origin/main", None, None),
661            "Audit scope: 3 changed files vs origin/main"
662        );
663    }
664
665    #[test]
666    fn build_status_parts_describes_only_non_empty_categories() {
667        let summary = AuditSummary {
668            dead_code_issues: 1,
669            dead_code_has_errors: true,
670            complexity_findings: 2,
671            max_cyclomatic: Some(12),
672            duplication_clone_groups: 3,
673        };
674
675        assert_eq!(
676            build_status_parts(&summary),
677            vec![
678                "dead code: 1 issue".to_string(),
679                "complexity: 2 findings".to_string(),
680                "duplication: 3 clone groups".to_string(),
681            ]
682        );
683
684        let empty = AuditSummary {
685            dead_code_issues: 0,
686            dead_code_has_errors: false,
687            complexity_findings: 0,
688            max_cyclomatic: None,
689            duplication_clone_groups: 0,
690        };
691        assert!(build_status_parts(&empty).is_empty());
692    }
693
694    #[test]
695    fn build_vital_sign_parts_includes_warn_threshold_when_present() {
696        let summary = AuditSummary {
697            dead_code_issues: 0,
698            dead_code_has_errors: false,
699            complexity_findings: 2,
700            max_cyclomatic: Some(18),
701            duplication_clone_groups: 1,
702        };
703
704        assert_eq!(
705            build_vital_sign_parts(&summary),
706            vec![
707                "dead code 0".to_string(),
708                "complexity 2 (warn, max cyclomatic: 18)".to_string(),
709                "duplication 1".to_string(),
710            ]
711        );
712    }
713
714    #[test]
715    fn build_vital_sign_parts_omits_threshold_when_absent() {
716        let summary = AuditSummary {
717            dead_code_issues: 3,
718            dead_code_has_errors: false,
719            complexity_findings: 0,
720            max_cyclomatic: None,
721            duplication_clone_groups: 0,
722        };
723
724        assert_eq!(
725            build_vital_sign_parts(&summary),
726            vec![
727                "dead code 3".to_string(),
728                "complexity 0".to_string(),
729                "duplication 0".to_string(),
730            ]
731        );
732    }
733
734    #[test]
735    fn build_audit_codeclimate_returns_empty_issue_list_without_findings() {
736        let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
737
738        assert_eq!(build_audit_codeclimate(&result), serde_json::json!([]));
739    }
740
741    #[test]
742    fn print_audit_result_rejects_badge_format() {
743        let result = audit_result(AuditVerdict::Pass, OutputFormat::Badge);
744
745        assert_eq!(print_audit_result(&result, true, false), ExitCode::from(2));
746    }
747
748    #[test]
749    fn print_audit_result_maps_fail_verdict_to_error_exit() {
750        let result = audit_result(AuditVerdict::Fail, OutputFormat::Human);
751
752        assert_eq!(print_audit_result(&result, true, false), ExitCode::from(1));
753    }
754
755    fn audit_result_with_findings(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
756        let mut result = audit_result(verdict, output);
757        result.summary = AuditSummary {
758            dead_code_issues: 2,
759            dead_code_has_errors: true,
760            complexity_findings: 1,
761            max_cyclomatic: Some(14),
762            duplication_clone_groups: 3,
763        };
764        result.changed_files_count = 4;
765        result
766    }
767
768    #[test]
769    fn print_audit_json_emits_optional_header_fields() {
770        let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
771        result.base_description = Some("merge-base with origin/main".to_string());
772        result.head_sha = Some("abc123".to_string());
773        result.performance = true;
774        result.base_snapshot_skipped = true;
775        result.changed_files_count = 5;
776
777        // Pass verdict + successful JSON emit (no sub-results) maps to success;
778        // Exercises the typed audit header's optional base_description /
779        // head_sha / performance branches and the empty next-steps path.
780        assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
781    }
782
783    #[test]
784    fn build_audit_json_output_uses_typed_audit_contract() {
785        let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
786        result.base_description = Some("merge-base with origin/main".to_string());
787        result.head_sha = Some("abc123".to_string());
788        result.performance = true;
789        result.base_snapshot_skipped = true;
790        result.changed_files_count = 5;
791
792        let json = build_audit_json_output(&result).expect("audit JSON should build");
793
794        assert_eq!(json["kind"], "audit");
795        assert_eq!(json["command"], "audit");
796        assert_eq!(json["base_description"], "merge-base with origin/main");
797        assert_eq!(json["head_sha"], "abc123");
798        assert_eq!(json["base_snapshot_skipped"], true);
799        assert_eq!(json["changed_files_count"], 5);
800    }
801
802    #[test]
803    fn print_audit_result_renders_sarif_skeleton_without_findings() {
804        let result = audit_result(AuditVerdict::Pass, OutputFormat::Sarif);
805
806        assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
807    }
808
809    #[test]
810    fn print_audit_result_renders_codeclimate_without_findings() {
811        let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
812
813        assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
814    }
815
816    #[test]
817    fn print_audit_result_renders_pr_comment_for_both_providers() {
818        for format in [OutputFormat::PrCommentGithub, OutputFormat::PrCommentGitlab] {
819            let result = audit_result(AuditVerdict::Pass, format);
820            assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
821        }
822    }
823
824    #[test]
825    fn print_audit_result_renders_review_envelope_for_both_providers() {
826        for format in [OutputFormat::ReviewGithub, OutputFormat::ReviewGitlab] {
827            let result = audit_result(AuditVerdict::Pass, format);
828            assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
829        }
830    }
831
832    #[test]
833    fn print_audit_result_compact_and_markdown_use_human_path() {
834        for format in [OutputFormat::Compact, OutputFormat::Markdown] {
835            let result = audit_result(AuditVerdict::Pass, format);
836            assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
837        }
838    }
839
840    #[test]
841    fn print_audit_result_human_pass_renders_scope_and_status_line() {
842        let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Human);
843        result.changed_files_count = 2;
844
845        // quiet=false drives the scope line + the green "no issues" status line.
846        assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
847    }
848
849    #[test]
850    fn print_audit_result_human_warn_renders_vital_signs_and_notes() {
851        let mut result = audit_result_with_findings(AuditVerdict::Warn, OutputFormat::Human);
852        result.attribution = AuditAttribution {
853            gate: AuditGate::NewOnly,
854            dead_code_inherited: 2,
855            complexity_inherited: 1,
856            duplication_inherited: 0,
857            ..AuditAttribution::default()
858        };
859        result.performance = true;
860
861        // Warn + findings (without sub-results) covers the explain tip, vital
862        // signs, the gate-excluded inherited note, and the performance note.
863        assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
864    }
865
866    #[test]
867    fn print_audit_result_human_fail_renders_red_status_line() {
868        let result = audit_result_with_findings(AuditVerdict::Fail, OutputFormat::Human);
869
870        // Fail maps to exit 1 and renders the red status line via build_status_parts.
871        assert_eq!(print_audit_result(&result, false, false), ExitCode::from(1));
872    }
873}