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