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