Skip to main content

fallow_cli/report/
mod.rs

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