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