Skip to main content

fallow_cli/report/
mod.rs

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