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