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