Skip to main content

fallow_cli/report/
mod.rs

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