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