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    let incomplete = ci::required_type_aware_incomplete(ctx.type_aware);
430    let conclusion = incomplete.then_some(fallow_output::PrDecisionConclusion::Failure);
431    let status_message = incomplete.then_some(ci::TYPE_AWARE_INCOMPLETE_MESSAGE);
432    print_ci_comment_format_with_status("dead-code", &value, output, conclusion, status_message)
433        .unwrap_or_else(|| {
434            eprintln!("Error: badge format is only supported for the health command");
435            ExitCode::from(2)
436        })
437}
438
439/// Render grouped results across all output formats.
440#[must_use]
441fn print_grouped_results(
442    groups: &[grouping::ResultGroup],
443    original: &AnalysisResults,
444    ctx: &ReportContext<'_>,
445    output: OutputFormat,
446    resolver: &OwnershipResolver,
447) -> ExitCode {
448    match output {
449        OutputFormat::Human => {
450            human::print_grouped_human(&human::PrintGroupedHumanInput {
451                groups,
452                root: ctx.root,
453                rules: ctx.rules,
454                elapsed: ctx.elapsed,
455                quiet: ctx.quiet,
456                resolver: Some(resolver),
457                explain: ctx.explain,
458            });
459            ExitCode::SUCCESS
460        }
461        OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
462            groups,
463            original,
464            root: ctx.root,
465            elapsed: ctx.elapsed,
466            explain: ctx.explain,
467            type_aware: ctx.type_aware,
468            resolver,
469            config_fixable: ctx.config_fixable,
470            json_style: ctx.json_style,
471        }),
472        OutputFormat::Compact => {
473            compact::print_grouped_compact(groups, ctx.root);
474            compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
475            ExitCode::SUCCESS
476        }
477        OutputFormat::Markdown => {
478            markdown::print_grouped_markdown(groups, ctx.root);
479            markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
480            ExitCode::SUCCESS
481        }
482        OutputFormat::Sarif => {
483            sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver, ctx.type_aware)
484        }
485        OutputFormat::CodeClimate => {
486            codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
487        }
488        // The GitHub formats have no grouping concept; render ungrouped from
489        // the original results (same fallback the PR-comment formats use).
490        OutputFormat::GithubAnnotations => print_check_github_annotations(original, ctx),
491        OutputFormat::GithubSummary => {
492            print_check_github_format(original, ctx, GithubTarget::Summary)
493        }
494        ci_format => print_results_ci_comment(original, ctx, ci_format),
495    }
496}
497
498/// Print duplication analysis results in the configured format.
499#[must_use]
500pub(crate) fn print_duplication_report(
501    report: &DuplicationReport,
502    ctx: &ReportContext<'_>,
503    output: OutputFormat,
504) -> ExitCode {
505    if let Some(ref resolver) = ctx.group_by {
506        let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
507        return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
508    }
509
510    match output {
511        OutputFormat::Human => {
512            if ctx.summary {
513                human::dupes::print_duplication_summary(
514                    report,
515                    ctx.elapsed,
516                    ctx.quiet,
517                    ctx.summary_heading,
518                );
519            } else {
520                human::print_duplication_human(
521                    report,
522                    ctx.root,
523                    ctx.elapsed,
524                    ctx.quiet,
525                    ctx.show_explain_tip,
526                    ctx.explain,
527                );
528            }
529            ExitCode::SUCCESS
530        }
531        OutputFormat::Json => {
532            json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain, ctx.json_style)
533        }
534        OutputFormat::Compact => {
535            compact::print_duplication_compact(report, ctx.root);
536            ExitCode::SUCCESS
537        }
538        OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
539        OutputFormat::Markdown => {
540            markdown::print_duplication_markdown(report, ctx.root);
541            ExitCode::SUCCESS
542        }
543        OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
544        OutputFormat::GithubAnnotations => {
545            print_dupes_github_format(report, ctx, GithubTarget::Annotations)
546        }
547        OutputFormat::GithubSummary => {
548            print_dupes_github_format(report, ctx, GithubTarget::Summary)
549        }
550        ci_format => print_duplication_ci_comment(report, ctx.root, ci_format),
551    }
552}
553
554/// Render duplication results in a GitHub-native format from the same JSON
555/// envelope `--format json` serializes.
556fn print_dupes_github_format(
557    report: &DuplicationReport,
558    ctx: &ReportContext<'_>,
559    target: GithubTarget,
560) -> ExitCode {
561    match json::api_duplication_json_document(report, ctx.root, ctx.elapsed, ctx.explain) {
562        Ok(envelope) => print_github_format(
563            github_annotations::EnvelopeKind::Dupes,
564            &envelope,
565            ctx.root,
566            target,
567        ),
568        Err(e) => {
569            eprintln!("Error: failed to serialize duplication report: {e}");
570            ExitCode::from(2)
571        }
572    }
573}
574
575/// Render the CI comment / review / badge fallback arms for duplication results.
576fn print_duplication_ci_comment(
577    report: &DuplicationReport,
578    root: &Path,
579    output: OutputFormat,
580) -> ExitCode {
581    let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
582    let value = fallow_output::codeclimate_issues_to_value(&issues);
583    print_ci_comment_format("dupes", &value, output).unwrap_or_else(|| {
584        eprintln!("Error: badge format is only supported for the health command");
585        ExitCode::from(2)
586    })
587}
588
589/// Render grouped duplication results across all output formats.
590#[must_use]
591fn print_grouped_duplication_report(
592    report: &DuplicationReport,
593    grouping: &DuplicationGrouping,
594    ctx: &ReportContext<'_>,
595    output: OutputFormat,
596    resolver: &OwnershipResolver,
597) -> ExitCode {
598    match output {
599        OutputFormat::Human => {
600            human::print_grouped_duplication_human(
601                report,
602                grouping,
603                ctx.root,
604                ctx.elapsed,
605                ctx.quiet,
606            );
607            ExitCode::SUCCESS
608        }
609        OutputFormat::Json => json::print_grouped_duplication_json(
610            report,
611            grouping,
612            ctx.root,
613            ctx.elapsed,
614            ctx.explain,
615            ctx.json_style,
616        ),
617        OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
618        OutputFormat::CodeClimate => {
619            codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
620        }
621        OutputFormat::PrCommentGithub
622        | OutputFormat::PrCommentGitlab
623        | OutputFormat::ReviewGithub
624        | OutputFormat::ReviewGitlab => print_duplication_ci_comment(report, ctx.root, output),
625        // The GitHub formats have no grouping concept; render ungrouped (same
626        // fallback the PR-comment formats use).
627        OutputFormat::GithubAnnotations => {
628            print_dupes_github_format(report, ctx, GithubTarget::Annotations)
629        }
630        OutputFormat::GithubSummary => {
631            print_dupes_github_format(report, ctx, GithubTarget::Summary)
632        }
633        OutputFormat::Compact => {
634            compact::print_duplication_compact(report, ctx.root);
635            warn_dupes_grouping_unsupported(grouping, "compact");
636            ExitCode::SUCCESS
637        }
638        OutputFormat::Markdown => {
639            markdown::print_duplication_markdown(report, ctx.root);
640            warn_dupes_grouping_unsupported(grouping, "markdown");
641            ExitCode::SUCCESS
642        }
643        OutputFormat::Badge => {
644            eprintln!("Error: badge format is only supported for the health command");
645            ExitCode::from(2)
646        }
647    }
648}
649
650/// Dispatch a PR-comment / review CI format from a precomputed CodeClimate value.
651///
652/// Returns `Some(exit_code)` for the four CI comment/review formats and `None`
653/// for every other output format, so callers keep their exhaustive match arms.
654fn print_ci_comment_format(
655    analysis: &str,
656    value: &serde_json::Value,
657    output: OutputFormat,
658) -> Option<ExitCode> {
659    print_ci_comment_format_with_status(analysis, value, output, None, None)
660}
661
662fn print_ci_comment_format_with_status(
663    analysis: &str,
664    value: &serde_json::Value,
665    output: OutputFormat,
666    conclusion: Option<fallow_output::PrDecisionConclusion>,
667    status_message: Option<&str>,
668) -> Option<ExitCode> {
669    let exit = match output {
670        OutputFormat::PrCommentGithub => conclusion.map_or_else(
671            || ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Github, value),
672            |conclusion| {
673                ci::pr_comment::print_pr_comment_with_status(
674                    analysis,
675                    ci::pr_comment::Provider::Github,
676                    value,
677                    conclusion,
678                    status_message,
679                )
680            },
681        ),
682        OutputFormat::PrCommentGitlab => conclusion.map_or_else(
683            || ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Gitlab, value),
684            |conclusion| {
685                ci::pr_comment::print_pr_comment_with_status(
686                    analysis,
687                    ci::pr_comment::Provider::Gitlab,
688                    value,
689                    conclusion,
690                    status_message,
691                )
692            },
693        ),
694        OutputFormat::ReviewGithub => conclusion.map_or_else(
695            || ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Github, value),
696            |conclusion| {
697                ci::review::print_review_envelope_with_conclusion(
698                    analysis,
699                    ci::pr_comment::Provider::Github,
700                    value,
701                    conclusion,
702                    status_message,
703                )
704            },
705        ),
706        OutputFormat::ReviewGitlab => conclusion.map_or_else(
707            || ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Gitlab, value),
708            |conclusion| {
709                ci::review::print_review_envelope_with_conclusion(
710                    analysis,
711                    ci::pr_comment::Provider::Gitlab,
712                    value,
713                    conclusion,
714                    status_message,
715                )
716            },
717        ),
718        _ => return None,
719    };
720    Some(exit)
721}
722
723fn warn_dupes_grouping_unsupported(grouping: &DuplicationGrouping, format: &str) {
724    eprintln!(
725        "note: --group-by {} is not supported for {format} duplication output, falling back to \
726         ungrouped output (use --format json for the full grouped envelope)",
727        grouping.mode
728    );
729}
730
731/// Print health (complexity) analysis results in the configured format.
732///
733/// `grouping` and `group_resolver` carry per-group output produced by
734/// `--group-by`:
735/// - **JSON** renders the grouped envelope (`{ grouped_by, vital_signs,
736///   health_score, groups: [...] }`).
737/// - **Human** prints a per-group summary block (score / files / hot / p90)
738///   after the project-level report.
739/// - **SARIF** and **CodeClimate** tag every per-finding result with the
740///   resolver-derived group key (`properties.group` for SARIF, top-level
741///   `group` for CodeClimate) so CI consumers like GitHub Code Scanning
742///   and GitLab Code Quality can partition findings per team / package
743///   without re-parsing the project structure.
744/// - **Compact**, **Markdown**, and **Badge** fall back to ungrouped output
745///   and emit a one-line stderr note pointing at `--format json` for the
746///   richer grouped envelope.
747#[must_use]
748pub(crate) fn print_health_report(
749    report: &fallow_output::HealthReport,
750    grouping: Option<&fallow_output::HealthGrouping>,
751    group_resolver: Option<&grouping::OwnershipResolver>,
752    ctx: &ReportContext<'_>,
753    output: OutputFormat,
754) -> ExitCode {
755    match output {
756        OutputFormat::Human => {
757            print_health_human_report(report, grouping, ctx);
758            ExitCode::SUCCESS
759        }
760        OutputFormat::Compact => {
761            compact::print_health_compact(report, ctx.root);
762            compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
763            warn_grouping_unsupported(grouping, "compact");
764            ExitCode::SUCCESS
765        }
766        OutputFormat::Markdown => {
767            markdown::print_health_markdown(report, ctx.root);
768            markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
769            warn_grouping_unsupported(grouping, "markdown");
770            ExitCode::SUCCESS
771        }
772        OutputFormat::Sarif => match group_resolver {
773            Some(resolver) => {
774                sarif::print_grouped_health_sarif(report, ctx.root, resolver, ctx.type_aware)
775            }
776            None => sarif::print_health_sarif(report, ctx.root, ctx.type_aware),
777        },
778        OutputFormat::Json => match grouping {
779            Some(grouping) => json::print_grouped_health_json(
780                report,
781                grouping,
782                ctx.root,
783                ctx.elapsed,
784                ctx.explain,
785                ctx.type_aware,
786                ctx.json_style,
787            ),
788            None => json::print_health_json(
789                report,
790                ctx.root,
791                ctx.elapsed,
792                ctx.explain,
793                ctx.type_aware,
794                ctx.json_style,
795            ),
796        },
797        OutputFormat::CodeClimate => match group_resolver {
798            Some(resolver) => {
799                codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
800            }
801            None => codeclimate::print_health_codeclimate(report, ctx.root),
802        },
803        OutputFormat::PrCommentGithub
804        | OutputFormat::PrCommentGitlab
805        | OutputFormat::ReviewGithub
806        | OutputFormat::ReviewGitlab => print_health_ci_comment(report, ctx.root, output),
807        // The GitHub formats have no grouping concept; render ungrouped (same
808        // fallback the PR-comment formats use).
809        OutputFormat::GithubAnnotations => {
810            print_health_github_format(report, ctx, GithubTarget::Annotations)
811        }
812        OutputFormat::GithubSummary => {
813            print_health_github_format(report, ctx, GithubTarget::Summary)
814        }
815        OutputFormat::Badge => {
816            warn_grouping_unsupported(grouping, "badge");
817            badge::print_health_badge(report)
818        }
819    }
820}
821
822/// Render health results in a GitHub-native format from the same JSON
823/// envelope `--format json` serializes.
824fn print_health_github_format(
825    report: &fallow_output::HealthReport,
826    ctx: &ReportContext<'_>,
827    target: GithubTarget,
828) -> ExitCode {
829    match json::api_health_json_document(report, ctx.root, ctx.elapsed, ctx.explain, ctx.type_aware)
830    {
831        Ok(envelope) => print_github_format(
832            github_annotations::EnvelopeKind::Health,
833            &envelope,
834            ctx.root,
835            target,
836        ),
837        Err(e) => {
838            eprintln!("Error: failed to serialize health report: {e}");
839            ExitCode::from(2)
840        }
841    }
842}
843
844/// Render the human-format health report, including the per-group summary block.
845fn print_health_human_report(
846    report: &fallow_output::HealthReport,
847    grouping: Option<&fallow_output::HealthGrouping>,
848    ctx: &ReportContext<'_>,
849) {
850    if ctx.summary {
851        human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
852        return;
853    }
854    human::print_health_human(&human::PrintHealthHumanInput {
855        report,
856        root: ctx.root,
857        elapsed: ctx.elapsed,
858        quiet: ctx.quiet,
859        show_explain_tip: ctx.show_explain_tip,
860        explain: ctx.explain,
861        skip_score_and_trend: ctx.skip_score_and_trend,
862        css_requested: ctx.css_requested,
863        type_aware: ctx.type_aware,
864    });
865    if let Some(grouping) = grouping {
866        human::print_health_grouping(grouping, ctx.root, ctx.quiet);
867    }
868}
869
870/// Render the CI comment / review fallback arms for health results.
871fn print_health_ci_comment(
872    report: &fallow_output::HealthReport,
873    root: &Path,
874    output: OutputFormat,
875) -> ExitCode {
876    let issues = codeclimate::api_health_codeclimate_issues(report, root);
877    let value = fallow_output::codeclimate_issues_to_value(&issues);
878    print_ci_comment_format("health", &value, output).unwrap_or_else(|| {
879        eprintln!("Error: badge format is only supported for the health command");
880        ExitCode::from(2)
881    })
882}
883
884fn warn_grouping_unsupported(grouping: Option<&fallow_output::HealthGrouping>, format: &str) {
885    if let Some(g) = grouping {
886        eprintln!(
887            "note: --group-by {} is not supported for {format} output, falling back to \
888             ungrouped output (use --format json for the full grouped envelope)",
889            g.mode
890        );
891    }
892}
893
894/// Print cross-reference findings (duplicated code that is also dead code).
895///
896/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
897pub(crate) fn print_cross_reference_findings(
898    cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
899    root: &Path,
900    quiet: bool,
901    output: OutputFormat,
902) {
903    human::print_cross_reference_findings(cross_ref, root, quiet, output);
904}
905
906/// Print export trace results.
907pub(crate) fn print_export_trace(
908    trace: &ExportTrace,
909    format: OutputFormat,
910    json_style: crate::json_style::JsonStyle,
911) {
912    match format {
913        OutputFormat::Json => json::print_trace_json(trace, json_style),
914        _ => human::print_export_trace_human(trace),
915    }
916}
917
918/// Print a syntactic export trace with its authoritative checker-backed
919/// semantic section and optional field definitions.
920pub(crate) fn print_semantic_export_trace(
921    trace: &ExportTrace,
922    format: OutputFormat,
923    explain: bool,
924    json_style: crate::json_style::JsonStyle,
925) {
926    match format {
927        OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
928        _ => human::print_export_trace_human(trace),
929    }
930}
931
932/// Print class-member trace results (the `--trace FILE:MEMBER` fallback).
933pub(crate) fn print_class_member_trace(
934    trace: &fallow_engine::trace::ClassMemberTrace,
935    format: OutputFormat,
936    json_style: crate::json_style::JsonStyle,
937) {
938    match format {
939        OutputFormat::Json => json::print_trace_json(trace, json_style),
940        _ => human::print_class_member_trace_human(trace),
941    }
942}
943
944/// Print a class-member trace with authoritative checker-backed evidence and
945/// optional field definitions.
946pub(crate) fn print_semantic_class_member_trace(
947    trace: &fallow_engine::trace::ClassMemberTrace,
948    format: OutputFormat,
949    explain: bool,
950    json_style: crate::json_style::JsonStyle,
951) {
952    match format {
953        OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
954        _ => human::print_class_member_trace_human(trace),
955    }
956}
957
958/// Print file trace results.
959pub(crate) fn print_file_trace(
960    trace: &FileTrace,
961    format: OutputFormat,
962    json_style: crate::json_style::JsonStyle,
963) {
964    match format {
965        OutputFormat::Json => json::print_trace_json(trace, json_style),
966        _ => human::print_file_trace_human(trace),
967    }
968}
969
970/// Print dependency trace results.
971pub(crate) fn print_dependency_trace(
972    trace: &DependencyTrace,
973    format: OutputFormat,
974    json_style: crate::json_style::JsonStyle,
975) {
976    match format {
977        OutputFormat::Json => json::print_trace_json(trace, json_style),
978        _ => human::print_dependency_trace_human(trace),
979    }
980}
981
982/// Print clone trace results.
983pub(crate) fn print_clone_trace(
984    trace: &CloneTrace,
985    root: &Path,
986    format: OutputFormat,
987    json_style: crate::json_style::JsonStyle,
988) {
989    match format {
990        OutputFormat::Json => json::print_trace_json(trace, json_style),
991        _ => human::print_clone_trace_human(trace, root),
992    }
993}
994
995/// Print impact-closure trace results. JSON only emits the structured
996/// closure; human renders a short summary.
997pub(crate) fn print_impact_closure_trace(
998    trace: &ImpactClosureTrace,
999    format: OutputFormat,
1000    json_style: crate::json_style::JsonStyle,
1001) {
1002    match format {
1003        OutputFormat::Json => json::print_trace_json(trace, json_style),
1004        _ => {
1005            outln!("Impact closure for {}", trace.seed);
1006            outln!(
1007                "  affected beyond the diff: {} file{}",
1008                trace.affected_not_shown.len(),
1009                plural(trace.affected_not_shown.len())
1010            );
1011            for gap in &trace.coordination_gap {
1012                outln!(
1013                    "  coordination gap: {} consumes {}",
1014                    gap.consumer_file,
1015                    gap.consumed_symbols.join(", ")
1016                );
1017            }
1018        }
1019    }
1020}
1021
1022/// Print exact-symbol impact and targeted-test recommendations.
1023pub(crate) fn print_symbol_impact(
1024    impact: &SemanticSymbolImpact,
1025    format: OutputFormat,
1026    explain: bool,
1027    json_style: crate::json_style::JsonStyle,
1028) {
1029    match format {
1030        OutputFormat::Json => json::print_semantic_impact_json(impact, explain, json_style),
1031        _ => human::print_symbol_impact_human(impact),
1032    }
1033}
1034
1035/// Print pipeline performance timings.
1036/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
1037pub(crate) fn print_performance(
1038    timings: &PipelineTimings,
1039    format: OutputFormat,
1040    json_style: crate::json_style::JsonStyle,
1041) {
1042    match format {
1043        OutputFormat::Json => match json_style.serialize(timings) {
1044            Ok(json) => eprintln!("{json}"),
1045            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1046        },
1047        _ => human::print_performance_human(timings),
1048    }
1049}
1050
1051/// Print health pipeline performance timings.
1052/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
1053pub(crate) fn print_health_performance(
1054    timings: &fallow_output::HealthTimings,
1055    format: OutputFormat,
1056    json_style: crate::json_style::JsonStyle,
1057) {
1058    match format {
1059        OutputFormat::Json => match json_style.serialize(timings) {
1060            Ok(json) => eprintln!("{json}"),
1061            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1062        },
1063        _ => human::print_health_performance_human(timings),
1064    }
1065}
1066
1067#[allow(
1068    unused_imports,
1069    reason = "target-dependent: used in lib, unused in bin"
1070)]
1071pub use fallow_api::build_compact_lines;
1072#[allow(
1073    unused_imports,
1074    reason = "target-dependent: used in lib, unused in bin"
1075)]
1076pub use fallow_api::build_duplication_markdown;
1077#[allow(
1078    unused_imports,
1079    reason = "target-dependent: used in lib, unused in bin"
1080)]
1081pub use fallow_api::build_health_markdown;
1082#[allow(
1083    unused_imports,
1084    reason = "target-dependent: used in lib, unused in bin"
1085)]
1086pub use fallow_api::build_markdown;
1087#[allow(
1088    clippy::redundant_pub_crate,
1089    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1090)]
1091pub(crate) use json::api_check_json_payload_with_config_fixable;
1092#[allow(
1093    clippy::redundant_pub_crate,
1094    reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1095)]
1096pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1097#[allow(
1098    unused_imports,
1099    reason = "target-dependent: used in lib, unused in bin"
1100)]
1101#[allow(
1102    clippy::redundant_pub_crate,
1103    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1104)]
1105pub(crate) use sarif::api_health_sarif_document;
1106#[allow(
1107    unused_imports,
1108    reason = "target-dependent: used in lib, unused in bin"
1109)]
1110#[allow(
1111    clippy::redundant_pub_crate,
1112    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1113)]
1114pub(crate) use sarif::api_sarif_document;
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use std::path::{Path, PathBuf};
1120
1121    #[test]
1122    fn format_bytes_pivots_at_power_of_1024() {
1123        assert_eq!(format_bytes(0), "0 B");
1124        assert_eq!(format_bytes(1023), "1023 B");
1125        assert_eq!(format_bytes(1024), "1 KiB");
1126        assert_eq!(format_bytes(2048), "2 KiB");
1127        assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1128        assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1129        assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1130    }
1131
1132    fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1133        ReportContext {
1134            root,
1135            rules,
1136            elapsed: Duration::default(),
1137            quiet: true,
1138            explain: false,
1139            type_aware: None,
1140            type_aware_scope: None,
1141            group_by: None,
1142            top: None,
1143            summary: false,
1144            summary_heading: false,
1145            show_explain_tip: false,
1146            baseline_matched: None,
1147            config_fixable: false,
1148            skip_score_and_trend: false,
1149            css_requested: false,
1150            json_style: crate::json_style::JsonStyle::Compact,
1151        }
1152    }
1153
1154    #[test]
1155    fn normalize_uri_forward_slashes_unchanged() {
1156        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1157    }
1158
1159    #[test]
1160    fn normalize_uri_backslashes_replaced() {
1161        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1162    }
1163
1164    #[test]
1165    fn normalize_uri_mixed_slashes() {
1166        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1167    }
1168
1169    #[test]
1170    fn normalize_uri_path_with_spaces() {
1171        assert_eq!(
1172            normalize_uri("src\\my folder\\file.ts"),
1173            "src/my folder/file.ts"
1174        );
1175    }
1176
1177    #[test]
1178    fn normalize_uri_empty_string() {
1179        assert_eq!(normalize_uri(""), "");
1180    }
1181
1182    #[test]
1183    fn relative_path_strips_root_prefix() {
1184        let root = Path::new("/project");
1185        let path = Path::new("/project/src/utils.ts");
1186        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1187    }
1188
1189    #[test]
1190    fn relative_path_returns_full_path_when_no_prefix() {
1191        let root = Path::new("/other");
1192        let path = Path::new("/project/src/utils.ts");
1193        assert_eq!(relative_path(path, root), path);
1194    }
1195
1196    #[test]
1197    fn relative_path_at_root_returns_empty_or_file() {
1198        let root = Path::new("/project");
1199        let path = Path::new("/project/file.ts");
1200        assert_eq!(relative_path(path, root), Path::new("file.ts"));
1201    }
1202
1203    #[test]
1204    fn relative_path_deeply_nested() {
1205        let root = Path::new("/project");
1206        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1207        assert_eq!(
1208            relative_path(path, root),
1209            Path::new("packages/ui/src/components/Button.tsx")
1210        );
1211    }
1212
1213    #[test]
1214    fn format_display_path_returns_workspace_relative() {
1215        let root = Path::new("/project");
1216        let path = Path::new("/project/apps/server/src/index.ts");
1217        assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1218    }
1219
1220    #[test]
1221    fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1222        let root = Path::new("/project");
1223        let server = Path::new("/project/apps/server/src/index.ts");
1224        let client = Path::new("/project/apps/client/src/index.ts");
1225        assert_eq!(
1226            format_display_path(server, root),
1227            "apps/server/src/index.ts"
1228        );
1229        assert_eq!(
1230            format_display_path(client, root),
1231            "apps/client/src/index.ts"
1232        );
1233    }
1234
1235    #[test]
1236    fn format_display_path_angular_component_renders_parent_directory() {
1237        let root = Path::new("/project");
1238        let path = Path::new(
1239            "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1240        );
1241        assert_eq!(
1242            format_display_path(path, root),
1243            "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1244        );
1245    }
1246
1247    #[test]
1248    fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1249        let root = Path::new("/other");
1250        let path = Path::new("/project/src/utils.ts");
1251        let rendered = format_display_path(path, root);
1252        assert!(rendered.contains("project"));
1253        assert!(rendered.ends_with("utils.ts"));
1254        assert!(!rendered.contains('\\'));
1255    }
1256
1257    #[test]
1258    fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1259        let root = Path::new("/project");
1260        let path = Path::new("/project/src/sub\\file.ts");
1261        let rendered = format_display_path(path, root);
1262        assert!(
1263            !rendered.contains('\\'),
1264            "backslashes must be normalized: {rendered}"
1265        );
1266    }
1267
1268    #[test]
1269    fn format_display_path_handles_brackets_verbatim() {
1270        let root = Path::new("/project");
1271        let path = Path::new("/project/app/[slug]/page.tsx");
1272        assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1273    }
1274
1275    #[test]
1276    fn format_display_path_path_equals_root_returns_empty() {
1277        let root = Path::new("/project");
1278        let path = Path::new("/project");
1279        assert_eq!(format_display_path(path, root), "");
1280    }
1281
1282    #[test]
1283    fn format_display_path_basename_only_when_path_is_at_root() {
1284        let root = Path::new("/project");
1285        let path = Path::new("/project/Cargo.toml");
1286        assert_eq!(format_display_path(path, root), "Cargo.toml");
1287    }
1288
1289    #[test]
1290    fn relative_uri_produces_forward_slash_path() {
1291        let root = PathBuf::from("/project");
1292        let path = root.join("src").join("utils.ts");
1293        let uri = relative_uri(&path, &root);
1294        assert_eq!(uri, "src/utils.ts");
1295    }
1296
1297    #[test]
1298    fn relative_uri_encodes_brackets() {
1299        let root = PathBuf::from("/project");
1300        let path = root.join("src/app/[...slug]/page.tsx");
1301        let uri = relative_uri(&path, &root);
1302        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1303    }
1304
1305    #[test]
1306    fn relative_uri_encodes_nested_dynamic_routes() {
1307        let root = PathBuf::from("/project");
1308        let path = root.join("src/app/[slug]/[id]/page.tsx");
1309        let uri = relative_uri(&path, &root);
1310        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1311    }
1312
1313    #[test]
1314    fn relative_uri_no_common_prefix_returns_full() {
1315        let root = PathBuf::from("/other");
1316        let path = PathBuf::from("/project/src/utils.ts");
1317        let uri = relative_uri(&path, &root);
1318        assert!(uri.contains("project"));
1319        assert!(uri.contains("utils.ts"));
1320    }
1321
1322    #[test]
1323    fn severity_error_maps_to_level_error() {
1324        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1325    }
1326
1327    #[test]
1328    fn severity_warn_maps_to_level_warn() {
1329        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1330    }
1331
1332    #[test]
1333    fn severity_off_maps_to_level_info() {
1334        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1335    }
1336
1337    #[test]
1338    fn normalize_uri_single_bracket_pair() {
1339        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1340    }
1341
1342    #[test]
1343    fn normalize_uri_catch_all_route() {
1344        assert_eq!(
1345            normalize_uri("app/[...slug]/page.tsx"),
1346            "app/%5B...slug%5D/page.tsx"
1347        );
1348    }
1349
1350    #[test]
1351    fn normalize_uri_optional_catch_all_route() {
1352        assert_eq!(
1353            normalize_uri("app/[[...slug]]/page.tsx"),
1354            "app/%5B%5B...slug%5D%5D/page.tsx"
1355        );
1356    }
1357
1358    #[test]
1359    fn normalize_uri_multiple_dynamic_segments() {
1360        assert_eq!(
1361            normalize_uri("app/[lang]/posts/[id]"),
1362            "app/%5Blang%5D/posts/%5Bid%5D"
1363        );
1364    }
1365
1366    #[test]
1367    fn normalize_uri_no_special_chars() {
1368        let plain = "src/components/Button.tsx";
1369        assert_eq!(normalize_uri(plain), plain);
1370    }
1371
1372    #[test]
1373    fn normalize_uri_only_backslashes() {
1374        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1375    }
1376
1377    #[test]
1378    fn relative_path_identical_paths_returns_empty() {
1379        let root = Path::new("/project");
1380        assert_eq!(relative_path(root, root), Path::new(""));
1381    }
1382
1383    #[test]
1384    fn relative_path_partial_name_match_not_stripped() {
1385        let root = Path::new("/project");
1386        let path = Path::new("/project-two/src/a.ts");
1387        assert_eq!(relative_path(path, root), path);
1388    }
1389
1390    #[test]
1391    fn relative_uri_combines_stripping_and_encoding() {
1392        let root = PathBuf::from("/project");
1393        let path = root.join("src/app/[slug]/page.tsx");
1394        let uri = relative_uri(&path, &root);
1395        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1396        assert!(!uri.starts_with('/'));
1397    }
1398
1399    #[test]
1400    fn relative_uri_at_root_file() {
1401        let root = PathBuf::from("/project");
1402        let path = root.join("index.ts");
1403        assert_eq!(relative_uri(&path, &root), "index.ts");
1404    }
1405
1406    #[test]
1407    fn severity_to_level_is_const_evaluable() {
1408        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1409        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1410        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1411        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1412        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1413        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1414    }
1415
1416    #[test]
1417    fn level_is_copy() {
1418        let level = severity_to_level(Severity::Error);
1419        let copy = level;
1420        assert!(matches!(level, Level::Error));
1421        assert!(matches!(copy, Level::Error));
1422    }
1423
1424    #[test]
1425    fn print_results_rejects_badge_for_dead_code_reports() {
1426        let root = Path::new("/project");
1427        let rules = RulesConfig::default();
1428        let ctx = test_context(root, &rules);
1429
1430        let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1431
1432        assert_eq!(code, ExitCode::from(2));
1433    }
1434
1435    #[test]
1436    fn print_duplication_report_rejects_badge_format() {
1437        let root = Path::new("/project");
1438        let rules = RulesConfig::default();
1439        let ctx = test_context(root, &rules);
1440
1441        let code =
1442            print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1443
1444        assert_eq!(code, ExitCode::from(2));
1445    }
1446
1447    #[test]
1448    fn elide_common_prefix_shared_dir() {
1449        assert_eq!(
1450            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1451            "B.tsx"
1452        );
1453    }
1454
1455    #[test]
1456    fn elide_common_prefix_partial_shared() {
1457        assert_eq!(
1458            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1459            "utils/B.tsx"
1460        );
1461    }
1462
1463    #[test]
1464    fn elide_common_prefix_no_shared() {
1465        assert_eq!(
1466            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1467            "pkg-b/src/B.tsx"
1468        );
1469    }
1470
1471    #[test]
1472    fn elide_common_prefix_identical_files() {
1473        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1474    }
1475
1476    #[test]
1477    fn elide_common_prefix_no_dirs() {
1478        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1479    }
1480
1481    #[test]
1482    fn elide_common_prefix_deep_monorepo() {
1483        assert_eq!(
1484            elide_common_prefix(
1485                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1486                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1487            ),
1488            "SearchSelectItem.tsx"
1489        );
1490    }
1491
1492    #[test]
1493    fn split_dir_filename_with_dir() {
1494        let (dir, file) = split_dir_filename("src/utils/index.ts");
1495        assert_eq!(dir, "src/utils/");
1496        assert_eq!(file, "index.ts");
1497    }
1498
1499    #[test]
1500    fn split_dir_filename_no_dir() {
1501        let (dir, file) = split_dir_filename("file.ts");
1502        assert_eq!(dir, "");
1503        assert_eq!(file, "file.ts");
1504    }
1505
1506    #[test]
1507    fn split_dir_filename_deeply_nested() {
1508        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1509        assert_eq!(dir, "a/b/c/d/");
1510        assert_eq!(file, "e.ts");
1511    }
1512
1513    #[test]
1514    fn split_dir_filename_trailing_slash() {
1515        let (dir, file) = split_dir_filename("src/");
1516        assert_eq!(dir, "src/");
1517        assert_eq!(file, "");
1518    }
1519
1520    #[test]
1521    fn split_dir_filename_empty() {
1522        let (dir, file) = split_dir_filename("");
1523        assert_eq!(dir, "");
1524        assert_eq!(file, "");
1525    }
1526
1527    #[test]
1528    fn plural_zero_is_plural() {
1529        assert_eq!(plural(0), "s");
1530    }
1531
1532    #[test]
1533    fn plural_one_is_singular() {
1534        assert_eq!(plural(1), "");
1535    }
1536
1537    #[test]
1538    fn plural_two_is_plural() {
1539        assert_eq!(plural(2), "s");
1540    }
1541
1542    #[test]
1543    fn plural_large_number() {
1544        assert_eq!(plural(999), "s");
1545    }
1546
1547    #[test]
1548    fn elide_common_prefix_empty_base() {
1549        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1550    }
1551
1552    #[test]
1553    fn elide_common_prefix_empty_target() {
1554        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1555    }
1556
1557    #[test]
1558    fn elide_common_prefix_both_empty() {
1559        assert_eq!(elide_common_prefix("", ""), "");
1560    }
1561
1562    #[test]
1563    fn elide_common_prefix_same_file_different_extension() {
1564        assert_eq!(
1565            elide_common_prefix("src/utils.ts", "src/utils.js"),
1566            "utils.js"
1567        );
1568    }
1569
1570    #[test]
1571    fn elide_common_prefix_partial_filename_match_not_stripped() {
1572        assert_eq!(
1573            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1574            "AppUtils.tsx"
1575        );
1576    }
1577
1578    #[test]
1579    fn elide_common_prefix_identical_paths() {
1580        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1581    }
1582
1583    #[test]
1584    fn split_dir_filename_single_slash() {
1585        let (dir, file) = split_dir_filename("/file.ts");
1586        assert_eq!(dir, "/");
1587        assert_eq!(file, "file.ts");
1588    }
1589
1590    #[test]
1591    fn emit_json_returns_success_for_valid_value() {
1592        let value = serde_json::json!({"key": "value"});
1593        let code = emit_json(&value, "test");
1594        assert_eq!(code, ExitCode::SUCCESS);
1595    }
1596
1597    mod proptests {
1598        use super::*;
1599        use proptest::prelude::*;
1600
1601        proptest! {
1602            /// split_dir_filename always reconstructs the original path.
1603            #[test]
1604            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1605                let (dir, file) = split_dir_filename(&path);
1606                let reconstructed = format!("{dir}{file}");
1607                prop_assert_eq!(
1608                    reconstructed, path,
1609                    "dir+file should reconstruct the original path"
1610                );
1611            }
1612
1613            /// plural returns either "" or "s", nothing else.
1614            #[test]
1615            fn plural_returns_empty_or_s(n: usize) {
1616                let result = plural(n);
1617                prop_assert!(
1618                    result.is_empty() || result == "s",
1619                    "plural should return \"\" or \"s\", got {:?}",
1620                    result
1621                );
1622            }
1623
1624            /// plural(1) is always "" and plural(n != 1) is always "s".
1625            #[test]
1626            fn plural_singular_only_for_one(n: usize) {
1627                let result = plural(n);
1628                if n == 1 {
1629                    prop_assert_eq!(result, "", "plural(1) should be empty");
1630                } else {
1631                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1632                }
1633            }
1634
1635            /// normalize_uri never panics and always replaces backslashes.
1636            #[test]
1637            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1638                let result = normalize_uri(&path);
1639                prop_assert!(
1640                    !result.contains('\\'),
1641                    "Result should not contain backslashes: {result}"
1642                );
1643            }
1644
1645            /// normalize_uri always encodes brackets.
1646            #[test]
1647            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1648                let result = normalize_uri(&path);
1649                prop_assert!(
1650                    !result.contains('[') && !result.contains(']'),
1651                    "Result should not contain raw brackets: {result}"
1652                );
1653            }
1654
1655            /// elide_common_prefix always returns a suffix of or equal to target.
1656            #[test]
1657            fn elide_common_prefix_returns_suffix_of_target(
1658                base in "[a-zA-Z0-9_./]{0,50}",
1659                target in "[a-zA-Z0-9_./]{0,50}",
1660            ) {
1661                let result = elide_common_prefix(&base, &target);
1662                prop_assert!(
1663                    target.ends_with(result),
1664                    "Result {:?} should be a suffix of target {:?}",
1665                    result, target
1666                );
1667            }
1668
1669            /// relative_path never panics.
1670            #[test]
1671            fn relative_path_never_panics(
1672                root in "/[a-zA-Z0-9_/]{0,30}",
1673                suffix in "[a-zA-Z0-9_./]{0,30}",
1674            ) {
1675                let root_path = Path::new(&root);
1676                let full = PathBuf::from(format!("{root}/{suffix}"));
1677                let _ = relative_path(&full, root_path);
1678            }
1679        }
1680    }
1681}