Skip to main content

fallow_cli/report/
mod.rs

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