Skip to main content

fallow_cli/report/
mod.rs

1mod badge;
2pub mod baseline_advisory_text;
3pub mod ci;
4pub(crate) mod codeclimate;
5mod compact;
6pub mod dupes_grouping;
7pub(crate) mod gate_outcome_text;
8pub mod github;
9pub mod github_annotations;
10pub mod github_summary;
11pub mod grouping;
12pub(crate) mod grouping_note;
13mod human;
14mod json;
15mod markdown;
16pub(crate) mod request_outcome_text;
17pub(crate) mod sarif;
18mod shared;
19pub(crate) mod sink;
20mod status;
21pub(crate) mod suggestions;
22#[cfg(test)]
23pub(crate) mod test_helpers;
24
25use std::path::Path;
26use std::process::ExitCode;
27use std::time::Duration;
28
29use fallow_api::DuplicationGrouping;
30use fallow_config::{OutputFormat, RulesConfig, Severity};
31use fallow_types::duplicates::DuplicationReport;
32use fallow_types::results::AnalysisResults;
33use fallow_types::semantic::SemanticSymbolImpact;
34use fallow_types::trace::{
35    CloneTrace, DependencyTrace, ExportTrace, FileTrace, ImpactClosureTrace, PipelineTimings,
36};
37
38use crate::report::sink::outln;
39
40#[allow(
41    unused_imports,
42    reason = "used by binary crate modules (combined.rs, audit.rs)"
43)]
44pub use fallow_output::strip_root_prefix;
45pub use grouping::OwnershipResolver;
46pub(crate) use human::dupes::MAX_CLONE_GROUPS;
47pub(crate) use human::health::{render_health_score, render_health_trend};
48pub(crate) use status::{
49    HumanStatus, line as human_status_line, semantic_status, type_aware_meta_status,
50};
51
52/// The three line-groups of a human `fallow review --walkthrough` render: the
53/// orientation header and final status (stderr), and the staged tour body
54/// (stdout). The entry point in `audit_brief.rs` owns the stream split; this
55/// keeps the pure line builder behind the private `human` module while exposing
56/// exactly what the entry point needs.
57pub(crate) struct WalkthroughHumanRender {
58    /// Review Focus orientation header lines (stderr).
59    pub(crate) header: Vec<String>,
60    /// The staged tour body lines (stdout).
61    pub(crate) body: Vec<String>,
62    /// The final green status line (stderr).
63    pub(crate) status: String,
64}
65
66/// The root-relative files (in `direction.order`) the local ledger marked viewed
67/// against the guide's current hash. Exposed so the markdown surface can collapse
68/// the same viewed files into Cleared that the human surface does, keeping the two
69/// formats consistent on the same on-disk `--mark-viewed` state.
70#[must_use]
71pub(crate) fn walkthrough_viewed_files(
72    guide: &fallow_output::StandardWalkthroughGuide,
73    viewed: &crate::walkthrough_state::ViewedState,
74) -> Vec<String> {
75    human::walkthrough::viewed_files_for(guide, viewed)
76}
77
78/// Build the human walkthrough tour from the in-memory guide. Pure: no IO, no
79/// mutation. `viewed` decorates each file row; `show_cleared` expands the
80/// Cleared panel.
81#[must_use]
82pub(crate) fn build_walkthrough_human(
83    guide: &fallow_output::StandardWalkthroughGuide,
84    viewed: &crate::walkthrough_state::ViewedState,
85    show_cleared: bool,
86) -> WalkthroughHumanRender {
87    let input = human::walkthrough::WalkthroughHumanInput {
88        guide,
89        viewed,
90        show_cleared,
91    };
92    WalkthroughHumanRender {
93        header: human::walkthrough::build_focus_header(guide, viewed),
94        body: human::walkthrough::build_walkthrough_human_lines(&input),
95        status: human::walkthrough::build_status_line(guide, viewed),
96    }
97}
98
99/// Shared context for all report dispatch functions.
100///
101/// Bundles the common parameters that every format renderer needs,
102/// replacing per-parameter threading through the dispatch match arms.
103pub(crate) struct ReportContext<'a> {
104    pub(crate) root: &'a Path,
105    pub(crate) rules: &'a RulesConfig,
106    /// Workspace diagnostics captured by the analysis that owns this report.
107    pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
108    pub(crate) elapsed: Duration,
109    pub(crate) quiet: bool,
110    pub(crate) explain: bool,
111    /// Provenance for the opt-in TypeScript semantic analysis pass.
112    pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
113    /// Optional label for semantic metadata when one command renders multiple
114    /// analysis scopes into the same stream.
115    pub(crate) type_aware_scope: Option<&'static str>,
116    /// When set, group all output by this resolver.
117    pub(crate) group_by: Option<OwnershipResolver>,
118    /// Limit displayed items per section (--top N).
119    pub(crate) top: Option<usize>,
120    /// When set, print a concise summary instead of the full report.
121    pub(crate) summary: bool,
122    /// Human-only: print the summary renderer's own title line. Combined mode
123    /// already prints section headers, so it disables this to avoid duplicate
124    /// "Dead Code" / "Dead Code Summary" headings.
125    pub(crate) summary_heading: bool,
126    /// Human-only: print a one-line hint pointing at `fallow explain`.
127    pub(crate) show_explain_tip: bool,
128    /// When a baseline was loaded: (total entries in baseline, entries that matched).
129    pub(crate) baseline_matched: Option<(usize, usize)>,
130    /// This run's full view of the loaded baseline, for the JSON envelope's
131    /// `baseline_staleness`. Carries what `baseline_matched` cannot: whether the
132    /// run was change-scoped, the advisory verdict and the
133    /// `--fail-on-stale-baseline` verdict.
134    pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
135    /// Every gate this run evaluated, for the JSON envelope's `gate_outcomes`.
136    /// `None` when the run evaluated none, which keeps the key off the wire.
137    pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
138    /// Whether config-edit actions can be applied by `fallow fix`.
139    ///
140    /// This is caller-provided because an explicit `--config` path is fixable
141    /// even when default config discovery from the root would find nothing.
142    pub(crate) config_fixable: bool,
143    /// When set, the human health renderer skips the `● Health score:` and
144    /// trend table sections because they have already been rendered upstream
145    /// (combined-mode orientation header). Standalone `fallow health` keeps
146    /// the default `false` and renders both sections inline.
147    pub(crate) skip_score_and_trend: bool,
148    /// Human-only: whether `--css` was requested. When `true` but no stylesheet
149    /// was import-reachable, the CSS-health section renders an explanatory note
150    /// instead of being silently omitted. Defaults `false` for non-css callers.
151    pub(crate) css_requested: bool,
152    /// Presentation style for report JSON. Non-JSON renderers ignore it.
153    pub(crate) json_style: crate::json_style::JsonStyle,
154    /// Duplication JSON only: whether each clone instance carries its verbatim
155    /// source text. `false` is `fallow dupes --no-fragments`. Every other
156    /// renderer and analysis ignores it.
157    pub(crate) include_fragments: bool,
158}
159
160/// Strip the project root prefix from a path for display, falling back to the full path.
161#[must_use]
162pub(crate) fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
163    path.strip_prefix(root).unwrap_or(path)
164}
165
166/// Format a path for human-facing display: project-relative when the path is
167/// under `root`, falling back to the full path otherwise. Always
168/// forward-slash-normalized so Windows backslashes do not leak into
169/// terminal output.
170///
171/// Use this for any human-output site that today renders bare `file_name()`,
172/// since bare basenames are ambiguous in Nx / Angular / Rust-workspace layouts
173/// where many files share names like `index.ts`, `mod.rs`, or
174/// `*.component.ts`. See issue #547.
175#[must_use]
176pub(crate) fn format_display_path(path: &Path, root: &Path) -> String {
177    relative_path(path, root)
178        .display()
179        .to_string()
180        .replace('\\', "/")
181}
182
183/// Split a path string into (directory, filename) for display.
184/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
185#[must_use]
186pub(crate) fn split_dir_filename(path: &str) -> (&str, &str) {
187    path.rfind('/')
188        .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
189}
190
191/// Return `"s"` for plural or `""` for singular.
192#[must_use]
193pub(crate) const fn plural(n: usize) -> &'static str {
194    if n == 1 { "" } else { "s" }
195}
196
197/// Format a byte count in KiB / MiB / GiB for terminal output. Byte-exact
198/// sizes are available in JSON output paths; humans get a readable form.
199#[expect(
200    clippy::cast_precision_loss,
201    reason = "reported byte counts are well under the f64 precision loss range"
202)]
203#[must_use]
204pub(crate) fn format_bytes(bytes: u64) -> String {
205    const KIB: u64 = 1024;
206    const MIB: u64 = KIB * 1024;
207    const GIB: u64 = MIB * 1024;
208    if bytes >= GIB {
209        format!("{:.1} GiB", bytes as f64 / GIB as f64)
210    } else if bytes >= MIB {
211        format!("{:.1} MiB", bytes as f64 / MIB as f64)
212    } else if bytes >= KIB {
213        format!("{:.0} KiB", bytes as f64 / KIB as f64)
214    } else {
215        format!("{bytes} B")
216    }
217}
218
219/// Serialize a spec-defined JSON value with its established pretty formatting.
220///
221/// On success prints the JSON and returns `ExitCode::SUCCESS`.
222/// On serialization failure prints an error to stderr and returns exit code 2.
223#[must_use]
224pub(crate) fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
225    match serde_json::to_string_pretty(value) {
226        Ok(json) => {
227            outln!("{json}");
228            ExitCode::SUCCESS
229        }
230        Err(e) => {
231            eprintln!("Error: failed to serialize {kind} output: {e}");
232            ExitCode::from(2)
233        }
234    }
235}
236
237/// Serialize report JSON with the requested presentation style.
238#[must_use]
239pub(crate) fn emit_report_json(
240    value: &serde_json::Value,
241    kind: &str,
242    style: crate::json_style::JsonStyle,
243) -> ExitCode {
244    match style.serialize(value) {
245        Ok(json) => {
246            outln!("{json}");
247            ExitCode::SUCCESS
248        }
249        Err(e) => {
250            eprintln!("Error: failed to serialize {kind} output: {e}");
251            ExitCode::from(2)
252        }
253    }
254}
255
256pub(crate) struct CheckJsonRenderInput<'a> {
257    pub(crate) results: &'a AnalysisResults,
258    pub(crate) root: &'a Path,
259    pub(crate) elapsed: Duration,
260    pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
261    pub(crate) regression: Option<&'a crate::regression::RegressionOutcome>,
262    pub(crate) baseline_matched: Option<(usize, usize)>,
263    pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
264    pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
265    pub(crate) config_fixable: bool,
266    pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
267    pub(crate) json_style: crate::json_style::JsonStyle,
268}
269
270pub(crate) fn render_check_json(
271    input: &CheckJsonRenderInput<'_>,
272) -> Result<String, serde_json::Error> {
273    json::render_json(&json::PrintJsonInput {
274        results: input.results,
275        root: input.root,
276        elapsed: input.elapsed,
277        explain: false,
278        type_aware: input.type_aware,
279        regression: input.regression,
280        baseline_matched: input.baseline_matched,
281        baseline_staleness: input.baseline_staleness,
282        gate_outcomes: input.gate_outcomes.clone(),
283        config_fixable: input.config_fixable,
284        workspace_diagnostics: input.workspace_diagnostics,
285        json_style: input.json_style,
286    })
287}
288
289/// Elide the common directory prefix between a base path and a target path.
290/// Only strips complete directory segments (never partial filenames).
291/// Returns the remaining suffix of `target`.
292///
293/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
294#[must_use]
295pub(crate) fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
296    let mut last_sep = 0;
297    for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
298        if a != b {
299            break;
300        }
301        if a == b'/' {
302            last_sep = i + 1;
303        }
304    }
305    if last_sep > 0 && last_sep <= target.len() {
306        &target[last_sep..]
307    } else {
308        target
309    }
310}
311
312/// Compute a SARIF-compatible relative URI from an absolute path and project root.
313#[cfg(test)]
314fn relative_uri(path: &Path, root: &Path) -> String {
315    normalize_uri(&relative_path(path, root).display().to_string())
316}
317
318/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
319///
320/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
321/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
322#[must_use]
323pub(crate) fn normalize_uri(path_str: &str) -> String {
324    fallow_output::normalize_uri(path_str)
325}
326
327/// Severity level for human-readable output.
328#[derive(Clone, Copy, Debug)]
329pub enum Level {
330    Warn,
331    Info,
332    Error,
333}
334
335#[must_use]
336pub(crate) const fn severity_to_level(s: Severity) -> Level {
337    match s {
338        Severity::Error => Level::Error,
339        Severity::Warn => Level::Warn,
340        Severity::Off => Level::Info,
341    }
342}
343
344/// Print analysis results in the configured format.
345/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
346///
347/// When `regression` is `Some`, the JSON format includes a `regression` key in the output envelope.
348/// When `ctx.group_by` is `Some`, results are partitioned into labeled groups before rendering.
349#[must_use]
350pub(crate) fn print_results(
351    results: &AnalysisResults,
352    ctx: &ReportContext<'_>,
353    output: OutputFormat,
354    regression: Option<&crate::regression::RegressionOutcome>,
355) -> ExitCode {
356    if let Some(ref resolver) = ctx.group_by {
357        let groups = grouping::group_analysis_results(results, ctx.root, resolver);
358        return print_grouped_results(&groups, results, ctx, output, resolver);
359    }
360
361    match output {
362        OutputFormat::Human => {
363            if ctx.summary {
364                human::check::print_check_summary(
365                    results,
366                    ctx.rules,
367                    ctx.elapsed,
368                    ctx.quiet,
369                    ctx.summary_heading,
370                );
371            } else {
372                human::print_human(&human::PrintHumanInput {
373                    results,
374                    root: ctx.root,
375                    rules: ctx.rules,
376                    elapsed: ctx.elapsed,
377                    quiet: ctx.quiet,
378                    top: ctx.top,
379                    show_explain_tip: ctx.show_explain_tip,
380                    explain: ctx.explain,
381                });
382            }
383            ExitCode::SUCCESS
384        }
385        OutputFormat::Json => json::print_json(&json::PrintJsonInput {
386            results,
387            root: ctx.root,
388            elapsed: ctx.elapsed,
389            explain: ctx.explain,
390            type_aware: ctx.type_aware,
391            regression,
392            baseline_matched: ctx.baseline_matched,
393            baseline_staleness: ctx.baseline_staleness,
394            gate_outcomes: ctx.gate_outcomes.clone(),
395            config_fixable: ctx.config_fixable,
396            workspace_diagnostics: ctx.workspace_diagnostics,
397            json_style: ctx.json_style,
398        }),
399        OutputFormat::Compact => {
400            compact::print_compact(results, ctx.root);
401            compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
402            ExitCode::SUCCESS
403        }
404        OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules, ctx.type_aware),
405        OutputFormat::Markdown => {
406            markdown::print_markdown(results, ctx.root);
407            markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
408            ExitCode::SUCCESS
409        }
410        OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
411        OutputFormat::GithubAnnotations => print_check_github_annotations(results, ctx),
412        OutputFormat::GithubSummary => {
413            print_check_github_format(results, ctx, GithubTarget::Summary)
414        }
415        ci_format => print_results_ci_comment(results, ctx, ci_format),
416    }
417}
418
419/// Which GitHub-native renderer a dispatch arm targets.
420#[derive(Clone, Copy)]
421enum GithubTarget {
422    Annotations,
423    Summary,
424}
425
426fn print_github_format(
427    kind: github_annotations::EnvelopeKind,
428    envelope: &serde_json::Value,
429    root: &Path,
430    target: GithubTarget,
431) -> ExitCode {
432    match target {
433        GithubTarget::Annotations => github_annotations::print_annotations(kind, envelope, root),
434        GithubTarget::Summary => github_summary::print_summary(kind, envelope, root),
435    }
436}
437
438/// Render dead-code results as GitHub workflow-command annotations by
439/// building the same JSON envelope `--format json` serializes and feeding it
440/// to the value-driven renderer (which keeps `fallow report --from` output
441/// byte-identical to the direct format run).
442fn print_check_github_annotations(results: &AnalysisResults, ctx: &ReportContext<'_>) -> ExitCode {
443    print_check_github_format(results, ctx, GithubTarget::Annotations)
444}
445
446fn print_check_github_format(
447    results: &AnalysisResults,
448    ctx: &ReportContext<'_>,
449    target: GithubTarget,
450) -> ExitCode {
451    match json::api_check_json_document_with_config_fixable_meta_and_extras(
452        results,
453        ctx.root,
454        ctx.elapsed,
455        ctx.config_fixable,
456        None,
457        // This envelope is a render input for the GitHub-native targets, so it
458        // carries exactly what those two surfaces state: the scope the run
459        // covered (issues #2687, #2688), the gate inventory, and this run's view
460        // of the loaded baseline. Leaving the last two defaulted made the
461        // `Gate outcomes:` line and the baseline advisory reachable only through
462        // `fallow report --from`, so a direct run and a re-render of its own
463        // envelope disagreed (issue #2734).
464        fallow_api::CheckJsonExtraOutputs {
465            request_outcomes: crate::requests::request_outcomes(),
466            baseline_staleness: ctx.baseline_staleness,
467            gate_outcomes: ctx.gate_outcomes.clone(),
468            ..Default::default()
469        },
470        ctx.workspace_diagnostics,
471    ) {
472        Ok(envelope) => print_github_format(
473            github_annotations::EnvelopeKind::DeadCode,
474            &envelope,
475            ctx.root,
476            target,
477        ),
478        Err(e) => {
479            eprintln!("Error: failed to serialize results: {e}");
480            ExitCode::from(2)
481        }
482    }
483}
484
485/// The note a CI comment or review body carries: the type-aware message, the
486/// baseline advisory, the gate verdict, whether the run did what it was asked,
487/// a grouping this target cannot carry, or any combination of them.
488///
489/// Shared by the live renderers and by `fallow report --from`, because the two
490/// must produce byte-identical bodies for one envelope and that parity has its
491/// own suite. The clauses join in a fixed order, so two identical runs render
492/// identical bodies and a run that has nothing to say renders no note at all.
493///
494/// `grouping_dropped` is the `--group-by` mode when one was requested, because
495/// every target this note reaches renders one flat document (issue #2691).
496pub fn ci_status_note(
497    existing: Option<&'static str>,
498    baseline_advisory: Option<&str>,
499    gates: Option<&fallow_output::GateOutcomes>,
500    requests: Option<&fallow_output::RequestOutcomes>,
501    grouping_dropped: Option<&str>,
502) -> Option<String> {
503    let gate_summary = gate_outcome_text::summary_line_for_gates(gates);
504    let request_summary = request_outcome_text::summary_line_for_requests(requests);
505    let grouping_clause = grouping_dropped.map(grouping_note::dropped_grouping_clause);
506    join_status_clauses(&[
507        existing,
508        baseline_advisory,
509        gate_summary.as_deref(),
510        request_summary.as_deref(),
511        grouping_clause.as_deref(),
512    ])
513}
514
515/// Space-join the clauses a status note is made of, dropping the absent ones.
516///
517/// Order is fixed: the type-aware message first because it says the analysis
518/// was incomplete, then the baseline advisory as the specific fact, then the
519/// gate inventory as the summary, then what the run was asked to do, and last
520/// the grouping this target could not carry. Both comment renderers emit the
521/// note as a single `> ` blockquote line, so the join is a space rather than a
522/// newline.
523pub(crate) fn join_status_clauses(clauses: &[Option<&str>]) -> Option<String> {
524    let joined = clauses
525        .iter()
526        .filter_map(|clause| clause.filter(|value| !value.is_empty()))
527        .collect::<Vec<_>>()
528        .join(" ");
529    (!joined.is_empty()).then_some(joined)
530}
531
532/// Render the CI comment / review / badge fallback arms for dead-code results.
533fn print_results_ci_comment(
534    results: &AnalysisResults,
535    ctx: &ReportContext<'_>,
536    output: OutputFormat,
537) -> ExitCode {
538    // Analysis-root-relative on purpose: the review renderer applies the
539    // presentation prefix after its diff lookups, and rebasing here would
540    // prefix twice and key the filter in the wrong namespace.
541    let issues = codeclimate::api_codeclimate_issues(results, ctx.root, ctx.rules);
542    let value = fallow_output::codeclimate_issues_to_value(&issues);
543    let incomplete = ci::required_type_aware_incomplete(ctx.type_aware);
544    let conclusion = incomplete.then_some(fallow_output::PrDecisionConclusion::Failure);
545    let advisory =
546        baseline_advisory_text::advisory_line_for_staleness(ctx.baseline_staleness.as_ref());
547    let requests = crate::requests::request_outcomes();
548    let grouping_dropped = dropped_grouping_mode(ctx, output);
549    let status_message = ci_status_note(
550        incomplete.then_some(ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
551        advisory.as_deref(),
552        ctx.gate_outcomes.as_ref(),
553        requests.as_ref(),
554        grouping_dropped,
555    );
556    print_ci_comment_format_with_status(
557        "dead-code",
558        &value,
559        output,
560        conclusion,
561        ci::pr_comment::PrCommentStatus {
562            message: status_message.as_deref(),
563            gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
564        },
565    )
566    .unwrap_or_else(|| {
567        eprintln!("Error: badge format is only supported for the health command");
568        ExitCode::from(2)
569    })
570}
571
572/// Render grouped results across all output formats.
573#[must_use]
574fn print_grouped_results(
575    groups: &[grouping::ResultGroup],
576    original: &AnalysisResults,
577    ctx: &ReportContext<'_>,
578    output: OutputFormat,
579    resolver: &OwnershipResolver,
580) -> ExitCode {
581    match output {
582        OutputFormat::Human => {
583            human::print_grouped_human(&human::PrintGroupedHumanInput {
584                groups,
585                root: ctx.root,
586                rules: ctx.rules,
587                elapsed: ctx.elapsed,
588                quiet: ctx.quiet,
589                resolver: Some(resolver),
590                explain: ctx.explain,
591            });
592            ExitCode::SUCCESS
593        }
594        OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
595            groups,
596            original,
597            root: ctx.root,
598            elapsed: ctx.elapsed,
599            explain: ctx.explain,
600            type_aware: ctx.type_aware,
601            resolver,
602            config_fixable: ctx.config_fixable,
603            baseline_staleness: ctx.baseline_staleness,
604            gate_outcomes: ctx.gate_outcomes.clone(),
605            workspace_diagnostics: ctx.workspace_diagnostics,
606            json_style: ctx.json_style,
607        }),
608        OutputFormat::Compact => {
609            compact::print_grouped_compact(groups, ctx.root);
610            compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
611            ExitCode::SUCCESS
612        }
613        OutputFormat::Markdown => {
614            markdown::print_grouped_markdown(groups, ctx.root);
615            markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
616            ExitCode::SUCCESS
617        }
618        OutputFormat::Sarif => {
619            sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver, ctx.type_aware)
620        }
621        OutputFormat::CodeClimate => {
622            codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
623        }
624        // The GitHub-native formats have no grouping concept, so they render
625        // the ungrouped document from the original results and say so on
626        // stderr. Deliberately stderr only: both documents COULD carry the
627        // sentence (each already appends the gate and request lines), but a
628        // dropped grouping is a fact about the invocation rather than about the
629        // findings, and neither document has a reader who can act on it. The
630        // comment and review bodies carry the clause because a human reads them
631        // and would otherwise take the flat list for a grouped one (issue
632        // #2691).
633        OutputFormat::GithubAnnotations => {
634            dropped_grouping_mode(ctx, output);
635            print_check_github_annotations(original, ctx)
636        }
637        OutputFormat::GithubSummary => {
638            dropped_grouping_mode(ctx, output);
639            print_check_github_format(original, ctx, GithubTarget::Summary)
640        }
641        ci_format => print_results_ci_comment(original, ctx, ci_format),
642    }
643}
644
645/// Print duplication analysis results in the configured format.
646#[must_use]
647pub(crate) fn print_duplication_report(
648    report: &DuplicationReport,
649    ctx: &ReportContext<'_>,
650    output: OutputFormat,
651) -> ExitCode {
652    if let Some(ref resolver) = ctx.group_by {
653        let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
654        return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
655    }
656
657    match output {
658        OutputFormat::Human => {
659            if ctx.summary {
660                human::dupes::print_duplication_summary(
661                    report,
662                    ctx.elapsed,
663                    ctx.quiet,
664                    ctx.summary_heading,
665                );
666            } else {
667                human::print_duplication_human(
668                    report,
669                    ctx.root,
670                    ctx.elapsed,
671                    ctx.quiet,
672                    ctx.show_explain_tip,
673                    ctx.explain,
674                );
675            }
676            ExitCode::SUCCESS
677        }
678        OutputFormat::Json => json::print_duplication_json(
679            report,
680            ctx.root,
681            ctx.elapsed,
682            &json::DuplicationJsonRender {
683                explain: ctx.explain,
684                include_fragments: ctx.include_fragments,
685                baseline_staleness: ctx.baseline_staleness,
686                gate_outcomes: ctx.gate_outcomes.clone(),
687            },
688            ctx.workspace_diagnostics,
689            ctx.json_style,
690        ),
691        OutputFormat::Compact => {
692            compact::print_duplication_compact(report, ctx.root);
693            ExitCode::SUCCESS
694        }
695        OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
696        OutputFormat::Markdown => {
697            markdown::print_duplication_markdown(report, ctx.root);
698            ExitCode::SUCCESS
699        }
700        OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
701        OutputFormat::GithubAnnotations => {
702            print_dupes_github_format(report, ctx, GithubTarget::Annotations)
703        }
704        OutputFormat::GithubSummary => {
705            print_dupes_github_format(report, ctx, GithubTarget::Summary)
706        }
707        ci_format => print_duplication_ci_comment(report, ctx.root, ci_format, ctx, None),
708    }
709}
710
711/// Render duplication results in a GitHub-native format from the same JSON
712/// envelope `--format json` serializes.
713fn print_dupes_github_format(
714    report: &DuplicationReport,
715    ctx: &ReportContext<'_>,
716    target: GithubTarget,
717) -> ExitCode {
718    match json::api_duplication_json_document(
719        report,
720        ctx.root,
721        ctx.elapsed,
722        &json::DuplicationJsonRender {
723            explain: ctx.explain,
724            include_fragments: ctx.include_fragments,
725            baseline_staleness: ctx.baseline_staleness,
726            gate_outcomes: ctx.gate_outcomes.clone(),
727        },
728        ctx.workspace_diagnostics,
729    ) {
730        Ok(envelope) => print_github_format(
731            github_annotations::EnvelopeKind::Dupes,
732            &envelope,
733            ctx.root,
734            target,
735        ),
736        Err(e) => {
737            eprintln!("Error: failed to serialize duplication report: {e}");
738            ExitCode::from(2)
739        }
740    }
741}
742
743/// Render the CI comment / review / badge fallback arms for duplication results.
744fn print_duplication_ci_comment(
745    report: &DuplicationReport,
746    root: &Path,
747    output: OutputFormat,
748    ctx: &ReportContext<'_>,
749    grouping_dropped: Option<&str>,
750) -> ExitCode {
751    let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
752    let value = fallow_output::codeclimate_issues_to_value(&issues);
753    let advisory =
754        baseline_advisory_text::advisory_line_for_staleness(ctx.baseline_staleness.as_ref());
755    let requests = crate::requests::request_outcomes();
756    let note = ci_status_note(
757        None,
758        advisory.as_deref(),
759        ctx.gate_outcomes.as_ref(),
760        requests.as_ref(),
761        grouping_dropped,
762    );
763    print_ci_comment_format_with_status(
764        "dupes",
765        &value,
766        output,
767        None,
768        ci::pr_comment::PrCommentStatus {
769            message: note.as_deref(),
770            gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
771        },
772    )
773    .unwrap_or_else(|| {
774        eprintln!("Error: badge format is only supported for the health command");
775        ExitCode::from(2)
776    })
777}
778
779/// Render grouped duplication results across all output formats.
780#[must_use]
781fn print_grouped_duplication_report(
782    report: &DuplicationReport,
783    grouping: &DuplicationGrouping,
784    ctx: &ReportContext<'_>,
785    output: OutputFormat,
786    resolver: &OwnershipResolver,
787) -> ExitCode {
788    match output {
789        OutputFormat::Human => {
790            human::print_grouped_duplication_human(
791                report,
792                grouping,
793                ctx.root,
794                ctx.elapsed,
795                ctx.quiet,
796            );
797            ExitCode::SUCCESS
798        }
799        OutputFormat::Json => json::print_grouped_duplication_json(
800            report,
801            grouping,
802            ctx.root,
803            ctx.elapsed,
804            &json::DuplicationJsonRender {
805                explain: ctx.explain,
806                include_fragments: ctx.include_fragments,
807                baseline_staleness: ctx.baseline_staleness,
808                gate_outcomes: ctx.gate_outcomes.clone(),
809            },
810            ctx.workspace_diagnostics,
811            ctx.json_style,
812        ),
813        OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
814        OutputFormat::CodeClimate => {
815            codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
816        }
817        OutputFormat::PrCommentGithub
818        | OutputFormat::PrCommentGitlab
819        | OutputFormat::ReviewGithub
820        | OutputFormat::ReviewGitlab => print_duplication_ci_comment(
821            report,
822            ctx.root,
823            output,
824            ctx,
825            note_dropped_grouping(Some(grouping.mode), output),
826        ),
827        // The GitHub-native formats have no grouping concept, so they render
828        // the ungrouped document and say so on stderr, which is the whole fix
829        // for those two targets. Not because those documents have nowhere to
830        // put it (both already append the gate and request lines) but because a
831        // dropped grouping is a fact about the invocation, and the reader who
832        // can act on it is the one reading the job log (issue #2691).
833        OutputFormat::GithubAnnotations => {
834            note_dropped_grouping(Some(grouping.mode), output);
835            print_dupes_github_format(report, ctx, GithubTarget::Annotations)
836        }
837        OutputFormat::GithubSummary => {
838            note_dropped_grouping(Some(grouping.mode), output);
839            print_dupes_github_format(report, ctx, GithubTarget::Summary)
840        }
841        OutputFormat::Compact => {
842            compact::print_duplication_compact(report, ctx.root);
843            note_dropped_grouping(Some(grouping.mode), output);
844            ExitCode::SUCCESS
845        }
846        OutputFormat::Markdown => {
847            markdown::print_duplication_markdown(report, ctx.root);
848            note_dropped_grouping(Some(grouping.mode), output);
849            ExitCode::SUCCESS
850        }
851        OutputFormat::Badge => {
852            eprintln!("Error: badge format is only supported for the health command");
853            ExitCode::from(2)
854        }
855    }
856}
857
858/// Dispatch a PR-comment / review CI format from a precomputed CodeClimate value.
859///
860/// Returns `Some(exit_code)` for the four CI comment/review formats and `None`
861/// for every other output format, so callers keep their exhaustive match arms.
862fn print_ci_comment_format_with_status(
863    analysis: &str,
864    value: &serde_json::Value,
865    output: OutputFormat,
866    conclusion: Option<fallow_output::PrDecisionConclusion>,
867    status: ci::pr_comment::PrCommentStatus<'_>,
868) -> Option<ExitCode> {
869    let exit = match output {
870        OutputFormat::PrCommentGithub => conclusion.map_or_else(
871            || {
872                ci::pr_comment::print_pr_comment(
873                    analysis,
874                    ci::pr_comment::Provider::Github,
875                    value,
876                    status,
877                )
878            },
879            |conclusion| {
880                ci::pr_comment::print_pr_comment_with_status(
881                    analysis,
882                    ci::pr_comment::Provider::Github,
883                    value,
884                    conclusion,
885                    status,
886                )
887            },
888        ),
889        OutputFormat::PrCommentGitlab => conclusion.map_or_else(
890            || {
891                ci::pr_comment::print_pr_comment(
892                    analysis,
893                    ci::pr_comment::Provider::Gitlab,
894                    value,
895                    status,
896                )
897            },
898            |conclusion| {
899                ci::pr_comment::print_pr_comment_with_status(
900                    analysis,
901                    ci::pr_comment::Provider::Gitlab,
902                    value,
903                    conclusion,
904                    status,
905                )
906            },
907        ),
908        OutputFormat::ReviewGithub => conclusion.map_or_else(
909            || {
910                ci::review::print_review_envelope(
911                    analysis,
912                    ci::pr_comment::Provider::Github,
913                    value,
914                    status.message,
915                )
916            },
917            |conclusion| {
918                ci::review::print_review_envelope_with_conclusion(
919                    analysis,
920                    ci::pr_comment::Provider::Github,
921                    value,
922                    conclusion,
923                    status.message,
924                )
925            },
926        ),
927        OutputFormat::ReviewGitlab => conclusion.map_or_else(
928            || {
929                ci::review::print_review_envelope(
930                    analysis,
931                    ci::pr_comment::Provider::Gitlab,
932                    value,
933                    status.message,
934                )
935            },
936            |conclusion| {
937                ci::review::print_review_envelope_with_conclusion(
938                    analysis,
939                    ci::pr_comment::Provider::Gitlab,
940                    value,
941                    conclusion,
942                    status.message,
943                )
944            },
945        ),
946        _ => return None,
947    };
948    Some(exit)
949}
950
951/// Note on stderr that this render target dropped the requested grouping, and
952/// return the mode so the rendered body can say the same thing.
953///
954/// The two travel together on purpose: until issue #2691 three targets printed
955/// the note, six said nothing at all, and no target put the fact in what it
956/// rendered.
957fn note_dropped_grouping(mode: Option<&str>, output: OutputFormat) -> Option<&str> {
958    if let Some(mode) = mode {
959        eprintln!(
960            "note: --group-by {mode} is not supported for {format} output, falling back to \
961             ungrouped output (use --format json for the full grouped envelope)",
962            format = output.flag_label()
963        );
964    }
965    mode
966}
967
968/// The requested `--group-by` mode when this target cannot carry it, noted on
969/// stderr on the way out.
970fn dropped_grouping_mode<'a>(ctx: &'a ReportContext<'_>, output: OutputFormat) -> Option<&'a str> {
971    note_dropped_grouping(
972        ctx.group_by
973            .as_ref()
974            .map(grouping::OwnershipResolver::mode_label),
975        output,
976    )
977}
978
979/// Print health (complexity) analysis results in the configured format.
980///
981/// `grouping` and `group_resolver` carry per-group output produced by
982/// `--group-by`:
983/// - **JSON** renders the grouped envelope (`{ grouped_by, vital_signs,
984///   health_score, groups: [...] }`).
985/// - **Human** prints a per-group summary block (score / files / hot / p90)
986///   after the project-level report.
987/// - **SARIF** and **CodeClimate** tag every per-finding result with the
988///   resolver-derived group key (`properties.group` for SARIF, top-level
989///   `group` for CodeClimate) so CI consumers like GitHub Code Scanning
990///   and GitLab Code Quality can partition findings per team / package
991///   without re-parsing the project structure.
992/// - **Compact**, **Markdown**, and **Badge** fall back to ungrouped output
993///   and emit a one-line stderr note pointing at `--format json` for the
994///   richer grouped envelope.
995#[must_use]
996pub(crate) fn print_health_report(
997    report: &fallow_output::HealthReport,
998    grouping: Option<&fallow_output::HealthGrouping>,
999    group_resolver: Option<&grouping::OwnershipResolver>,
1000    ctx: &ReportContext<'_>,
1001    output: OutputFormat,
1002) -> ExitCode {
1003    match output {
1004        OutputFormat::Human => {
1005            print_health_human_report(report, grouping, ctx);
1006            ExitCode::SUCCESS
1007        }
1008        OutputFormat::Compact => {
1009            compact::print_health_compact(report, ctx.root);
1010            compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
1011            dropped_health_grouping_mode(grouping, output);
1012            ExitCode::SUCCESS
1013        }
1014        OutputFormat::Markdown => {
1015            markdown::print_health_markdown(report, ctx.root);
1016            markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
1017            dropped_health_grouping_mode(grouping, output);
1018            ExitCode::SUCCESS
1019        }
1020        OutputFormat::Sarif => match group_resolver {
1021            Some(resolver) => {
1022                sarif::print_grouped_health_sarif(report, ctx.root, resolver, ctx.type_aware)
1023            }
1024            None => sarif::print_health_sarif(report, ctx.root, ctx.type_aware),
1025        },
1026        OutputFormat::Json => match grouping {
1027            Some(grouping) => json::print_grouped_health_json(
1028                report,
1029                grouping,
1030                ctx.root,
1031                ctx.elapsed,
1032                ctx.explain,
1033                ctx.type_aware,
1034                ctx.workspace_diagnostics,
1035                ctx.json_style,
1036                ctx.gate_outcomes.clone(),
1037            ),
1038            None => json::print_health_json(
1039                report,
1040                ctx.root,
1041                ctx.elapsed,
1042                ctx.explain,
1043                ctx.type_aware,
1044                ctx.workspace_diagnostics,
1045                ctx.json_style,
1046                ctx.gate_outcomes.clone(),
1047            ),
1048        },
1049        OutputFormat::CodeClimate => match group_resolver {
1050            Some(resolver) => {
1051                codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
1052            }
1053            None => codeclimate::print_health_codeclimate(report, ctx.root),
1054        },
1055        OutputFormat::PrCommentGithub
1056        | OutputFormat::PrCommentGitlab
1057        | OutputFormat::ReviewGithub
1058        | OutputFormat::ReviewGitlab => print_health_ci_comment(
1059            report,
1060            ctx.root,
1061            output,
1062            ctx,
1063            dropped_health_grouping_mode(grouping, output),
1064        ),
1065        // The GitHub-native formats have no grouping concept, so they render
1066        // the ungrouped document and say so on stderr. There is nowhere in an
1067        // annotation stream or a job summary to put the fact (issue #2691).
1068        OutputFormat::GithubAnnotations => {
1069            dropped_health_grouping_mode(grouping, output);
1070            print_health_github_format(report, ctx, GithubTarget::Annotations)
1071        }
1072        OutputFormat::GithubSummary => {
1073            dropped_health_grouping_mode(grouping, output);
1074            print_health_github_format(report, ctx, GithubTarget::Summary)
1075        }
1076        OutputFormat::Badge => {
1077            dropped_health_grouping_mode(grouping, output);
1078            badge::print_health_badge(report)
1079        }
1080    }
1081}
1082
1083/// Render health results in a GitHub-native format from the same JSON
1084/// envelope `--format json` serializes.
1085fn print_health_github_format(
1086    report: &fallow_output::HealthReport,
1087    ctx: &ReportContext<'_>,
1088    target: GithubTarget,
1089) -> ExitCode {
1090    match json::api_health_json_document(
1091        report,
1092        ctx.root,
1093        ctx.elapsed,
1094        ctx.explain,
1095        ctx.type_aware,
1096        ctx.workspace_diagnostics,
1097        ctx.gate_outcomes.clone(),
1098    ) {
1099        Ok(envelope) => print_github_format(
1100            github_annotations::EnvelopeKind::Health,
1101            &envelope,
1102            ctx.root,
1103            target,
1104        ),
1105        Err(e) => {
1106            eprintln!("Error: failed to serialize health report: {e}");
1107            ExitCode::from(2)
1108        }
1109    }
1110}
1111
1112/// Render the human-format health report, including the per-group summary block.
1113fn print_health_human_report(
1114    report: &fallow_output::HealthReport,
1115    grouping: Option<&fallow_output::HealthGrouping>,
1116    ctx: &ReportContext<'_>,
1117) {
1118    if ctx.summary {
1119        human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
1120        return;
1121    }
1122    human::print_health_human(&human::PrintHealthHumanInput {
1123        report,
1124        root: ctx.root,
1125        elapsed: ctx.elapsed,
1126        quiet: ctx.quiet,
1127        show_explain_tip: ctx.show_explain_tip,
1128        explain: ctx.explain,
1129        skip_score_and_trend: ctx.skip_score_and_trend,
1130        css_requested: ctx.css_requested,
1131        type_aware: ctx.type_aware,
1132    });
1133    if let Some(grouping) = grouping {
1134        human::print_health_grouping(grouping, ctx.root, ctx.quiet);
1135    }
1136}
1137
1138/// Render the CI comment / review fallback arms for health results.
1139fn print_health_ci_comment(
1140    report: &fallow_output::HealthReport,
1141    root: &Path,
1142    output: OutputFormat,
1143    ctx: &ReportContext<'_>,
1144    grouping_dropped: Option<&str>,
1145) -> ExitCode {
1146    let issues = codeclimate::api_health_codeclimate_issues(report, root);
1147    let value = fallow_output::codeclimate_issues_to_value(&issues);
1148    // Health's own summary is the carrier on the envelope, and the report in
1149    // hand is the same object, so read it from there rather than from the
1150    // context: a grouped render rebuilds the report but not the context.
1151    let advisory = baseline_advisory_text::advisory_line_for_staleness(
1152        report.summary.baseline_staleness.as_ref(),
1153    );
1154    let requests = crate::requests::request_outcomes();
1155    let note = ci_status_note(
1156        None,
1157        advisory.as_deref(),
1158        ctx.gate_outcomes.as_ref(),
1159        requests.as_ref(),
1160        grouping_dropped,
1161    );
1162    print_ci_comment_format_with_status(
1163        "health",
1164        &value,
1165        output,
1166        None,
1167        ci::pr_comment::PrCommentStatus {
1168            message: note.as_deref(),
1169            gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
1170        },
1171    )
1172    .unwrap_or_else(|| {
1173        eprintln!("Error: badge format is only supported for the health command");
1174        ExitCode::from(2)
1175    })
1176}
1177
1178/// The requested health `--group-by` mode when this target cannot carry it.
1179///
1180/// Health threads its grouping through its own parameter rather than
1181/// `ReportContext::group_by`, which standalone `fallow health` leaves unset.
1182fn dropped_health_grouping_mode(
1183    grouping: Option<&fallow_output::HealthGrouping>,
1184    output: OutputFormat,
1185) -> Option<&str> {
1186    note_dropped_grouping(grouping.map(|grouping| grouping.mode), output)
1187}
1188
1189/// Print cross-reference findings (duplicated code that is also dead code).
1190///
1191/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
1192pub(crate) fn print_cross_reference_findings(
1193    cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
1194    root: &Path,
1195    quiet: bool,
1196    output: OutputFormat,
1197) {
1198    human::print_cross_reference_findings(cross_ref, root, quiet, output);
1199}
1200
1201/// Print export trace results.
1202pub(crate) fn print_export_trace(
1203    trace: &ExportTrace,
1204    format: OutputFormat,
1205    json_style: crate::json_style::JsonStyle,
1206) {
1207    match format {
1208        OutputFormat::Json => json::print_trace_json(trace, json_style),
1209        _ => human::print_export_trace_human(trace),
1210    }
1211}
1212
1213/// Print a syntactic export trace with its authoritative checker-backed
1214/// semantic section and optional field definitions.
1215pub(crate) fn print_semantic_export_trace(
1216    trace: &ExportTrace,
1217    format: OutputFormat,
1218    explain: bool,
1219    json_style: crate::json_style::JsonStyle,
1220) {
1221    match format {
1222        OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1223        _ => human::print_export_trace_human(trace),
1224    }
1225}
1226
1227/// Print class-member trace results (the `--trace FILE:MEMBER` fallback).
1228pub(crate) fn print_class_member_trace(
1229    trace: &fallow_engine::trace::ClassMemberTrace,
1230    format: OutputFormat,
1231    json_style: crate::json_style::JsonStyle,
1232) {
1233    match format {
1234        OutputFormat::Json => json::print_trace_json(trace, json_style),
1235        _ => human::print_class_member_trace_human(trace),
1236    }
1237}
1238
1239/// Print a class-member trace with authoritative checker-backed evidence and
1240/// optional field definitions.
1241pub(crate) fn print_semantic_class_member_trace(
1242    trace: &fallow_engine::trace::ClassMemberTrace,
1243    format: OutputFormat,
1244    explain: bool,
1245    json_style: crate::json_style::JsonStyle,
1246) {
1247    match format {
1248        OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1249        _ => human::print_class_member_trace_human(trace),
1250    }
1251}
1252
1253/// Print file trace results.
1254pub(crate) fn print_file_trace(
1255    trace: &FileTrace,
1256    format: OutputFormat,
1257    json_style: crate::json_style::JsonStyle,
1258) {
1259    match format {
1260        OutputFormat::Json => json::print_trace_json(trace, json_style),
1261        _ => human::print_file_trace_human(trace),
1262    }
1263}
1264
1265/// Print dependency trace results.
1266pub(crate) fn print_dependency_trace(
1267    trace: &DependencyTrace,
1268    format: OutputFormat,
1269    json_style: crate::json_style::JsonStyle,
1270) {
1271    match format {
1272        OutputFormat::Json => json::print_trace_json(trace, json_style),
1273        _ => human::print_dependency_trace_human(trace),
1274    }
1275}
1276
1277/// Print clone trace results.
1278pub(crate) fn print_clone_trace(
1279    trace: &CloneTrace,
1280    root: &Path,
1281    format: OutputFormat,
1282    json_style: crate::json_style::JsonStyle,
1283) {
1284    match format {
1285        OutputFormat::Json => json::print_trace_json(trace, json_style),
1286        _ => human::print_clone_trace_human(trace, root),
1287    }
1288}
1289
1290/// Print impact-closure trace results. JSON only emits the structured
1291/// closure; human renders a short summary.
1292pub(crate) fn print_impact_closure_trace(
1293    trace: &ImpactClosureTrace,
1294    format: OutputFormat,
1295    json_style: crate::json_style::JsonStyle,
1296) {
1297    match format {
1298        OutputFormat::Json => json::print_trace_json(trace, json_style),
1299        _ => {
1300            outln!("Impact closure for {}", trace.seed);
1301            outln!(
1302                "  affected beyond the diff: {} file{}",
1303                trace.affected_not_shown.len(),
1304                plural(trace.affected_not_shown.len())
1305            );
1306            for gap in &trace.coordination_gap {
1307                outln!(
1308                    "  coordination gap: {} consumes {}",
1309                    gap.consumer_file,
1310                    gap.consumed_symbols.join(", ")
1311                );
1312            }
1313        }
1314    }
1315}
1316
1317/// Print exact-symbol impact and targeted-test recommendations.
1318pub(crate) fn print_symbol_impact(
1319    impact: &SemanticSymbolImpact,
1320    format: OutputFormat,
1321    explain: bool,
1322    json_style: crate::json_style::JsonStyle,
1323) {
1324    match format {
1325        OutputFormat::Json => json::print_semantic_impact_json(impact, explain, json_style),
1326        _ => human::print_symbol_impact_human(impact),
1327    }
1328}
1329
1330/// Print pipeline performance timings.
1331/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
1332pub(crate) fn print_performance(
1333    timings: &PipelineTimings,
1334    format: OutputFormat,
1335    json_style: crate::json_style::JsonStyle,
1336) {
1337    match format {
1338        OutputFormat::Json => match json_style.serialize(timings) {
1339            Ok(json) => eprintln!("{json}"),
1340            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1341        },
1342        _ => human::print_performance_human(timings),
1343    }
1344}
1345
1346/// Print health pipeline performance timings.
1347/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
1348pub(crate) fn print_health_performance(
1349    timings: &fallow_output::HealthTimings,
1350    format: OutputFormat,
1351    json_style: crate::json_style::JsonStyle,
1352) {
1353    match format {
1354        OutputFormat::Json => match json_style.serialize(timings) {
1355            Ok(json) => eprintln!("{json}"),
1356            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1357        },
1358        _ => human::print_health_performance_human(timings),
1359    }
1360}
1361
1362#[allow(
1363    unused_imports,
1364    reason = "target-dependent: used in lib, unused in bin"
1365)]
1366pub use fallow_api::build_compact_lines;
1367#[allow(
1368    unused_imports,
1369    reason = "target-dependent: used in lib, unused in bin"
1370)]
1371pub use fallow_api::build_duplication_markdown;
1372#[allow(
1373    unused_imports,
1374    reason = "target-dependent: used in lib, unused in bin"
1375)]
1376pub use fallow_api::build_health_markdown;
1377#[allow(
1378    unused_imports,
1379    reason = "target-dependent: used in lib, unused in bin"
1380)]
1381pub use fallow_api::build_markdown;
1382#[allow(
1383    clippy::redundant_pub_crate,
1384    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1385)]
1386pub(crate) use json::api_check_json_payload_with_config_fixable;
1387#[allow(
1388    clippy::redundant_pub_crate,
1389    reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1390)]
1391pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1392#[allow(
1393    unused_imports,
1394    reason = "target-dependent: used in lib, unused in bin"
1395)]
1396#[allow(
1397    clippy::redundant_pub_crate,
1398    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1399)]
1400pub(crate) use sarif::api_health_sarif_document;
1401#[allow(
1402    unused_imports,
1403    reason = "target-dependent: used in lib, unused in bin"
1404)]
1405#[allow(
1406    clippy::redundant_pub_crate,
1407    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1408)]
1409pub(crate) use sarif::api_sarif_document;
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414    use std::path::{Path, PathBuf};
1415
1416    #[test]
1417    fn format_bytes_pivots_at_power_of_1024() {
1418        assert_eq!(format_bytes(0), "0 B");
1419        assert_eq!(format_bytes(1023), "1023 B");
1420        assert_eq!(format_bytes(1024), "1 KiB");
1421        assert_eq!(format_bytes(2048), "2 KiB");
1422        assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1423        assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1424        assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1425    }
1426
1427    fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1428        ReportContext {
1429            baseline_staleness: None,
1430            gate_outcomes: None,
1431            root,
1432            rules,
1433            workspace_diagnostics: &[],
1434            elapsed: Duration::default(),
1435            quiet: true,
1436            explain: false,
1437            type_aware: None,
1438            type_aware_scope: None,
1439            group_by: None,
1440            top: None,
1441            summary: false,
1442            summary_heading: false,
1443            show_explain_tip: false,
1444            baseline_matched: None,
1445            config_fixable: false,
1446            skip_score_and_trend: false,
1447            css_requested: false,
1448            json_style: crate::json_style::JsonStyle::Compact,
1449            include_fragments: true,
1450        }
1451    }
1452
1453    #[test]
1454    fn normalize_uri_forward_slashes_unchanged() {
1455        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1456    }
1457
1458    #[test]
1459    fn normalize_uri_backslashes_replaced() {
1460        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1461    }
1462
1463    #[test]
1464    fn normalize_uri_mixed_slashes() {
1465        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1466    }
1467
1468    #[test]
1469    fn normalize_uri_path_with_spaces() {
1470        assert_eq!(
1471            normalize_uri("src\\my folder\\file.ts"),
1472            "src/my folder/file.ts"
1473        );
1474    }
1475
1476    #[test]
1477    fn normalize_uri_empty_string() {
1478        assert_eq!(normalize_uri(""), "");
1479    }
1480
1481    #[test]
1482    fn relative_path_strips_root_prefix() {
1483        let root = Path::new("/project");
1484        let path = Path::new("/project/src/utils.ts");
1485        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1486    }
1487
1488    #[test]
1489    fn relative_path_returns_full_path_when_no_prefix() {
1490        let root = Path::new("/other");
1491        let path = Path::new("/project/src/utils.ts");
1492        assert_eq!(relative_path(path, root), path);
1493    }
1494
1495    #[test]
1496    fn relative_path_at_root_returns_empty_or_file() {
1497        let root = Path::new("/project");
1498        let path = Path::new("/project/file.ts");
1499        assert_eq!(relative_path(path, root), Path::new("file.ts"));
1500    }
1501
1502    #[test]
1503    fn relative_path_deeply_nested() {
1504        let root = Path::new("/project");
1505        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1506        assert_eq!(
1507            relative_path(path, root),
1508            Path::new("packages/ui/src/components/Button.tsx")
1509        );
1510    }
1511
1512    #[test]
1513    fn format_display_path_returns_workspace_relative() {
1514        let root = Path::new("/project");
1515        let path = Path::new("/project/apps/server/src/index.ts");
1516        assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1517    }
1518
1519    #[test]
1520    fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1521        let root = Path::new("/project");
1522        let server = Path::new("/project/apps/server/src/index.ts");
1523        let client = Path::new("/project/apps/client/src/index.ts");
1524        assert_eq!(
1525            format_display_path(server, root),
1526            "apps/server/src/index.ts"
1527        );
1528        assert_eq!(
1529            format_display_path(client, root),
1530            "apps/client/src/index.ts"
1531        );
1532    }
1533
1534    #[test]
1535    fn format_display_path_angular_component_renders_parent_directory() {
1536        let root = Path::new("/project");
1537        let path = Path::new(
1538            "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1539        );
1540        assert_eq!(
1541            format_display_path(path, root),
1542            "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1543        );
1544    }
1545
1546    #[test]
1547    fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1548        let root = Path::new("/other");
1549        let path = Path::new("/project/src/utils.ts");
1550        let rendered = format_display_path(path, root);
1551        assert!(rendered.contains("project"));
1552        assert!(rendered.ends_with("utils.ts"));
1553        assert!(!rendered.contains('\\'));
1554    }
1555
1556    #[test]
1557    fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1558        let root = Path::new("/project");
1559        let path = Path::new("/project/src/sub\\file.ts");
1560        let rendered = format_display_path(path, root);
1561        assert!(
1562            !rendered.contains('\\'),
1563            "backslashes must be normalized: {rendered}"
1564        );
1565    }
1566
1567    #[test]
1568    fn format_display_path_handles_brackets_verbatim() {
1569        let root = Path::new("/project");
1570        let path = Path::new("/project/app/[slug]/page.tsx");
1571        assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1572    }
1573
1574    #[test]
1575    fn format_display_path_path_equals_root_returns_empty() {
1576        let root = Path::new("/project");
1577        let path = Path::new("/project");
1578        assert_eq!(format_display_path(path, root), "");
1579    }
1580
1581    #[test]
1582    fn format_display_path_basename_only_when_path_is_at_root() {
1583        let root = Path::new("/project");
1584        let path = Path::new("/project/Cargo.toml");
1585        assert_eq!(format_display_path(path, root), "Cargo.toml");
1586    }
1587
1588    #[test]
1589    fn relative_uri_produces_forward_slash_path() {
1590        let root = PathBuf::from("/project");
1591        let path = root.join("src").join("utils.ts");
1592        let uri = relative_uri(&path, &root);
1593        assert_eq!(uri, "src/utils.ts");
1594    }
1595
1596    #[test]
1597    fn relative_uri_encodes_brackets() {
1598        let root = PathBuf::from("/project");
1599        let path = root.join("src/app/[...slug]/page.tsx");
1600        let uri = relative_uri(&path, &root);
1601        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1602    }
1603
1604    #[test]
1605    fn relative_uri_encodes_nested_dynamic_routes() {
1606        let root = PathBuf::from("/project");
1607        let path = root.join("src/app/[slug]/[id]/page.tsx");
1608        let uri = relative_uri(&path, &root);
1609        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1610    }
1611
1612    #[test]
1613    fn relative_uri_no_common_prefix_returns_full() {
1614        let root = PathBuf::from("/other");
1615        let path = PathBuf::from("/project/src/utils.ts");
1616        let uri = relative_uri(&path, &root);
1617        assert!(uri.contains("project"));
1618        assert!(uri.contains("utils.ts"));
1619    }
1620
1621    #[test]
1622    fn severity_error_maps_to_level_error() {
1623        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1624    }
1625
1626    #[test]
1627    fn severity_warn_maps_to_level_warn() {
1628        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1629    }
1630
1631    #[test]
1632    fn severity_off_maps_to_level_info() {
1633        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1634    }
1635
1636    #[test]
1637    fn normalize_uri_single_bracket_pair() {
1638        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1639    }
1640
1641    #[test]
1642    fn normalize_uri_catch_all_route() {
1643        assert_eq!(
1644            normalize_uri("app/[...slug]/page.tsx"),
1645            "app/%5B...slug%5D/page.tsx"
1646        );
1647    }
1648
1649    #[test]
1650    fn normalize_uri_optional_catch_all_route() {
1651        assert_eq!(
1652            normalize_uri("app/[[...slug]]/page.tsx"),
1653            "app/%5B%5B...slug%5D%5D/page.tsx"
1654        );
1655    }
1656
1657    #[test]
1658    fn normalize_uri_multiple_dynamic_segments() {
1659        assert_eq!(
1660            normalize_uri("app/[lang]/posts/[id]"),
1661            "app/%5Blang%5D/posts/%5Bid%5D"
1662        );
1663    }
1664
1665    #[test]
1666    fn normalize_uri_no_special_chars() {
1667        let plain = "src/components/Button.tsx";
1668        assert_eq!(normalize_uri(plain), plain);
1669    }
1670
1671    #[test]
1672    fn normalize_uri_only_backslashes() {
1673        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1674    }
1675
1676    #[test]
1677    fn relative_path_identical_paths_returns_empty() {
1678        let root = Path::new("/project");
1679        assert_eq!(relative_path(root, root), Path::new(""));
1680    }
1681
1682    #[test]
1683    fn relative_path_partial_name_match_not_stripped() {
1684        let root = Path::new("/project");
1685        let path = Path::new("/project-two/src/a.ts");
1686        assert_eq!(relative_path(path, root), path);
1687    }
1688
1689    #[test]
1690    fn relative_uri_combines_stripping_and_encoding() {
1691        let root = PathBuf::from("/project");
1692        let path = root.join("src/app/[slug]/page.tsx");
1693        let uri = relative_uri(&path, &root);
1694        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1695        assert!(!uri.starts_with('/'));
1696    }
1697
1698    #[test]
1699    fn relative_uri_at_root_file() {
1700        let root = PathBuf::from("/project");
1701        let path = root.join("index.ts");
1702        assert_eq!(relative_uri(&path, &root), "index.ts");
1703    }
1704
1705    #[test]
1706    fn severity_to_level_is_const_evaluable() {
1707        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1708        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1709        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1710        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1711        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1712        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1713    }
1714
1715    #[test]
1716    fn level_is_copy() {
1717        let level = severity_to_level(Severity::Error);
1718        let copy = level;
1719        assert!(matches!(level, Level::Error));
1720        assert!(matches!(copy, Level::Error));
1721    }
1722
1723    #[test]
1724    fn print_results_rejects_badge_for_dead_code_reports() {
1725        let root = Path::new("/project");
1726        let rules = RulesConfig::default();
1727        let ctx = test_context(root, &rules);
1728
1729        let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1730
1731        assert_eq!(code, ExitCode::from(2));
1732    }
1733
1734    #[test]
1735    fn print_duplication_report_rejects_badge_format() {
1736        let root = Path::new("/project");
1737        let rules = RulesConfig::default();
1738        let ctx = test_context(root, &rules);
1739
1740        let code =
1741            print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1742
1743        assert_eq!(code, ExitCode::from(2));
1744    }
1745
1746    #[test]
1747    fn elide_common_prefix_shared_dir() {
1748        assert_eq!(
1749            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1750            "B.tsx"
1751        );
1752    }
1753
1754    #[test]
1755    fn elide_common_prefix_partial_shared() {
1756        assert_eq!(
1757            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1758            "utils/B.tsx"
1759        );
1760    }
1761
1762    #[test]
1763    fn elide_common_prefix_no_shared() {
1764        assert_eq!(
1765            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1766            "pkg-b/src/B.tsx"
1767        );
1768    }
1769
1770    #[test]
1771    fn elide_common_prefix_identical_files() {
1772        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1773    }
1774
1775    #[test]
1776    fn elide_common_prefix_no_dirs() {
1777        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1778    }
1779
1780    #[test]
1781    fn elide_common_prefix_deep_monorepo() {
1782        assert_eq!(
1783            elide_common_prefix(
1784                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1785                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1786            ),
1787            "SearchSelectItem.tsx"
1788        );
1789    }
1790
1791    #[test]
1792    fn split_dir_filename_with_dir() {
1793        let (dir, file) = split_dir_filename("src/utils/index.ts");
1794        assert_eq!(dir, "src/utils/");
1795        assert_eq!(file, "index.ts");
1796    }
1797
1798    #[test]
1799    fn split_dir_filename_no_dir() {
1800        let (dir, file) = split_dir_filename("file.ts");
1801        assert_eq!(dir, "");
1802        assert_eq!(file, "file.ts");
1803    }
1804
1805    #[test]
1806    fn split_dir_filename_deeply_nested() {
1807        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1808        assert_eq!(dir, "a/b/c/d/");
1809        assert_eq!(file, "e.ts");
1810    }
1811
1812    #[test]
1813    fn split_dir_filename_trailing_slash() {
1814        let (dir, file) = split_dir_filename("src/");
1815        assert_eq!(dir, "src/");
1816        assert_eq!(file, "");
1817    }
1818
1819    #[test]
1820    fn split_dir_filename_empty() {
1821        let (dir, file) = split_dir_filename("");
1822        assert_eq!(dir, "");
1823        assert_eq!(file, "");
1824    }
1825
1826    #[test]
1827    fn plural_zero_is_plural() {
1828        assert_eq!(plural(0), "s");
1829    }
1830
1831    #[test]
1832    fn plural_one_is_singular() {
1833        assert_eq!(plural(1), "");
1834    }
1835
1836    #[test]
1837    fn plural_two_is_plural() {
1838        assert_eq!(plural(2), "s");
1839    }
1840
1841    #[test]
1842    fn plural_large_number() {
1843        assert_eq!(plural(999), "s");
1844    }
1845
1846    #[test]
1847    fn elide_common_prefix_empty_base() {
1848        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1849    }
1850
1851    #[test]
1852    fn elide_common_prefix_empty_target() {
1853        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1854    }
1855
1856    #[test]
1857    fn elide_common_prefix_both_empty() {
1858        assert_eq!(elide_common_prefix("", ""), "");
1859    }
1860
1861    #[test]
1862    fn elide_common_prefix_same_file_different_extension() {
1863        assert_eq!(
1864            elide_common_prefix("src/utils.ts", "src/utils.js"),
1865            "utils.js"
1866        );
1867    }
1868
1869    #[test]
1870    fn elide_common_prefix_partial_filename_match_not_stripped() {
1871        assert_eq!(
1872            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1873            "AppUtils.tsx"
1874        );
1875    }
1876
1877    #[test]
1878    fn elide_common_prefix_identical_paths() {
1879        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1880    }
1881
1882    #[test]
1883    fn split_dir_filename_single_slash() {
1884        let (dir, file) = split_dir_filename("/file.ts");
1885        assert_eq!(dir, "/");
1886        assert_eq!(file, "file.ts");
1887    }
1888
1889    #[test]
1890    fn emit_json_returns_success_for_valid_value() {
1891        let value = serde_json::json!({"key": "value"});
1892        let code = emit_json(&value, "test");
1893        assert_eq!(code, ExitCode::SUCCESS);
1894    }
1895
1896    mod proptests {
1897        use super::*;
1898        use proptest::prelude::*;
1899
1900        proptest! {
1901            /// split_dir_filename always reconstructs the original path.
1902            #[test]
1903            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1904                let (dir, file) = split_dir_filename(&path);
1905                let reconstructed = format!("{dir}{file}");
1906                prop_assert_eq!(
1907                    reconstructed, path,
1908                    "dir+file should reconstruct the original path"
1909                );
1910            }
1911
1912            /// plural returns either "" or "s", nothing else.
1913            #[test]
1914            fn plural_returns_empty_or_s(n: usize) {
1915                let result = plural(n);
1916                prop_assert!(
1917                    result.is_empty() || result == "s",
1918                    "plural should return \"\" or \"s\", got {:?}",
1919                    result
1920                );
1921            }
1922
1923            /// plural(1) is always "" and plural(n != 1) is always "s".
1924            #[test]
1925            fn plural_singular_only_for_one(n: usize) {
1926                let result = plural(n);
1927                if n == 1 {
1928                    prop_assert_eq!(result, "", "plural(1) should be empty");
1929                } else {
1930                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1931                }
1932            }
1933
1934            /// normalize_uri never panics and always replaces backslashes.
1935            #[test]
1936            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1937                let result = normalize_uri(&path);
1938                prop_assert!(
1939                    !result.contains('\\'),
1940                    "Result should not contain backslashes: {result}"
1941                );
1942            }
1943
1944            /// normalize_uri always encodes brackets.
1945            #[test]
1946            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1947                let result = normalize_uri(&path);
1948                prop_assert!(
1949                    !result.contains('[') && !result.contains(']'),
1950                    "Result should not contain raw brackets: {result}"
1951                );
1952            }
1953
1954            /// elide_common_prefix always returns a suffix of or equal to target.
1955            #[test]
1956            fn elide_common_prefix_returns_suffix_of_target(
1957                base in "[a-zA-Z0-9_./]{0,50}",
1958                target in "[a-zA-Z0-9_./]{0,50}",
1959            ) {
1960                let result = elide_common_prefix(&base, &target);
1961                prop_assert!(
1962                    target.ends_with(result),
1963                    "Result {:?} should be a suffix of target {:?}",
1964                    result, target
1965                );
1966            }
1967
1968            /// relative_path never panics.
1969            #[test]
1970            fn relative_path_never_panics(
1971                root in "/[a-zA-Z0-9_/]{0,30}",
1972                suffix in "[a-zA-Z0-9_./]{0,30}",
1973            ) {
1974                let root_path = Path::new(&root);
1975                let full = PathBuf::from(format!("{root}/{suffix}"));
1976                let _ = relative_path(&full, root_path);
1977            }
1978        }
1979    }
1980}