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