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, RulesConfig, Severity};
10use fallow_output::{
11    AuditDisplayGate, AuditDisplaySeverity, AuditStylingContextLabelInput, PrDecisionConclusion,
12};
13use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
14use fallow_types::results::AnalysisResults;
15
16use crate::error::emit_error;
17use crate::report;
18use crate::report::plural;
19use crate::report::sink::outln;
20
21use super::keys::{
22    annotate_dead_code_json, annotate_dupes_json, annotate_health_json, styling_finding_key,
23};
24use super::{AuditResult, AuditSummary, AuditVerdict};
25
26/// Print audit results and return the appropriate exit code.
27#[must_use]
28pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
29    let format_exit = print_audit_format(result, quiet, explain);
30
31    if format_exit != ExitCode::SUCCESS {
32        return format_exit;
33    }
34
35    match result.verdict {
36        AuditVerdict::Fail => ExitCode::from(1),
37        AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
38    }
39}
40
41fn audit_decision_conclusion(verdict: AuditVerdict) -> PrDecisionConclusion {
42    match verdict {
43        AuditVerdict::Pass => PrDecisionConclusion::Success,
44        AuditVerdict::Warn => PrDecisionConclusion::Neutral,
45        AuditVerdict::Fail => PrDecisionConclusion::Failure,
46    }
47}
48
49fn print_audit_format(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
50    match result.output {
51        OutputFormat::Json => print_audit_json(result),
52        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
53            print_audit_human(result, quiet, explain, result.output);
54            ExitCode::SUCCESS
55        }
56        OutputFormat::Sarif => print_audit_sarif(result),
57        OutputFormat::CodeClimate => print_audit_codeclimate(result),
58        OutputFormat::PrCommentGithub => {
59            print_audit_pr_comment(result, report::ci::pr_comment::Provider::Github)
60        }
61        OutputFormat::PrCommentGitlab => {
62            print_audit_pr_comment(result, report::ci::pr_comment::Provider::Gitlab)
63        }
64        OutputFormat::ReviewGithub => {
65            print_audit_review(result, report::ci::pr_comment::Provider::Github)
66        }
67        OutputFormat::ReviewGitlab => {
68            print_audit_review(result, report::ci::pr_comment::Provider::Gitlab)
69        }
70        OutputFormat::Badge => {
71            eprintln!("Error: badge format is not supported for the audit command");
72            ExitCode::from(2)
73        }
74    }
75}
76
77fn print_audit_pr_comment(
78    result: &AuditResult,
79    provider: report::ci::pr_comment::Provider,
80) -> ExitCode {
81    let value = build_audit_codeclimate(result);
82    report::ci::pr_comment::print_pr_comment_with_conclusion(
83        "audit",
84        provider,
85        &value,
86        audit_decision_conclusion(result.verdict),
87    )
88}
89
90fn print_audit_review(
91    result: &AuditResult,
92    provider: report::ci::pr_comment::Provider,
93) -> ExitCode {
94    let value = build_audit_codeclimate(result);
95    report::ci::review::print_review_envelope("audit", provider, &value)
96}
97
98fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
99    let show_headers = matches!(output, OutputFormat::Human) && !quiet;
100
101    if !quiet {
102        let scope = format_scope_line(result);
103        eprintln!();
104        eprintln!("{scope}");
105    }
106
107    let has_check_issues = result.summary.dead_code_issues > 0;
108    let has_health_findings = result.summary.complexity_findings > 0;
109    let has_dupe_groups = result.summary.duplication_clone_groups > 0;
110    // Styling is verdict-neutral but must still surface when it is the ONLY
111    // signal (a project clean of dead-code/complexity/dupes but with styling
112    // candidates), else the styling summary is invisible on the common case.
113    let has_styling = result.health.as_ref().is_some_and(|h| {
114        !h.report.styling_findings.is_empty()
115            || h.report
116                .css_analytics
117                .as_ref()
118                .is_some_and(|c| fallow_output::styling_candidate_count(&c.summary) > 0)
119    });
120
121    if has_check_issues || has_health_findings || has_dupe_groups || has_styling {
122        print_audit_findings(result, quiet, explain, show_headers);
123    }
124
125    if !has_dupe_groups && let Some(ref dupes) = result.dupes {
126        crate::dupes::print_default_ignore_note(dupes, quiet);
127        crate::dupes::print_min_occurrences_note(dupes, quiet);
128    }
129
130    if !quiet {
131        print_audit_status_line(result);
132    }
133}
134
135/// Print the per-analysis findings sections (dead code, duplication, complexity)
136/// plus the explain tip and vital signs, with section headers when enabled.
137pub fn print_audit_findings(result: &AuditResult, quiet: bool, explain: bool, show_headers: bool) {
138    print_audit_explain_tip(show_headers);
139
140    if result.verdict != AuditVerdict::Fail && !quiet {
141        print_audit_vital_signs(result);
142    }
143
144    if result.summary.dead_code_issues > 0
145        && let Some(ref check) = result.check
146    {
147        print_audit_dead_code_section(check, quiet, explain, show_headers);
148    }
149
150    if result.summary.duplication_clone_groups > 0
151        && let Some(ref dupes) = result.dupes
152    {
153        print_audit_duplication_section(dupes, quiet, explain, show_headers);
154    }
155
156    if result.summary.complexity_findings > 0
157        && let Some(ref health) = result.health
158    {
159        print_audit_complexity_section(health, quiet, explain, show_headers);
160    }
161
162    print_audit_styling_summary(result, show_headers);
163}
164
165fn print_audit_dead_code_section(
166    check: &crate::check::CheckResult,
167    quiet: bool,
168    explain: bool,
169    show_headers: bool,
170) {
171    print_audit_section_header(
172        show_headers,
173        "── Dead Code ──────────────────────────────────────",
174    );
175    crate::check::print_check_result(
176        check,
177        crate::check::PrintCheckOptions {
178            quiet,
179            explain,
180            regression_json: false,
181            group_by: None,
182            top: None,
183            summary: false,
184            summary_heading: true,
185            show_explain_tip: false,
186        },
187    );
188}
189
190fn print_audit_duplication_section(
191    dupes: &crate::dupes::DupesResult,
192    quiet: bool,
193    explain: bool,
194    show_headers: bool,
195) {
196    print_audit_section_header(
197        show_headers,
198        "── Duplication ────────────────────────────────────",
199    );
200    crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
201}
202
203fn print_audit_complexity_section(
204    health: &crate::health::HealthResult,
205    quiet: bool,
206    explain: bool,
207    show_headers: bool,
208) {
209    print_audit_section_header(
210        show_headers,
211        "── Complexity ─────────────────────────────────────",
212    );
213    crate::health::print_health_result(
214        health,
215        crate::health::HealthPrintOptions {
216            quiet,
217            explain,
218            gates: fallow_engine::health::HealthGateOptions::default(),
219            summary: false,
220            summary_heading: true,
221            show_explain_tip: false,
222            skip_score_and_trend: false,
223            css_requested: false,
224        },
225    );
226}
227
228/// Styling section in the audit view: the graduated, agent-actionable styling
229/// FINDINGS (top-N per the noise budget), falling back to a candidate count for
230/// the not-yet-graduated descriptive candidates. Verdict-neutral; deliberately NOT
231/// the A-F styling grade (that stays in `fallow health` for trending, per plan).
232fn print_audit_styling_summary(result: &AuditResult, show_headers: bool) {
233    /// Noise budget: findings shown per the audit view (the rest via `--css`).
234    const FIX_CONFIDENTLY_TOP_N: usize = 5;
235    const VERIFY_FIRST_TOP_N: usize = 3;
236    let Some(ref health) = result.health else {
237        return;
238    };
239    let findings = &health.report.styling_findings;
240    let descriptive = health.report.css_analytics.as_ref().map_or(0, |css| {
241        fallow_output::styling_candidate_count(&css.summary)
242    });
243    if findings.is_empty() && descriptive == 0 {
244        return;
245    }
246    print_audit_section_header(
247        show_headers,
248        "── Styling ────────────────────────────────────────",
249    );
250    if findings.is_empty() {
251        // Only not-yet-graduated descriptive candidates (dead surface, etc.).
252        let noun = if descriptive == 1 {
253            "candidate"
254        } else {
255            "candidates"
256        };
257        outln!(
258            "  {descriptive} styling {noun} {}",
259            "(run `fallow health --css` for detail)".dimmed()
260        );
261        return;
262    }
263    let rules = &health.config.rules;
264    let groups = build_audit_styling_groups(rules, findings);
265    let show_group_labels = !groups.fix_confidently.is_empty() && !groups.verify_first.is_empty();
266    print_audit_styling_group(
267        result,
268        rules,
269        "Fix confidently",
270        &groups.fix_confidently,
271        FIX_CONFIDENTLY_TOP_N.max(groups.gated_count),
272        show_group_labels,
273    );
274    print_audit_styling_group(
275        result,
276        rules,
277        "Verify first",
278        &groups.verify_first,
279        VERIFY_FIRST_TOP_N.max(groups.gated_count),
280        show_group_labels,
281    );
282    outln!(
283        "  {}",
284        "(run `fallow audit --format json` for full styling detail)".dimmed()
285    );
286}
287
288struct AuditStylingGroups<'a> {
289    gated_count: usize,
290    fix_confidently: Vec<&'a fallow_output::StylingFinding>,
291    verify_first: Vec<&'a fallow_output::StylingFinding>,
292}
293
294fn build_audit_styling_groups<'a>(
295    rules: &RulesConfig,
296    findings: &'a [fallow_output::StylingFinding],
297) -> AuditStylingGroups<'a> {
298    let mut sorted: Vec<_> = findings.iter().collect();
299    sort_audit_styling_findings(rules, &mut sorted);
300    let gated_count = sorted
301        .iter()
302        .filter(|finding| styling_finding_is_error_gated(rules, &finding.code))
303        .count();
304    let fix_confidently = sorted
305        .iter()
306        .copied()
307        .filter(|finding| styling_finding_is_fix_confidently(finding))
308        .collect();
309    let verify_first = sorted
310        .iter()
311        .copied()
312        .filter(|finding| !styling_finding_is_fix_confidently(finding))
313        .collect();
314
315    AuditStylingGroups {
316        gated_count,
317        fix_confidently,
318        verify_first,
319    }
320}
321
322fn sort_audit_styling_findings(
323    rules: &RulesConfig,
324    findings: &mut [&fallow_output::StylingFinding],
325) {
326    findings.sort_by(|a, b| {
327        styling_finding_is_error_gated(rules, &b.code)
328            .cmp(&styling_finding_is_error_gated(rules, &a.code))
329            .then_with(|| a.path.cmp(&b.path))
330            .then_with(|| a.line.cmp(&b.line))
331            .then_with(|| a.code.cmp(&b.code))
332            .then_with(|| a.value.cmp(&b.value))
333    });
334}
335
336fn print_audit_styling_group(
337    result: &AuditResult,
338    rules: &RulesConfig,
339    label: &str,
340    findings: &[&fallow_output::StylingFinding],
341    top_n: usize,
342    show_label: bool,
343) {
344    if findings.is_empty() {
345        return;
346    }
347    if show_label {
348        outln!("  {}", label.bold());
349    }
350    let gated_count = findings
351        .iter()
352        .filter(|finding| styling_finding_is_error_gated(rules, &finding.code))
353        .count();
354    let visible_count = top_n.max(gated_count);
355    let indent = if show_label { "    " } else { "  " };
356    for finding in findings.iter().take(visible_count) {
357        outln!(
358            "{}{}  {}  {}  {}",
359            indent,
360            format!("{}:{}", finding.path, finding.line).dimmed(),
361            finding.code,
362            finding.value,
363            styling_finding_audit_context(result, finding).dimmed()
364        );
365    }
366    let hidden = findings.len().saturating_sub(visible_count);
367    if hidden > 0 {
368        let noun = if hidden == 1 { "finding" } else { "findings" };
369        outln!(
370            "{}{}",
371            indent,
372            format!("+ {hidden} more styling {noun}").dimmed()
373        );
374    }
375}
376
377fn styling_finding_is_fix_confidently(finding: &fallow_output::StylingFinding) -> bool {
378    matches!(
379        finding.agent_disposition,
380        Some(fallow_output::StylingAgentDisposition::FixConfidently)
381    ) || matches!(
382        finding.confidence,
383        Some(fallow_output::StylingFindingConfidence::High)
384    )
385}
386
387fn styling_finding_is_error_gated(rules: &RulesConfig, code: &str) -> bool {
388    let (_, severity) = styling_finding_rule_context(rules, code);
389    severity == Severity::Error
390}
391
392fn styling_finding_rule_context(rules: &RulesConfig, code: &str) -> (String, Severity) {
393    let severity = match code {
394        "css-token-drift" => rules.css_token_drift,
395        "css-duplicate-block" => rules.css_duplicate_block,
396        "css-selector-complexity" => rules.css_selector_complexity,
397        "css-dead-surface" => rules.css_dead_surface,
398        "css-broken-reference" => rules.css_broken_reference,
399        _ => Severity::Warn,
400    };
401    (format!("rules.{code}"), severity)
402}
403
404fn styling_finding_audit_context(
405    result: &AuditResult,
406    finding: &fallow_output::StylingFinding,
407) -> String {
408    let Some(health) = result.health.as_ref() else {
409        let (rule, severity) = styling_finding_rule_context(&RulesConfig::default(), &finding.code);
410        return styling_finding_audit_context_label(severity, &rule, None, result.attribution.gate);
411    };
412    let (rule, severity) = styling_finding_rule_context(&health.config.rules, &finding.code);
413    let base_state = result.base_snapshot.as_ref().map(|snapshot| {
414        let key = styling_finding_key(finding, &health.config.root);
415        if snapshot.styling.contains(&key) {
416            format!(
417                "inherited styling debt from {}",
418                short_base_ref(&result.base_ref)
419            )
420        } else {
421            format!(
422                "introduced design-system drift since {}",
423                short_base_ref(&result.base_ref)
424            )
425        }
426    });
427    styling_finding_audit_context_label(
428        severity,
429        &rule,
430        base_state.as_deref(),
431        result.attribution.gate,
432    )
433}
434
435fn styling_finding_audit_context_label(
436    severity: Severity,
437    rule: &str,
438    base_state: Option<&str>,
439    gate: AuditGate,
440) -> String {
441    fallow_output::styling_audit_context_label(AuditStylingContextLabelInput {
442        severity: audit_display_severity(severity),
443        rule,
444        base_state,
445        gate: audit_display_gate(gate),
446    })
447}
448
449fn audit_display_severity(severity: Severity) -> AuditDisplaySeverity {
450    match severity {
451        Severity::Off => AuditDisplaySeverity::Off,
452        Severity::Warn => AuditDisplaySeverity::Warn,
453        Severity::Error => AuditDisplaySeverity::Error,
454    }
455}
456
457fn audit_display_gate(gate: AuditGate) -> AuditDisplayGate {
458    match gate {
459        AuditGate::NewOnly => AuditDisplayGate::NewOnly,
460        AuditGate::All => AuditDisplayGate::All,
461    }
462}
463
464/// Print the TTY-only explain tip above the findings sections.
465fn print_audit_explain_tip(show_headers: bool) {
466    if show_headers && std::io::stdout().is_terminal() && !crate::report::sink::is_redirected() {
467        println!(
468            "{}",
469            "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
470                .dimmed()
471        );
472        println!();
473    }
474}
475
476/// Emit a blank line followed by a section header when headers are enabled.
477fn print_audit_section_header(show_headers: bool, header: &str) {
478    if show_headers {
479        eprintln!();
480        eprintln!("{header}");
481    }
482}
483
484/// Abbreviate a 40-char hex SHA to 12 chars for display; leave anything else
485/// (branch names, refspecs, the literal user typed for `--base`) untouched.
486fn short_base_ref(base_ref: &str) -> &str {
487    if base_ref.len() == 40 && base_ref.bytes().all(|b| b.is_ascii_hexdigit()) {
488        &base_ref[..12]
489    } else {
490        base_ref
491    }
492}
493
494/// Format the scope context line. When the base ref was auto-detected (or set
495/// via `FALLOW_AUDIT_BASE`), append the provenance so the comparison target is
496/// checkable, e.g. `vs a1b2c3d4e5f6 (merge-base with origin/main)`.
497fn format_scope_line(result: &AuditResult) -> String {
498    format_scope_line_parts(
499        result.changed_files_count,
500        &result.base_ref,
501        result.base_description.as_deref(),
502        result.head_sha.as_deref(),
503    )
504}
505
506fn format_scope_line_parts(
507    changed_files_count: usize,
508    base_ref: &str,
509    base_description: Option<&str>,
510    head_sha: Option<&str>,
511) -> String {
512    let sha_suffix = head_sha.map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
513    let base_display = match base_description {
514        Some(description) => format!("{} ({description})", short_base_ref(base_ref)),
515        None => base_ref.to_string(),
516    };
517    format!(
518        "Audit scope: {} changed file{} vs {}{}",
519        changed_files_count,
520        plural(changed_files_count),
521        base_display,
522        sha_suffix
523    )
524}
525
526/// Print a dimmed vital-signs line summarizing warn-only findings.
527fn print_audit_vital_signs(result: &AuditResult) {
528    let line = build_vital_sign_parts(&result.summary).join(" \u{00b7} ");
529    outln!(
530        "{} {} {}",
531        "\u{25a0}".dimmed(),
532        "Metrics:".dimmed(),
533        line.dimmed()
534    );
535}
536
537fn build_vital_sign_parts(summary: &AuditSummary) -> Vec<String> {
538    let mut parts = Vec::new();
539    parts.push(format!("dead code {}", summary.dead_code_issues));
540    if let Some(max) = summary.max_cyclomatic {
541        parts.push(format!(
542            "complexity {} (warn, max cyclomatic: {max})",
543            summary.complexity_findings
544        ));
545    } else {
546        parts.push(format!("complexity {}", summary.complexity_findings));
547    }
548    parts.push(format!("duplication {}", summary.duplication_clone_groups));
549    parts
550}
551
552/// Build summary parts for the status line (shared between warn and fail).
553fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
554    let mut parts = Vec::new();
555    if summary.dead_code_issues > 0 {
556        let n = summary.dead_code_issues;
557        parts.push(format!("dead code: {n} issue{}", plural(n)));
558    }
559    if summary.complexity_findings > 0 {
560        let n = summary.complexity_findings;
561        parts.push(format!("complexity: {n} finding{}", plural(n)));
562    }
563    if summary.duplication_clone_groups > 0 {
564        let n = summary.duplication_clone_groups;
565        parts.push(format!("duplication: {n} clone group{}", plural(n)));
566    }
567    parts
568}
569
570/// Print the final status line on stderr.
571fn print_audit_status_line(result: &AuditResult) {
572    let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
573    let n = result.changed_files_count;
574    let files_str = format!("{n} changed file{}", plural(n));
575
576    match result.verdict {
577        AuditVerdict::Pass => {
578            eprintln!(
579                "{}",
580                format!("\u{2713} No issues in {files_str} ({elapsed_str})")
581                    .green()
582                    .bold()
583            );
584        }
585        AuditVerdict::Warn => {
586            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
587            eprintln!(
588                "{}",
589                format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
590                    .green()
591                    .bold()
592            );
593        }
594        AuditVerdict::Fail => {
595            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
596            eprintln!(
597                "{}",
598                format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
599                    .red()
600                    .bold()
601            );
602        }
603    }
604
605    if !matches!(result.attribution.gate, AuditGate::All) {
606        let inherited = result.attribution.dead_code_inherited
607            + result.attribution.complexity_inherited
608            + result.attribution.duplication_inherited;
609        if inherited > 0 {
610            eprintln!(
611                "  {}",
612                format!(
613                    "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
614                    plural(inherited)
615                )
616                .dimmed()
617            );
618        }
619    }
620    if result.performance {
621        eprintln!(
622            "  {}",
623            format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
624        );
625    }
626}
627
628fn print_audit_json(result: &AuditResult) -> ExitCode {
629    let output = match build_audit_json_output(result) {
630        Ok(output) => output,
631        Err(code) => return code,
632    };
633    report::emit_json(&output, "audit")
634}
635
636fn build_audit_json_output(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
637    let mut check_results = result.check.as_ref().map(|check| check.results.clone());
638    let mut health_report = result.health.as_ref().map(|health| health.report.clone());
639    fallow_output::harmonize_dead_code_health_suppress_line_actions(
640        check_results.as_mut(),
641        health_report.as_mut(),
642    );
643
644    let dead_code = match (result.check.as_ref(), check_results.as_ref()) {
645        (Some(check), Some(results)) => Some(build_audit_dead_code_json_with_results(
646            result, check, results,
647        )?),
648        _ => None,
649    };
650    let duplication = result
651        .dupes
652        .as_ref()
653        .map(|dupes| build_audit_duplication_json(result, dupes))
654        .transpose()?;
655    let complexity = match (result.health.as_ref(), health_report.as_ref()) {
656        (Some(health), Some(report)) => {
657            Some(build_audit_health_json_with_report(result, health, report)?)
658        }
659        _ => None,
660    };
661
662    fallow_api::serialize_audit_json(
663        AuditJsonOutputInput {
664            header: audit_json_header_input(result),
665            dead_code,
666            duplication,
667            complexity,
668            next_steps: audit_next_steps(result),
669        },
670        crate::output_runtime::current_root_envelope_mode(),
671        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
672    )
673    .map_err(|err| {
674        emit_error(
675            &format!("JSON serialization error: {err}"),
676            2,
677            OutputFormat::Json,
678        )
679    })
680}
681
682fn elapsed_ms_for_output(elapsed: std::time::Duration) -> u64 {
683    u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
684}
685
686fn changed_files_count_for_output(changed_files_count: usize) -> u32 {
687    u32::try_from(changed_files_count).unwrap_or(u32::MAX)
688}
689
690pub fn audit_json_header_input(result: &AuditResult) -> AuditJsonHeaderInput {
691    AuditJsonHeaderInput {
692        schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
693        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
694        verdict: result.verdict,
695        changed_files_count: changed_files_count_for_output(result.changed_files_count),
696        base_ref: result.base_ref.clone(),
697        base_description: result.base_description.clone(),
698        head_sha: result.head_sha.clone(),
699        elapsed_ms: ElapsedMs(elapsed_ms_for_output(result.elapsed)),
700        base_snapshot_skipped: result.performance.then_some(result.base_snapshot_skipped),
701        summary: result.summary.clone(),
702        attribution: result.attribution.clone(),
703    }
704}
705
706pub fn insert_audit_dead_code_json(
707    obj: &mut serde_json::Map<String, serde_json::Value>,
708    result: &AuditResult,
709    check: &crate::check::CheckResult,
710) -> Result<(), ExitCode> {
711    let json = build_audit_dead_code_json(result, check)?;
712    obj.insert("dead_code".into(), json);
713    Ok(())
714}
715
716fn build_audit_dead_code_json(
717    result: &AuditResult,
718    check: &crate::check::CheckResult,
719) -> Result<serde_json::Value, ExitCode> {
720    build_audit_dead_code_json_with_results(result, check, &check.results)
721}
722
723fn build_audit_dead_code_json_with_results(
724    result: &AuditResult,
725    check: &crate::check::CheckResult,
726    results: &AnalysisResults,
727) -> Result<serde_json::Value, ExitCode> {
728    match report::api_check_json_payload_with_config_fixable(
729        results,
730        &check.config.root,
731        check.elapsed,
732        check.config_fixable,
733    ) {
734        Ok(mut json) => {
735            if let Some(ref base) = result.base_snapshot {
736                annotate_dead_code_json(&mut json, results, &check.config.root, &base.dead_code);
737            }
738            Ok(json)
739        }
740        Err(e) => Err(emit_error(
741            &format!("JSON serialization error: {e}"),
742            2,
743            OutputFormat::Json,
744        )),
745    }
746}
747
748pub fn insert_audit_duplication_json(
749    obj: &mut serde_json::Map<String, serde_json::Value>,
750    result: &AuditResult,
751    dupes: &crate::dupes::DupesResult,
752) -> Result<(), ExitCode> {
753    let json = build_audit_duplication_json(result, dupes)?;
754    obj.insert("duplication".into(), json);
755    Ok(())
756}
757
758fn build_audit_duplication_json(
759    result: &AuditResult,
760    dupes: &crate::dupes::DupesResult,
761) -> Result<serde_json::Value, ExitCode> {
762    let payload = DupesReportPayload::from_report(&dupes.report);
763    match serde_json::to_value(&payload) {
764        Ok(mut json) => {
765            let root_prefix = format!("{}/", dupes.config.root.display());
766            report::strip_root_prefix(&mut json, &root_prefix);
767            if let Some(ref base) = result.base_snapshot {
768                annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
769            }
770            Ok(json)
771        }
772        Err(e) => Err(emit_error(
773            &format!("JSON serialization error: {e}"),
774            2,
775            OutputFormat::Json,
776        )),
777    }
778}
779
780pub fn insert_audit_health_json(
781    obj: &mut serde_json::Map<String, serde_json::Value>,
782    result: &AuditResult,
783    health: &crate::health::HealthResult,
784) -> Result<(), ExitCode> {
785    let json = build_audit_health_json(result, health)?;
786    obj.insert("complexity".into(), json);
787    Ok(())
788}
789
790fn build_audit_health_json(
791    result: &AuditResult,
792    health: &crate::health::HealthResult,
793) -> Result<serde_json::Value, ExitCode> {
794    build_audit_health_json_with_report(result, health, &health.report)
795}
796
797fn build_audit_health_json_with_report(
798    result: &AuditResult,
799    health: &crate::health::HealthResult,
800    report: &fallow_output::HealthReport,
801) -> Result<serde_json::Value, ExitCode> {
802    match serde_json::to_value(report) {
803        Ok(mut json) => {
804            let root_prefix = format!("{}/", health.config.root.display());
805            report::strip_root_prefix(&mut json, &root_prefix);
806            if let Some(ref base) = result.base_snapshot {
807                annotate_health_json(&mut json, report, &health.config.root, &base.health);
808            }
809            Ok(json)
810        }
811        Err(e) => Err(emit_error(
812            &format!("JSON serialization error: {e}"),
813            2,
814            OutputFormat::Json,
815        )),
816    }
817}
818
819fn audit_next_steps(result: &AuditResult) -> Vec<fallow_types::output::NextStep> {
820    let input = fallow_output::build_audit_next_steps_input(
821        result
822            .check
823            .as_ref()
824            .map(|check| (&check.results, check.config.root.as_path())),
825        result.health.as_ref().map(|health| &health.report),
826        crate::report::suggestions::suggestions_enabled(),
827    );
828    fallow_output::build_audit_next_steps(&input)
829}
830
831fn print_audit_sarif(result: &AuditResult) -> ExitCode {
832    let check_sarif = result.check.as_ref().map(|check| {
833        report::api_sarif_document(&check.results, &check.config.root, &check.config.rules)
834    });
835    let health_sarif = result
836        .health
837        .as_ref()
838        .map(|health| report::api_health_sarif_document(&health.report, &health.config.root));
839    let combined = fallow_api::build_audit_sarif(AuditSarifOutputInput {
840        dead_code: check_sarif.as_ref(),
841        duplication: result.dupes.as_ref().map(|dupes| &dupes.report),
842        health: health_sarif.as_ref(),
843    });
844
845    report::emit_json(&combined, "SARIF audit")
846}
847
848fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
849    let value = build_audit_codeclimate(result);
850    report::emit_json(&value, "CodeClimate audit")
851}
852
853fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
854    fallow_api::build_audit_codeclimate(AuditCodeClimateOutputInput {
855        dead_code: result.check.as_ref().map_or_else(Vec::new, |check| {
856            fallow_api::build_codeclimate(&check.results, &check.config.root, &check.config.rules)
857        }),
858        duplication: result.dupes.as_ref().map_or_else(Vec::new, |dupes| {
859            fallow_api::build_duplication_codeclimate(&dupes.report, &dupes.config.root)
860        }),
861        health: result.health.as_ref().map_or_else(Vec::new, |health| {
862            fallow_api::build_health_codeclimate(&health.report, &health.config.root)
863        }),
864    })
865}
866
867#[cfg(test)]
868mod tests {
869    use std::process::ExitCode;
870    use std::time::Duration;
871
872    use fallow_config::{AuditGate, OutputFormat};
873    use fallow_output::PrDecisionConclusion;
874
875    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
876
877    use super::{
878        audit_decision_conclusion, build_audit_codeclimate, build_audit_json_output,
879        build_status_parts, build_vital_sign_parts, format_scope_line_parts, print_audit_result,
880        short_base_ref, styling_finding_audit_context_label,
881    };
882
883    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
884        AuditResult {
885            verdict,
886            summary: AuditSummary {
887                dead_code_issues: 0,
888                dead_code_has_errors: false,
889                complexity_findings: 0,
890                max_cyclomatic: None,
891                duplication_clone_groups: 0,
892            },
893            attribution: AuditAttribution {
894                gate: AuditGate::NewOnly,
895                ..AuditAttribution::default()
896            },
897            base_snapshot: None,
898            base_snapshot_skipped: false,
899            changed_files_count: 0,
900            changed_files: Vec::new(),
901            base_ref: "origin/main".to_string(),
902            base_description: None,
903            head_sha: None,
904            output,
905            performance: false,
906            check: None,
907            dupes: None,
908            health: None,
909            elapsed: Duration::ZERO,
910            review_deltas: None,
911            weakening_signals: Vec::new(),
912            routing: None,
913            decision_surface: None,
914            graph_snapshot_hash: None,
915            change_anchors: Vec::new(),
916        }
917    }
918
919    #[test]
920    fn short_base_ref_abbreviates_full_sha() {
921        assert_eq!(
922            short_base_ref("611d151e8250146426ff3178e94207f8a8d3cc7b"),
923            "611d151e8250"
924        );
925    }
926
927    #[test]
928    fn short_base_ref_leaves_branch_names_and_refspecs_untouched() {
929        assert_eq!(short_base_ref("main"), "main");
930        assert_eq!(short_base_ref("origin/main"), "origin/main");
931        assert_eq!(short_base_ref("HEAD~5"), "HEAD~5");
932        // Not 40 chars, so not treated as a SHA.
933        assert_eq!(short_base_ref("611d151e8250"), "611d151e8250");
934        // 40 chars but contains a non-hex character: left untouched.
935        assert_eq!(
936            short_base_ref("611d151e8250146426ff3178e94207f8a8d3ccZZ"),
937            "611d151e8250146426ff3178e94207f8a8d3ccZZ"
938        );
939    }
940
941    #[test]
942    fn format_scope_line_parts_uses_plural_ref_provenance_and_head_sha() {
943        assert_eq!(
944            format_scope_line_parts(
945                1,
946                "611d151e8250146426ff3178e94207f8a8d3cc7b",
947                Some("merge-base with origin/main"),
948                Some("HEADSHA")
949            ),
950            "Audit scope: 1 changed file vs 611d151e8250 (merge-base with origin/main) (HEADSHA..HEAD)"
951        );
952        assert_eq!(
953            format_scope_line_parts(3, "origin/main", None, None),
954            "Audit scope: 3 changed files vs origin/main"
955        );
956    }
957
958    #[test]
959    fn styling_finding_audit_context_label_explains_gate_state() {
960        assert_eq!(
961            styling_finding_audit_context_label(
962                fallow_config::Severity::Error,
963                "rules.css-selector-complexity",
964                Some("introduced design-system drift since HEAD"),
965                AuditGate::NewOnly,
966            ),
967            "(gated: rules.css-selector-complexity=error, introduced design-system drift since HEAD)"
968        );
969        assert_eq!(
970            styling_finding_audit_context_label(
971                fallow_config::Severity::Error,
972                "rules.css-selector-complexity",
973                Some("inherited styling debt from HEAD"),
974                AuditGate::NewOnly,
975            ),
976            "(not gated: rules.css-selector-complexity=error, inherited styling debt from HEAD)"
977        );
978        assert_eq!(
979            styling_finding_audit_context_label(
980                fallow_config::Severity::Warn,
981                "rules.css-selector-complexity",
982                None,
983                AuditGate::All,
984            ),
985            "(advisory: rules.css-selector-complexity=warn)"
986        );
987    }
988
989    #[test]
990    fn build_status_parts_describes_only_non_empty_categories() {
991        let summary = AuditSummary {
992            dead_code_issues: 1,
993            dead_code_has_errors: true,
994            complexity_findings: 2,
995            max_cyclomatic: Some(12),
996            duplication_clone_groups: 3,
997        };
998
999        assert_eq!(
1000            build_status_parts(&summary),
1001            vec![
1002                "dead code: 1 issue".to_string(),
1003                "complexity: 2 findings".to_string(),
1004                "duplication: 3 clone groups".to_string(),
1005            ]
1006        );
1007
1008        let empty = AuditSummary {
1009            dead_code_issues: 0,
1010            dead_code_has_errors: false,
1011            complexity_findings: 0,
1012            max_cyclomatic: None,
1013            duplication_clone_groups: 0,
1014        };
1015        assert!(build_status_parts(&empty).is_empty());
1016    }
1017
1018    #[test]
1019    fn build_vital_sign_parts_includes_warn_threshold_when_present() {
1020        let summary = AuditSummary {
1021            dead_code_issues: 0,
1022            dead_code_has_errors: false,
1023            complexity_findings: 2,
1024            max_cyclomatic: Some(18),
1025            duplication_clone_groups: 1,
1026        };
1027
1028        assert_eq!(
1029            build_vital_sign_parts(&summary),
1030            vec![
1031                "dead code 0".to_string(),
1032                "complexity 2 (warn, max cyclomatic: 18)".to_string(),
1033                "duplication 1".to_string(),
1034            ]
1035        );
1036    }
1037
1038    #[test]
1039    fn build_vital_sign_parts_omits_threshold_when_absent() {
1040        let summary = AuditSummary {
1041            dead_code_issues: 3,
1042            dead_code_has_errors: false,
1043            complexity_findings: 0,
1044            max_cyclomatic: None,
1045            duplication_clone_groups: 0,
1046        };
1047
1048        assert_eq!(
1049            build_vital_sign_parts(&summary),
1050            vec![
1051                "dead code 3".to_string(),
1052                "complexity 0".to_string(),
1053                "duplication 0".to_string(),
1054            ]
1055        );
1056    }
1057
1058    #[test]
1059    fn build_audit_codeclimate_returns_empty_issue_list_without_findings() {
1060        let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
1061
1062        assert_eq!(build_audit_codeclimate(&result), serde_json::json!([]));
1063    }
1064
1065    #[test]
1066    fn print_audit_result_rejects_badge_format() {
1067        let result = audit_result(AuditVerdict::Pass, OutputFormat::Badge);
1068
1069        assert_eq!(print_audit_result(&result, true, false), ExitCode::from(2));
1070    }
1071
1072    #[test]
1073    fn print_audit_result_maps_fail_verdict_to_error_exit() {
1074        let result = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1075
1076        assert_eq!(print_audit_result(&result, true, false), ExitCode::from(1));
1077    }
1078
1079    #[test]
1080    fn audit_verdict_maps_to_pr_decision_conclusion() {
1081        assert_eq!(
1082            audit_decision_conclusion(AuditVerdict::Pass),
1083            PrDecisionConclusion::Success
1084        );
1085        assert_eq!(
1086            audit_decision_conclusion(AuditVerdict::Warn),
1087            PrDecisionConclusion::Neutral
1088        );
1089        assert_eq!(
1090            audit_decision_conclusion(AuditVerdict::Fail),
1091            PrDecisionConclusion::Failure
1092        );
1093    }
1094
1095    fn audit_result_with_findings(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1096        let mut result = audit_result(verdict, output);
1097        result.summary = AuditSummary {
1098            dead_code_issues: 2,
1099            dead_code_has_errors: true,
1100            complexity_findings: 1,
1101            max_cyclomatic: Some(14),
1102            duplication_clone_groups: 3,
1103        };
1104        result.changed_files_count = 4;
1105        result
1106    }
1107
1108    #[test]
1109    fn print_audit_json_emits_optional_header_fields() {
1110        let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
1111        result.base_description = Some("merge-base with origin/main".to_string());
1112        result.head_sha = Some("abc123".to_string());
1113        result.performance = true;
1114        result.base_snapshot_skipped = true;
1115        result.changed_files_count = 5;
1116
1117        // Pass verdict + successful JSON emit (no sub-results) maps to success;
1118        // Exercises the typed audit header's optional base_description /
1119        // head_sha / performance branches and the empty next-steps path.
1120        assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1121    }
1122
1123    #[test]
1124    fn build_audit_json_output_uses_typed_audit_contract() {
1125        let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Json);
1126        result.base_description = Some("merge-base with origin/main".to_string());
1127        result.head_sha = Some("abc123".to_string());
1128        result.performance = true;
1129        result.base_snapshot_skipped = true;
1130        result.changed_files_count = 5;
1131
1132        let json = build_audit_json_output(&result).expect("audit JSON should build");
1133
1134        assert_eq!(json["kind"], "audit");
1135        assert_eq!(json["command"], "audit");
1136        assert_eq!(json["base_description"], "merge-base with origin/main");
1137        assert_eq!(json["head_sha"], "abc123");
1138        assert_eq!(json["base_snapshot_skipped"], true);
1139        assert_eq!(json["changed_files_count"], 5);
1140    }
1141
1142    #[test]
1143    fn print_audit_result_renders_sarif_skeleton_without_findings() {
1144        let result = audit_result(AuditVerdict::Pass, OutputFormat::Sarif);
1145
1146        assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1147    }
1148
1149    #[test]
1150    fn print_audit_result_renders_codeclimate_without_findings() {
1151        let result = audit_result(AuditVerdict::Pass, OutputFormat::CodeClimate);
1152
1153        assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1154    }
1155
1156    #[test]
1157    fn print_audit_result_renders_pr_comment_for_both_providers() {
1158        for format in [OutputFormat::PrCommentGithub, OutputFormat::PrCommentGitlab] {
1159            let result = audit_result(AuditVerdict::Pass, format);
1160            assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1161        }
1162    }
1163
1164    #[test]
1165    fn print_audit_result_renders_review_envelope_for_both_providers() {
1166        for format in [OutputFormat::ReviewGithub, OutputFormat::ReviewGitlab] {
1167            let result = audit_result(AuditVerdict::Pass, format);
1168            assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1169        }
1170    }
1171
1172    #[test]
1173    fn print_audit_result_compact_and_markdown_use_human_path() {
1174        for format in [OutputFormat::Compact, OutputFormat::Markdown] {
1175            let result = audit_result(AuditVerdict::Pass, format);
1176            assert_eq!(print_audit_result(&result, true, false), ExitCode::SUCCESS);
1177        }
1178    }
1179
1180    #[test]
1181    fn print_audit_result_human_pass_renders_scope_and_status_line() {
1182        let mut result = audit_result(AuditVerdict::Pass, OutputFormat::Human);
1183        result.changed_files_count = 2;
1184
1185        // quiet=false drives the scope line + the green "no issues" status line.
1186        assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
1187    }
1188
1189    #[test]
1190    fn print_audit_result_human_warn_renders_vital_signs_and_notes() {
1191        let mut result = audit_result_with_findings(AuditVerdict::Warn, OutputFormat::Human);
1192        result.attribution = AuditAttribution {
1193            gate: AuditGate::NewOnly,
1194            dead_code_inherited: 2,
1195            complexity_inherited: 1,
1196            duplication_inherited: 0,
1197            ..AuditAttribution::default()
1198        };
1199        result.performance = true;
1200
1201        // Warn + findings (without sub-results) covers the explain tip, vital
1202        // signs, the gate-excluded inherited note, and the performance note.
1203        assert_eq!(print_audit_result(&result, false, false), ExitCode::SUCCESS);
1204    }
1205
1206    #[test]
1207    fn print_audit_result_human_fail_renders_red_status_line() {
1208        let result = audit_result_with_findings(AuditVerdict::Fail, OutputFormat::Human);
1209
1210        // Fail maps to exit 1 and renders the red status line via build_status_parts.
1211        assert_eq!(print_audit_result(&result, false, false), ExitCode::from(1));
1212    }
1213}