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