Skip to main content

fallow_cli/report/
mod.rs

1mod badge;
2pub mod ci;
3mod codeclimate;
4mod compact;
5pub mod dupes_grouping;
6pub mod grouping;
7mod human;
8mod json;
9mod markdown;
10mod sarif;
11mod shared;
12#[cfg(test)]
13pub mod test_helpers;
14
15use std::path::Path;
16use std::process::ExitCode;
17use std::time::Duration;
18
19use fallow_config::{OutputFormat, RulesConfig, Severity};
20use fallow_core::duplicates::DuplicationReport;
21use fallow_core::results::AnalysisResults;
22use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
23
24pub use grouping::OwnershipResolver;
25#[allow(
26    unused_imports,
27    reason = "used by binary crate modules (combined.rs, audit.rs)"
28)]
29pub use json::strip_root_prefix;
30// Re-exported for combined.rs so `fallow --score` / `fallow --trend` can
31// render the same score / trend block as `fallow health --score` (issue #557).
32pub use human::health::{render_health_score, render_health_trend};
33
34/// Shared context for all report dispatch functions.
35///
36/// Bundles the common parameters that every format renderer needs,
37/// replacing per-parameter threading through the dispatch match arms.
38pub struct ReportContext<'a> {
39    pub root: &'a Path,
40    pub rules: &'a RulesConfig,
41    pub elapsed: Duration,
42    pub quiet: bool,
43    pub explain: bool,
44    /// When set, group all output by this resolver.
45    pub group_by: Option<OwnershipResolver>,
46    /// Limit displayed items per section (--top N).
47    pub top: Option<usize>,
48    /// When set, print a concise summary instead of the full report.
49    pub summary: bool,
50    /// Human-only: print the summary renderer's own title line. Combined mode
51    /// already prints section headers, so it disables this to avoid duplicate
52    /// "Dead Code" / "Dead Code Summary" headings.
53    pub summary_heading: bool,
54    /// Human-only: print a one-line hint pointing at `fallow explain`.
55    pub show_explain_tip: bool,
56    /// When a baseline was loaded: (total entries in baseline, entries that matched).
57    pub baseline_matched: Option<(usize, usize)>,
58    /// Whether config-edit actions can be applied by `fallow fix`.
59    ///
60    /// This is caller-provided because an explicit `--config` path is fixable
61    /// even when default config discovery from the root would find nothing.
62    pub config_fixable: bool,
63    /// When set, the human health renderer skips the `● Health score:` and
64    /// trend table sections because they have already been rendered upstream
65    /// (combined-mode orientation header). Standalone `fallow health` keeps
66    /// the default `false` and renders both sections inline.
67    pub skip_score_and_trend: bool,
68}
69
70/// Strip the project root prefix from a path for display, falling back to the full path.
71#[must_use]
72pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
73    path.strip_prefix(root).unwrap_or(path)
74}
75
76/// Format a path for human-facing display: project-relative when the path is
77/// under `root`, falling back to the full path otherwise. Always
78/// forward-slash-normalized so Windows backslashes do not leak into
79/// terminal output.
80///
81/// Use this for any human-output site that today renders bare `file_name()`,
82/// since bare basenames are ambiguous in Nx / Angular / Rust-workspace layouts
83/// where many files share names like `index.ts`, `mod.rs`, or
84/// `*.component.ts`. See issue #547.
85#[must_use]
86pub fn format_display_path(path: &Path, root: &Path) -> String {
87    relative_path(path, root)
88        .display()
89        .to_string()
90        .replace('\\', "/")
91}
92
93/// Split a path string into (directory, filename) for display.
94/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
95#[must_use]
96pub fn split_dir_filename(path: &str) -> (&str, &str) {
97    path.rfind('/')
98        .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
99}
100
101/// Return `"s"` for plural or `""` for singular.
102#[must_use]
103pub const fn plural(n: usize) -> &'static str {
104    if n == 1 { "" } else { "s" }
105}
106
107/// Serialize a JSON value to pretty-printed stdout, returning the appropriate exit code.
108///
109/// On success prints the JSON and returns `ExitCode::SUCCESS`.
110/// On serialization failure prints an error to stderr and returns exit code 2.
111#[must_use]
112pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
113    match serde_json::to_string_pretty(value) {
114        Ok(json) => {
115            println!("{json}");
116            ExitCode::SUCCESS
117        }
118        Err(e) => {
119            eprintln!("Error: failed to serialize {kind} output: {e}");
120            ExitCode::from(2)
121        }
122    }
123}
124
125/// Elide the common directory prefix between a base path and a target path.
126/// Only strips complete directory segments (never partial filenames).
127/// Returns the remaining suffix of `target`.
128///
129/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
130#[must_use]
131pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
132    let mut last_sep = 0;
133    for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
134        if a != b {
135            break;
136        }
137        if a == b'/' {
138            last_sep = i + 1;
139        }
140    }
141    if last_sep > 0 && last_sep <= target.len() {
142        &target[last_sep..]
143    } else {
144        target
145    }
146}
147
148/// Compute a SARIF-compatible relative URI from an absolute path and project root.
149fn relative_uri(path: &Path, root: &Path) -> String {
150    normalize_uri(&relative_path(path, root).display().to_string())
151}
152
153/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
154///
155/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
156/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
157#[must_use]
158pub fn normalize_uri(path_str: &str) -> String {
159    path_str
160        .replace('\\', "/")
161        .replace('[', "%5B")
162        .replace(']', "%5D")
163}
164
165/// Severity level for human-readable output.
166#[derive(Clone, Copy, Debug)]
167pub enum Level {
168    Warn,
169    Info,
170    Error,
171}
172
173#[must_use]
174pub const fn severity_to_level(s: Severity) -> Level {
175    match s {
176        Severity::Error => Level::Error,
177        Severity::Warn => Level::Warn,
178        // Off issues are filtered before reporting; fall back to Info.
179        Severity::Off => Level::Info,
180    }
181}
182
183/// Print analysis results in the configured format.
184/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
185///
186/// When `regression` is `Some`, the JSON format includes a `regression` key in the output envelope.
187/// When `ctx.group_by` is `Some`, results are partitioned into labeled groups before rendering.
188#[must_use]
189pub fn print_results(
190    results: &AnalysisResults,
191    ctx: &ReportContext<'_>,
192    output: OutputFormat,
193    regression: Option<&crate::regression::RegressionOutcome>,
194) -> ExitCode {
195    // Grouped output: partition results and render per-group
196    if let Some(ref resolver) = ctx.group_by {
197        let groups = grouping::group_analysis_results(results, ctx.root, resolver);
198        return print_grouped_results(&groups, results, ctx, output, resolver);
199    }
200
201    match output {
202        OutputFormat::Human => {
203            if ctx.summary {
204                human::check::print_check_summary(
205                    results,
206                    ctx.rules,
207                    ctx.elapsed,
208                    ctx.quiet,
209                    ctx.summary_heading,
210                );
211            } else {
212                human::print_human(
213                    results,
214                    ctx.root,
215                    ctx.rules,
216                    ctx.elapsed,
217                    ctx.quiet,
218                    ctx.top,
219                    ctx.show_explain_tip,
220                    ctx.explain,
221                );
222            }
223            ExitCode::SUCCESS
224        }
225        OutputFormat::Json => json::print_json(
226            results,
227            ctx.root,
228            ctx.elapsed,
229            ctx.explain,
230            regression,
231            ctx.baseline_matched,
232            ctx.config_fixable,
233        ),
234        OutputFormat::Compact => {
235            compact::print_compact(results, ctx.root);
236            ExitCode::SUCCESS
237        }
238        OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
239        OutputFormat::Markdown => {
240            markdown::print_markdown(results, ctx.root);
241            ExitCode::SUCCESS
242        }
243        OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
244        OutputFormat::PrCommentGithub => {
245            let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
246            let value = codeclimate::issues_to_value(&issues);
247            ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
248        }
249        OutputFormat::PrCommentGitlab => {
250            let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
251            let value = codeclimate::issues_to_value(&issues);
252            ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
253        }
254        OutputFormat::ReviewGithub => {
255            let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
256            let value = codeclimate::issues_to_value(&issues);
257            ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
258        }
259        OutputFormat::ReviewGitlab => {
260            let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
261            let value = codeclimate::issues_to_value(&issues);
262            ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
263        }
264        OutputFormat::Badge => {
265            eprintln!("Error: badge format is only supported for the health command");
266            ExitCode::from(2)
267        }
268    }
269}
270
271/// Render grouped results across all output formats.
272#[must_use]
273fn print_grouped_results(
274    groups: &[grouping::ResultGroup],
275    original: &AnalysisResults,
276    ctx: &ReportContext<'_>,
277    output: OutputFormat,
278    resolver: &OwnershipResolver,
279) -> ExitCode {
280    match output {
281        OutputFormat::Human => {
282            human::print_grouped_human(
283                groups,
284                ctx.root,
285                ctx.rules,
286                ctx.elapsed,
287                ctx.quiet,
288                Some(resolver),
289                ctx.explain,
290            );
291            ExitCode::SUCCESS
292        }
293        OutputFormat::Json => json::print_grouped_json(
294            groups,
295            original,
296            ctx.root,
297            ctx.elapsed,
298            ctx.explain,
299            resolver,
300            ctx.config_fixable,
301        ),
302        OutputFormat::Compact => {
303            compact::print_grouped_compact(groups, ctx.root);
304            ExitCode::SUCCESS
305        }
306        OutputFormat::Markdown => {
307            markdown::print_grouped_markdown(groups, ctx.root);
308            ExitCode::SUCCESS
309        }
310        OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
311        OutputFormat::CodeClimate => {
312            codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
313        }
314        OutputFormat::PrCommentGithub => {
315            let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
316            let value = codeclimate::issues_to_value(&issues);
317            ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
318        }
319        OutputFormat::PrCommentGitlab => {
320            let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
321            let value = codeclimate::issues_to_value(&issues);
322            ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
323        }
324        OutputFormat::ReviewGithub => {
325            let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
326            let value = codeclimate::issues_to_value(&issues);
327            ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
328        }
329        OutputFormat::ReviewGitlab => {
330            let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
331            let value = codeclimate::issues_to_value(&issues);
332            ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
333        }
334        OutputFormat::Badge => {
335            eprintln!("Error: badge format is only supported for the health command");
336            ExitCode::from(2)
337        }
338    }
339}
340
341// ── Duplication report ────────────────────────────────────────────
342
343/// Print duplication analysis results in the configured format.
344#[must_use]
345pub fn print_duplication_report(
346    report: &DuplicationReport,
347    ctx: &ReportContext<'_>,
348    output: OutputFormat,
349) -> ExitCode {
350    // Grouped output: build the grouping payload once and dispatch
351    // per-format. Compact, markdown, and badge fall back to ungrouped output
352    // with a stderr note (parity with the health grouped fallback).
353    if let Some(ref resolver) = ctx.group_by {
354        let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
355        return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
356    }
357
358    match output {
359        OutputFormat::Human => {
360            if ctx.summary {
361                human::dupes::print_duplication_summary(
362                    report,
363                    ctx.elapsed,
364                    ctx.quiet,
365                    ctx.summary_heading,
366                );
367            } else {
368                human::print_duplication_human(
369                    report,
370                    ctx.root,
371                    ctx.elapsed,
372                    ctx.quiet,
373                    ctx.show_explain_tip,
374                    ctx.explain,
375                );
376            }
377            ExitCode::SUCCESS
378        }
379        OutputFormat::Json => {
380            json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
381        }
382        OutputFormat::Compact => {
383            compact::print_duplication_compact(report, ctx.root);
384            ExitCode::SUCCESS
385        }
386        OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
387        OutputFormat::Markdown => {
388            markdown::print_duplication_markdown(report, ctx.root);
389            ExitCode::SUCCESS
390        }
391        OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
392        OutputFormat::PrCommentGithub => {
393            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
394            let value = codeclimate::issues_to_value(&issues);
395            ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
396        }
397        OutputFormat::PrCommentGitlab => {
398            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
399            let value = codeclimate::issues_to_value(&issues);
400            ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
401        }
402        OutputFormat::ReviewGithub => {
403            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
404            let value = codeclimate::issues_to_value(&issues);
405            ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
406        }
407        OutputFormat::ReviewGitlab => {
408            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
409            let value = codeclimate::issues_to_value(&issues);
410            ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
411        }
412        OutputFormat::Badge => {
413            eprintln!("Error: badge format is only supported for the health command");
414            ExitCode::from(2)
415        }
416    }
417}
418
419/// Render grouped duplication results across all output formats.
420#[must_use]
421fn print_grouped_duplication_report(
422    report: &DuplicationReport,
423    grouping: &dupes_grouping::DuplicationGrouping,
424    ctx: &ReportContext<'_>,
425    output: OutputFormat,
426    resolver: &OwnershipResolver,
427) -> ExitCode {
428    match output {
429        OutputFormat::Human => {
430            human::print_grouped_duplication_human(
431                report,
432                grouping,
433                ctx.root,
434                ctx.elapsed,
435                ctx.quiet,
436            );
437            ExitCode::SUCCESS
438        }
439        OutputFormat::Json => json::print_grouped_duplication_json(
440            report,
441            grouping,
442            ctx.root,
443            ctx.elapsed,
444            ctx.explain,
445        ),
446        OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
447        OutputFormat::CodeClimate => {
448            codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
449        }
450        OutputFormat::PrCommentGithub => {
451            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
452            let value = codeclimate::issues_to_value(&issues);
453            ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
454        }
455        OutputFormat::PrCommentGitlab => {
456            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
457            let value = codeclimate::issues_to_value(&issues);
458            ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
459        }
460        OutputFormat::ReviewGithub => {
461            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
462            let value = codeclimate::issues_to_value(&issues);
463            ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
464        }
465        OutputFormat::ReviewGitlab => {
466            let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
467            let value = codeclimate::issues_to_value(&issues);
468            ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
469        }
470        OutputFormat::Compact => {
471            compact::print_duplication_compact(report, ctx.root);
472            warn_dupes_grouping_unsupported(grouping, "compact");
473            ExitCode::SUCCESS
474        }
475        OutputFormat::Markdown => {
476            markdown::print_duplication_markdown(report, ctx.root);
477            warn_dupes_grouping_unsupported(grouping, "markdown");
478            ExitCode::SUCCESS
479        }
480        OutputFormat::Badge => {
481            eprintln!("Error: badge format is only supported for the health command");
482            ExitCode::from(2)
483        }
484    }
485}
486
487fn warn_dupes_grouping_unsupported(grouping: &dupes_grouping::DuplicationGrouping, format: &str) {
488    eprintln!(
489        "note: --group-by {} is not supported for {format} duplication output, falling back to \
490         ungrouped output (use --format json for the full grouped envelope)",
491        grouping.mode
492    );
493}
494
495// ── Health / complexity report ─────────────────────────────────────
496
497/// Print health (complexity) analysis results in the configured format.
498///
499/// `grouping` and `group_resolver` carry per-group output produced by
500/// `--group-by`:
501/// - **JSON** renders the grouped envelope (`{ grouped_by, vital_signs,
502///   health_score, groups: [...] }`).
503/// - **Human** prints a per-group summary block (score / files / hot / p90)
504///   after the project-level report.
505/// - **SARIF** and **CodeClimate** tag every per-finding result with the
506///   resolver-derived group key (`properties.group` for SARIF, top-level
507///   `group` for CodeClimate) so CI consumers like GitHub Code Scanning
508///   and GitLab Code Quality can partition findings per team / package
509///   without re-parsing the project structure.
510/// - **Compact**, **Markdown**, and **Badge** fall back to ungrouped output
511///   and emit a one-line stderr note pointing at `--format json` for the
512///   richer grouped envelope.
513#[must_use]
514pub fn print_health_report(
515    report: &crate::health_types::HealthReport,
516    grouping: Option<&crate::health_types::HealthGrouping>,
517    group_resolver: Option<&grouping::OwnershipResolver>,
518    ctx: &ReportContext<'_>,
519    output: OutputFormat,
520) -> ExitCode {
521    match output {
522        OutputFormat::Human => {
523            if ctx.summary {
524                human::health::print_health_summary(
525                    report,
526                    ctx.elapsed,
527                    ctx.quiet,
528                    ctx.summary_heading,
529                );
530            } else {
531                human::print_health_human(
532                    report,
533                    ctx.root,
534                    ctx.elapsed,
535                    ctx.quiet,
536                    ctx.show_explain_tip,
537                    ctx.explain,
538                    ctx.skip_score_and_trend,
539                );
540                if let Some(grouping) = grouping {
541                    human::print_health_grouping(grouping, ctx.root, ctx.quiet);
542                }
543            }
544            ExitCode::SUCCESS
545        }
546        OutputFormat::Compact => {
547            compact::print_health_compact(report, ctx.root);
548            warn_grouping_unsupported(grouping, "compact");
549            ExitCode::SUCCESS
550        }
551        OutputFormat::Markdown => {
552            markdown::print_health_markdown(report, ctx.root);
553            warn_grouping_unsupported(grouping, "markdown");
554            ExitCode::SUCCESS
555        }
556        OutputFormat::Sarif => match group_resolver {
557            Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
558            None => sarif::print_health_sarif(report, ctx.root),
559        },
560        OutputFormat::Json => match grouping {
561            Some(grouping) => json::print_grouped_health_json(
562                report,
563                grouping,
564                ctx.root,
565                ctx.elapsed,
566                ctx.explain,
567            ),
568            None => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
569        },
570        OutputFormat::CodeClimate => match group_resolver {
571            Some(resolver) => {
572                codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
573            }
574            None => codeclimate::print_health_codeclimate(report, ctx.root),
575        },
576        OutputFormat::PrCommentGithub => {
577            let issues = codeclimate::build_health_codeclimate(report, ctx.root);
578            let value = codeclimate::issues_to_value(&issues);
579            ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Github, &value)
580        }
581        OutputFormat::PrCommentGitlab => {
582            let issues = codeclimate::build_health_codeclimate(report, ctx.root);
583            let value = codeclimate::issues_to_value(&issues);
584            ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Gitlab, &value)
585        }
586        OutputFormat::ReviewGithub => {
587            let issues = codeclimate::build_health_codeclimate(report, ctx.root);
588            let value = codeclimate::issues_to_value(&issues);
589            ci::review::print_review_envelope("health", ci::pr_comment::Provider::Github, &value)
590        }
591        OutputFormat::ReviewGitlab => {
592            let issues = codeclimate::build_health_codeclimate(report, ctx.root);
593            let value = codeclimate::issues_to_value(&issues);
594            ci::review::print_review_envelope("health", ci::pr_comment::Provider::Gitlab, &value)
595        }
596        OutputFormat::Badge => {
597            warn_grouping_unsupported(grouping, "badge");
598            badge::print_health_badge(report)
599        }
600    }
601}
602
603fn warn_grouping_unsupported(grouping: Option<&crate::health_types::HealthGrouping>, format: &str) {
604    if let Some(g) = grouping {
605        eprintln!(
606            "note: --group-by {} is not supported for {format} output, falling back to \
607             ungrouped output (use --format json for the full grouped envelope)",
608            g.mode
609        );
610    }
611}
612
613/// Print cross-reference findings (duplicated code that is also dead code).
614///
615/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
616pub fn print_cross_reference_findings(
617    cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
618    root: &Path,
619    quiet: bool,
620    output: OutputFormat,
621) {
622    human::print_cross_reference_findings(cross_ref, root, quiet, output);
623}
624
625// ── Trace output ──────────────────────────────────────────────────
626
627/// Print export trace results.
628pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
629    match format {
630        OutputFormat::Json => json::print_trace_json(trace),
631        _ => human::print_export_trace_human(trace),
632    }
633}
634
635/// Print file trace results.
636pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
637    match format {
638        OutputFormat::Json => json::print_trace_json(trace),
639        _ => human::print_file_trace_human(trace),
640    }
641}
642
643/// Print dependency trace results.
644pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
645    match format {
646        OutputFormat::Json => json::print_trace_json(trace),
647        _ => human::print_dependency_trace_human(trace),
648    }
649}
650
651/// Print clone trace results.
652pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
653    match format {
654        OutputFormat::Json => json::print_trace_json(trace),
655        _ => human::print_clone_trace_human(trace, root),
656    }
657}
658
659/// Print pipeline performance timings.
660/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
661pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
662    match format {
663        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
664            Ok(json) => eprintln!("{json}"),
665            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
666        },
667        _ => human::print_performance_human(timings),
668    }
669}
670
671/// Print health pipeline performance timings.
672/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
673pub fn print_health_performance(
674    timings: &crate::health_types::HealthTimings,
675    format: OutputFormat,
676) {
677    match format {
678        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
679            Ok(json) => eprintln!("{json}"),
680            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
681        },
682        _ => human::print_health_performance_human(timings),
683    }
684}
685
686// Re-exported for snapshot testing via the lib target.
687// Uses #[allow] because unused_imports is target-dependent (used in lib, unused in bin).
688#[allow(
689    unused_imports,
690    reason = "target-dependent: used in lib, unused in bin"
691)]
692pub use codeclimate::build_codeclimate;
693#[allow(
694    unused_imports,
695    reason = "target-dependent: used in lib, unused in bin"
696)]
697pub use codeclimate::build_duplication_codeclimate;
698#[allow(
699    unused_imports,
700    reason = "target-dependent: used in lib, unused in bin"
701)]
702pub use codeclimate::build_health_codeclimate;
703#[allow(
704    unused_imports,
705    reason = "target-dependent: used in lib, unused in bin"
706)]
707pub use codeclimate::issues_to_value as codeclimate_issues_to_value;
708#[allow(
709    unused_imports,
710    reason = "target-dependent: used in lib, unused in bin"
711)]
712pub use compact::build_compact_lines;
713#[allow(
714    clippy::redundant_pub_crate,
715    reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
716)]
717pub(crate) use json::SCHEMA_VERSION;
718pub use json::build_baseline_deltas_json;
719#[allow(
720    unused_imports,
721    reason = "target-dependent: used in lib, unused in bin"
722)]
723pub use json::build_duplication_json;
724#[allow(
725    unused_imports,
726    reason = "target-dependent: used in lib, unused in bin"
727)]
728pub use json::build_grouped_duplication_json;
729#[allow(
730    unused_imports,
731    reason = "target-dependent: used in lib, unused in bin"
732)]
733pub use json::build_health_json;
734#[allow(
735    unused_imports,
736    reason = "target-dependent: used in bin audit.rs, unused in lib"
737)]
738#[allow(
739    clippy::redundant_pub_crate,
740    reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
741)]
742pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
743#[allow(
744    unused_imports,
745    reason = "target-dependent: used in lib, unused in bin"
746)]
747pub use json::{build_json, build_json_with_config_fixable};
748#[allow(
749    unused_imports,
750    reason = "target-dependent: used in lib, unused in bin"
751)]
752pub use markdown::build_duplication_markdown;
753#[allow(
754    unused_imports,
755    reason = "target-dependent: used in lib, unused in bin"
756)]
757pub use markdown::build_health_markdown;
758#[allow(
759    unused_imports,
760    reason = "target-dependent: used in lib, unused in bin"
761)]
762pub use markdown::build_markdown;
763#[allow(
764    unused_imports,
765    reason = "target-dependent: used in lib, unused in bin"
766)]
767pub use sarif::build_health_sarif;
768#[allow(
769    unused_imports,
770    reason = "target-dependent: used in lib, unused in bin"
771)]
772pub use sarif::build_sarif;
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use std::path::PathBuf;
778
779    // ── normalize_uri ────────────────────────────────────────────────
780
781    #[test]
782    fn normalize_uri_forward_slashes_unchanged() {
783        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
784    }
785
786    #[test]
787    fn normalize_uri_backslashes_replaced() {
788        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
789    }
790
791    #[test]
792    fn normalize_uri_mixed_slashes() {
793        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
794    }
795
796    #[test]
797    fn normalize_uri_path_with_spaces() {
798        assert_eq!(
799            normalize_uri("src\\my folder\\file.ts"),
800            "src/my folder/file.ts"
801        );
802    }
803
804    #[test]
805    fn normalize_uri_empty_string() {
806        assert_eq!(normalize_uri(""), "");
807    }
808
809    // ── relative_path ────────────────────────────────────────────────
810
811    #[test]
812    fn relative_path_strips_root_prefix() {
813        let root = Path::new("/project");
814        let path = Path::new("/project/src/utils.ts");
815        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
816    }
817
818    #[test]
819    fn relative_path_returns_full_path_when_no_prefix() {
820        let root = Path::new("/other");
821        let path = Path::new("/project/src/utils.ts");
822        assert_eq!(relative_path(path, root), path);
823    }
824
825    #[test]
826    fn relative_path_at_root_returns_empty_or_file() {
827        let root = Path::new("/project");
828        let path = Path::new("/project/file.ts");
829        assert_eq!(relative_path(path, root), Path::new("file.ts"));
830    }
831
832    #[test]
833    fn relative_path_deeply_nested() {
834        let root = Path::new("/project");
835        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
836        assert_eq!(
837            relative_path(path, root),
838            Path::new("packages/ui/src/components/Button.tsx")
839        );
840    }
841
842    // ── format_display_path ──────────────────────────────────────────
843
844    #[test]
845    fn format_display_path_returns_workspace_relative() {
846        let root = Path::new("/project");
847        let path = Path::new("/project/apps/server/src/index.ts");
848        assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
849    }
850
851    #[test]
852    fn format_display_path_collides_in_nx_layout_renders_full_relative() {
853        // Two Nx workspace packages with their own src/index.ts. Both render
854        // workspace-relative without any per-call collision logic; the reader
855        // disambiguates from the path itself.
856        let root = Path::new("/project");
857        let server = Path::new("/project/apps/server/src/index.ts");
858        let client = Path::new("/project/apps/client/src/index.ts");
859        assert_eq!(
860            format_display_path(server, root),
861            "apps/server/src/index.ts"
862        );
863        assert_eq!(
864            format_display_path(client, root),
865            "apps/client/src/index.ts"
866        );
867    }
868
869    #[test]
870    fn format_display_path_angular_component_renders_parent_directory() {
871        let root = Path::new("/project");
872        let path = Path::new(
873            "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
874        );
875        assert_eq!(
876            format_display_path(path, root),
877            "apps/admin/src/app/payments/payment-list/payment-list.component.html"
878        );
879    }
880
881    #[test]
882    fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
883        // Mirrors relative_path behavior: if the path is not under root, we
884        // emit the path verbatim (still forward-slash normalized).
885        let root = Path::new("/other");
886        let path = Path::new("/project/src/utils.ts");
887        let rendered = format_display_path(path, root);
888        assert!(rendered.contains("project"));
889        assert!(rendered.ends_with("utils.ts"));
890        assert!(!rendered.contains('\\'));
891    }
892
893    #[test]
894    fn format_display_path_normalizes_backslashes_to_forward_slashes() {
895        // Simulate Windows-style absolute paths. Path::strip_prefix matches on
896        // OsStr segments, so we can use forward-slash root + backslash path
897        // segments only on Windows. On Unix the test verifies the replace step
898        // by feeding a path that contains literal backslashes.
899        let root = Path::new("/project");
900        let path = Path::new("/project/src/sub\\file.ts");
901        let rendered = format_display_path(path, root);
902        assert!(
903            !rendered.contains('\\'),
904            "backslashes must be normalized: {rendered}"
905        );
906    }
907
908    #[test]
909    fn format_display_path_handles_brackets_verbatim() {
910        // Unlike SARIF URIs, human-display paths preserve brackets so users see
911        // Next.js dynamic-route names as authored.
912        let root = Path::new("/project");
913        let path = Path::new("/project/app/[slug]/page.tsx");
914        assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
915    }
916
917    #[test]
918    fn format_display_path_path_equals_root_returns_empty() {
919        let root = Path::new("/project");
920        let path = Path::new("/project");
921        assert_eq!(format_display_path(path, root), "");
922    }
923
924    #[test]
925    fn format_display_path_basename_only_when_path_is_at_root() {
926        let root = Path::new("/project");
927        let path = Path::new("/project/Cargo.toml");
928        assert_eq!(format_display_path(path, root), "Cargo.toml");
929    }
930
931    // ── relative_uri ─────────────────────────────────────────────────
932
933    #[test]
934    fn relative_uri_produces_forward_slash_path() {
935        let root = PathBuf::from("/project");
936        let path = root.join("src").join("utils.ts");
937        let uri = relative_uri(&path, &root);
938        assert_eq!(uri, "src/utils.ts");
939    }
940
941    #[test]
942    fn relative_uri_encodes_brackets() {
943        let root = PathBuf::from("/project");
944        let path = root.join("src/app/[...slug]/page.tsx");
945        let uri = relative_uri(&path, &root);
946        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
947    }
948
949    #[test]
950    fn relative_uri_encodes_nested_dynamic_routes() {
951        let root = PathBuf::from("/project");
952        let path = root.join("src/app/[slug]/[id]/page.tsx");
953        let uri = relative_uri(&path, &root);
954        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
955    }
956
957    #[test]
958    fn relative_uri_no_common_prefix_returns_full() {
959        let root = PathBuf::from("/other");
960        let path = PathBuf::from("/project/src/utils.ts");
961        let uri = relative_uri(&path, &root);
962        assert!(uri.contains("project"));
963        assert!(uri.contains("utils.ts"));
964    }
965
966    // ── severity_to_level ────────────────────────────────────────────
967
968    #[test]
969    fn severity_error_maps_to_level_error() {
970        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
971    }
972
973    #[test]
974    fn severity_warn_maps_to_level_warn() {
975        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
976    }
977
978    #[test]
979    fn severity_off_maps_to_level_info() {
980        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
981    }
982
983    // ── normalize_uri bracket encoding ──────────────────────────────
984
985    #[test]
986    fn normalize_uri_single_bracket_pair() {
987        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
988    }
989
990    #[test]
991    fn normalize_uri_catch_all_route() {
992        assert_eq!(
993            normalize_uri("app/[...slug]/page.tsx"),
994            "app/%5B...slug%5D/page.tsx"
995        );
996    }
997
998    #[test]
999    fn normalize_uri_optional_catch_all_route() {
1000        assert_eq!(
1001            normalize_uri("app/[[...slug]]/page.tsx"),
1002            "app/%5B%5B...slug%5D%5D/page.tsx"
1003        );
1004    }
1005
1006    #[test]
1007    fn normalize_uri_multiple_dynamic_segments() {
1008        assert_eq!(
1009            normalize_uri("app/[lang]/posts/[id]"),
1010            "app/%5Blang%5D/posts/%5Bid%5D"
1011        );
1012    }
1013
1014    #[test]
1015    fn normalize_uri_no_special_chars() {
1016        let plain = "src/components/Button.tsx";
1017        assert_eq!(normalize_uri(plain), plain);
1018    }
1019
1020    #[test]
1021    fn normalize_uri_only_backslashes() {
1022        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1023    }
1024
1025    // ── relative_path edge cases ────────────────────────────────────
1026
1027    #[test]
1028    fn relative_path_identical_paths_returns_empty() {
1029        let root = Path::new("/project");
1030        assert_eq!(relative_path(root, root), Path::new(""));
1031    }
1032
1033    #[test]
1034    fn relative_path_partial_name_match_not_stripped() {
1035        // "/project-two/src/a.ts" should NOT strip "/project" because
1036        // "/project" is not a proper prefix of "/project-two".
1037        let root = Path::new("/project");
1038        let path = Path::new("/project-two/src/a.ts");
1039        assert_eq!(relative_path(path, root), path);
1040    }
1041
1042    // ── relative_uri edge cases ─────────────────────────────────────
1043
1044    #[test]
1045    fn relative_uri_combines_stripping_and_encoding() {
1046        let root = PathBuf::from("/project");
1047        let path = root.join("src/app/[slug]/page.tsx");
1048        let uri = relative_uri(&path, &root);
1049        // Should both strip the prefix AND encode brackets.
1050        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1051        assert!(!uri.starts_with('/'));
1052    }
1053
1054    #[test]
1055    fn relative_uri_at_root_file() {
1056        let root = PathBuf::from("/project");
1057        let path = root.join("index.ts");
1058        assert_eq!(relative_uri(&path, &root), "index.ts");
1059    }
1060
1061    // ── severity_to_level exhaustiveness ────────────────────────────
1062
1063    #[test]
1064    fn severity_to_level_is_const_evaluable() {
1065        // Verify the function can be used in const context.
1066        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1067        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1068        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1069        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1070        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1071        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1072    }
1073
1074    // ── Level is Copy ───────────────────────────────────────────────
1075
1076    #[test]
1077    fn level_is_copy() {
1078        let level = severity_to_level(Severity::Error);
1079        let copy = level;
1080        // Both should still be usable (Copy semantics).
1081        assert!(matches!(level, Level::Error));
1082        assert!(matches!(copy, Level::Error));
1083    }
1084
1085    // ── elide_common_prefix ─────────────────────────────────────────
1086
1087    #[test]
1088    fn elide_common_prefix_shared_dir() {
1089        assert_eq!(
1090            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1091            "B.tsx"
1092        );
1093    }
1094
1095    #[test]
1096    fn elide_common_prefix_partial_shared() {
1097        assert_eq!(
1098            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1099            "utils/B.tsx"
1100        );
1101    }
1102
1103    #[test]
1104    fn elide_common_prefix_no_shared() {
1105        assert_eq!(
1106            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1107            "pkg-b/src/B.tsx"
1108        );
1109    }
1110
1111    #[test]
1112    fn elide_common_prefix_identical_files() {
1113        // Same dir, different file
1114        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1115    }
1116
1117    #[test]
1118    fn elide_common_prefix_no_dirs() {
1119        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1120    }
1121
1122    #[test]
1123    fn elide_common_prefix_deep_monorepo() {
1124        assert_eq!(
1125            elide_common_prefix(
1126                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1127                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1128            ),
1129            "SearchSelectItem.tsx"
1130        );
1131    }
1132
1133    // ── split_dir_filename ───────────────────────────────────────
1134
1135    #[test]
1136    fn split_dir_filename_with_dir() {
1137        let (dir, file) = split_dir_filename("src/utils/index.ts");
1138        assert_eq!(dir, "src/utils/");
1139        assert_eq!(file, "index.ts");
1140    }
1141
1142    #[test]
1143    fn split_dir_filename_no_dir() {
1144        let (dir, file) = split_dir_filename("file.ts");
1145        assert_eq!(dir, "");
1146        assert_eq!(file, "file.ts");
1147    }
1148
1149    #[test]
1150    fn split_dir_filename_deeply_nested() {
1151        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1152        assert_eq!(dir, "a/b/c/d/");
1153        assert_eq!(file, "e.ts");
1154    }
1155
1156    #[test]
1157    fn split_dir_filename_trailing_slash() {
1158        let (dir, file) = split_dir_filename("src/");
1159        assert_eq!(dir, "src/");
1160        assert_eq!(file, "");
1161    }
1162
1163    #[test]
1164    fn split_dir_filename_empty() {
1165        let (dir, file) = split_dir_filename("");
1166        assert_eq!(dir, "");
1167        assert_eq!(file, "");
1168    }
1169
1170    // ── plural ──────────────────────────────────────────────────
1171
1172    #[test]
1173    fn plural_zero_is_plural() {
1174        assert_eq!(plural(0), "s");
1175    }
1176
1177    #[test]
1178    fn plural_one_is_singular() {
1179        assert_eq!(plural(1), "");
1180    }
1181
1182    #[test]
1183    fn plural_two_is_plural() {
1184        assert_eq!(plural(2), "s");
1185    }
1186
1187    #[test]
1188    fn plural_large_number() {
1189        assert_eq!(plural(999), "s");
1190    }
1191
1192    // ── elide_common_prefix edge cases ──────────────────────────
1193
1194    #[test]
1195    fn elide_common_prefix_empty_base() {
1196        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1197    }
1198
1199    #[test]
1200    fn elide_common_prefix_empty_target() {
1201        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1202    }
1203
1204    #[test]
1205    fn elide_common_prefix_both_empty() {
1206        assert_eq!(elide_common_prefix("", ""), "");
1207    }
1208
1209    #[test]
1210    fn elide_common_prefix_same_file_different_extension() {
1211        // "src/utils.ts" vs "src/utils.js" — common prefix is "src/"
1212        assert_eq!(
1213            elide_common_prefix("src/utils.ts", "src/utils.js"),
1214            "utils.js"
1215        );
1216    }
1217
1218    #[test]
1219    fn elide_common_prefix_partial_filename_match_not_stripped() {
1220        // "src/App.tsx" vs "src/AppUtils.tsx" — both in src/, but file names differ
1221        assert_eq!(
1222            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1223            "AppUtils.tsx"
1224        );
1225    }
1226
1227    #[test]
1228    fn elide_common_prefix_identical_paths() {
1229        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1230    }
1231
1232    #[test]
1233    fn split_dir_filename_single_slash() {
1234        let (dir, file) = split_dir_filename("/file.ts");
1235        assert_eq!(dir, "/");
1236        assert_eq!(file, "file.ts");
1237    }
1238
1239    #[test]
1240    fn emit_json_returns_success_for_valid_value() {
1241        let value = serde_json::json!({"key": "value"});
1242        let code = emit_json(&value, "test");
1243        assert_eq!(code, ExitCode::SUCCESS);
1244    }
1245
1246    mod proptests {
1247        use super::*;
1248        use proptest::prelude::*;
1249
1250        proptest! {
1251            /// split_dir_filename always reconstructs the original path.
1252            #[test]
1253            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1254                let (dir, file) = split_dir_filename(&path);
1255                let reconstructed = format!("{dir}{file}");
1256                prop_assert_eq!(
1257                    reconstructed, path,
1258                    "dir+file should reconstruct the original path"
1259                );
1260            }
1261
1262            /// plural returns either "" or "s", nothing else.
1263            #[test]
1264            fn plural_returns_empty_or_s(n: usize) {
1265                let result = plural(n);
1266                prop_assert!(
1267                    result.is_empty() || result == "s",
1268                    "plural should return \"\" or \"s\", got {:?}",
1269                    result
1270                );
1271            }
1272
1273            /// plural(1) is always "" and plural(n != 1) is always "s".
1274            #[test]
1275            fn plural_singular_only_for_one(n: usize) {
1276                let result = plural(n);
1277                if n == 1 {
1278                    prop_assert_eq!(result, "", "plural(1) should be empty");
1279                } else {
1280                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1281                }
1282            }
1283
1284            /// normalize_uri never panics and always replaces backslashes.
1285            #[test]
1286            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1287                let result = normalize_uri(&path);
1288                prop_assert!(
1289                    !result.contains('\\'),
1290                    "Result should not contain backslashes: {result}"
1291                );
1292            }
1293
1294            /// normalize_uri always encodes brackets.
1295            #[test]
1296            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1297                let result = normalize_uri(&path);
1298                prop_assert!(
1299                    !result.contains('[') && !result.contains(']'),
1300                    "Result should not contain raw brackets: {result}"
1301                );
1302            }
1303
1304            /// elide_common_prefix always returns a suffix of or equal to target.
1305            #[test]
1306            fn elide_common_prefix_returns_suffix_of_target(
1307                base in "[a-zA-Z0-9_./]{0,50}",
1308                target in "[a-zA-Z0-9_./]{0,50}",
1309            ) {
1310                let result = elide_common_prefix(&base, &target);
1311                prop_assert!(
1312                    target.ends_with(result),
1313                    "Result {:?} should be a suffix of target {:?}",
1314                    result, target
1315                );
1316            }
1317
1318            /// relative_path never panics.
1319            #[test]
1320            fn relative_path_never_panics(
1321                root in "/[a-zA-Z0-9_/]{0,30}",
1322                suffix in "[a-zA-Z0-9_./]{0,30}",
1323            ) {
1324                let root_path = Path::new(&root);
1325                let full = PathBuf::from(format!("{root}/{suffix}"));
1326                let _ = relative_path(&full, root_path);
1327            }
1328        }
1329    }
1330}