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