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