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