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