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