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