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