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