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