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