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;
645#[allow(
646    clippy::redundant_pub_crate,
647    reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
648)]
649pub(crate) use json::SCHEMA_VERSION;
650pub use json::build_baseline_deltas_json;
651#[allow(
652    unused_imports,
653    reason = "target-dependent: used in lib, unused in bin"
654)]
655pub use json::build_duplication_json;
656#[allow(
657    unused_imports,
658    reason = "target-dependent: used in lib, unused in bin"
659)]
660pub use json::build_grouped_duplication_json;
661#[allow(
662    unused_imports,
663    reason = "target-dependent: used in lib, unused in bin"
664)]
665pub use json::build_health_json;
666#[allow(
667    unused_imports,
668    reason = "target-dependent: used in lib, unused in bin"
669)]
670pub use json::build_json;
671#[allow(
672    unused_imports,
673    reason = "target-dependent: used in bin audit.rs, unused in lib"
674)]
675#[allow(
676    clippy::redundant_pub_crate,
677    reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
678)]
679pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
680#[allow(
681    unused_imports,
682    reason = "target-dependent: used in bin audit.rs, unused in lib"
683)]
684#[allow(
685    clippy::redundant_pub_crate,
686    reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
687)]
688pub(crate) use json::inject_dupes_actions;
689#[allow(
690    unused_imports,
691    reason = "target-dependent: used in bin audit.rs, unused in lib"
692)]
693#[allow(
694    clippy::redundant_pub_crate,
695    reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
696)]
697pub(crate) use json::inject_health_actions;
698#[allow(
699    unused_imports,
700    reason = "target-dependent: used in lib, unused in bin"
701)]
702pub use markdown::build_duplication_markdown;
703#[allow(
704    unused_imports,
705    reason = "target-dependent: used in lib, unused in bin"
706)]
707pub use markdown::build_health_markdown;
708#[allow(
709    unused_imports,
710    reason = "target-dependent: used in lib, unused in bin"
711)]
712pub use markdown::build_markdown;
713#[allow(
714    unused_imports,
715    reason = "target-dependent: used in lib, unused in bin"
716)]
717pub use sarif::build_health_sarif;
718#[allow(
719    unused_imports,
720    reason = "target-dependent: used in lib, unused in bin"
721)]
722pub use sarif::build_sarif;
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use std::path::PathBuf;
728
729    // ── normalize_uri ────────────────────────────────────────────────
730
731    #[test]
732    fn normalize_uri_forward_slashes_unchanged() {
733        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
734    }
735
736    #[test]
737    fn normalize_uri_backslashes_replaced() {
738        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
739    }
740
741    #[test]
742    fn normalize_uri_mixed_slashes() {
743        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
744    }
745
746    #[test]
747    fn normalize_uri_path_with_spaces() {
748        assert_eq!(
749            normalize_uri("src\\my folder\\file.ts"),
750            "src/my folder/file.ts"
751        );
752    }
753
754    #[test]
755    fn normalize_uri_empty_string() {
756        assert_eq!(normalize_uri(""), "");
757    }
758
759    // ── relative_path ────────────────────────────────────────────────
760
761    #[test]
762    fn relative_path_strips_root_prefix() {
763        let root = Path::new("/project");
764        let path = Path::new("/project/src/utils.ts");
765        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
766    }
767
768    #[test]
769    fn relative_path_returns_full_path_when_no_prefix() {
770        let root = Path::new("/other");
771        let path = Path::new("/project/src/utils.ts");
772        assert_eq!(relative_path(path, root), path);
773    }
774
775    #[test]
776    fn relative_path_at_root_returns_empty_or_file() {
777        let root = Path::new("/project");
778        let path = Path::new("/project/file.ts");
779        assert_eq!(relative_path(path, root), Path::new("file.ts"));
780    }
781
782    #[test]
783    fn relative_path_deeply_nested() {
784        let root = Path::new("/project");
785        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
786        assert_eq!(
787            relative_path(path, root),
788            Path::new("packages/ui/src/components/Button.tsx")
789        );
790    }
791
792    // ── relative_uri ─────────────────────────────────────────────────
793
794    #[test]
795    fn relative_uri_produces_forward_slash_path() {
796        let root = PathBuf::from("/project");
797        let path = root.join("src").join("utils.ts");
798        let uri = relative_uri(&path, &root);
799        assert_eq!(uri, "src/utils.ts");
800    }
801
802    #[test]
803    fn relative_uri_encodes_brackets() {
804        let root = PathBuf::from("/project");
805        let path = root.join("src/app/[...slug]/page.tsx");
806        let uri = relative_uri(&path, &root);
807        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
808    }
809
810    #[test]
811    fn relative_uri_encodes_nested_dynamic_routes() {
812        let root = PathBuf::from("/project");
813        let path = root.join("src/app/[slug]/[id]/page.tsx");
814        let uri = relative_uri(&path, &root);
815        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
816    }
817
818    #[test]
819    fn relative_uri_no_common_prefix_returns_full() {
820        let root = PathBuf::from("/other");
821        let path = PathBuf::from("/project/src/utils.ts");
822        let uri = relative_uri(&path, &root);
823        assert!(uri.contains("project"));
824        assert!(uri.contains("utils.ts"));
825    }
826
827    // ── severity_to_level ────────────────────────────────────────────
828
829    #[test]
830    fn severity_error_maps_to_level_error() {
831        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
832    }
833
834    #[test]
835    fn severity_warn_maps_to_level_warn() {
836        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
837    }
838
839    #[test]
840    fn severity_off_maps_to_level_info() {
841        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
842    }
843
844    // ── normalize_uri bracket encoding ──────────────────────────────
845
846    #[test]
847    fn normalize_uri_single_bracket_pair() {
848        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
849    }
850
851    #[test]
852    fn normalize_uri_catch_all_route() {
853        assert_eq!(
854            normalize_uri("app/[...slug]/page.tsx"),
855            "app/%5B...slug%5D/page.tsx"
856        );
857    }
858
859    #[test]
860    fn normalize_uri_optional_catch_all_route() {
861        assert_eq!(
862            normalize_uri("app/[[...slug]]/page.tsx"),
863            "app/%5B%5B...slug%5D%5D/page.tsx"
864        );
865    }
866
867    #[test]
868    fn normalize_uri_multiple_dynamic_segments() {
869        assert_eq!(
870            normalize_uri("app/[lang]/posts/[id]"),
871            "app/%5Blang%5D/posts/%5Bid%5D"
872        );
873    }
874
875    #[test]
876    fn normalize_uri_no_special_chars() {
877        let plain = "src/components/Button.tsx";
878        assert_eq!(normalize_uri(plain), plain);
879    }
880
881    #[test]
882    fn normalize_uri_only_backslashes() {
883        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
884    }
885
886    // ── relative_path edge cases ────────────────────────────────────
887
888    #[test]
889    fn relative_path_identical_paths_returns_empty() {
890        let root = Path::new("/project");
891        assert_eq!(relative_path(root, root), Path::new(""));
892    }
893
894    #[test]
895    fn relative_path_partial_name_match_not_stripped() {
896        // "/project-two/src/a.ts" should NOT strip "/project" because
897        // "/project" is not a proper prefix of "/project-two".
898        let root = Path::new("/project");
899        let path = Path::new("/project-two/src/a.ts");
900        assert_eq!(relative_path(path, root), path);
901    }
902
903    // ── relative_uri edge cases ─────────────────────────────────────
904
905    #[test]
906    fn relative_uri_combines_stripping_and_encoding() {
907        let root = PathBuf::from("/project");
908        let path = root.join("src/app/[slug]/page.tsx");
909        let uri = relative_uri(&path, &root);
910        // Should both strip the prefix AND encode brackets.
911        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
912        assert!(!uri.starts_with('/'));
913    }
914
915    #[test]
916    fn relative_uri_at_root_file() {
917        let root = PathBuf::from("/project");
918        let path = root.join("index.ts");
919        assert_eq!(relative_uri(&path, &root), "index.ts");
920    }
921
922    // ── severity_to_level exhaustiveness ────────────────────────────
923
924    #[test]
925    fn severity_to_level_is_const_evaluable() {
926        // Verify the function can be used in const context.
927        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
928        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
929        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
930        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
931        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
932        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
933    }
934
935    // ── Level is Copy ───────────────────────────────────────────────
936
937    #[test]
938    fn level_is_copy() {
939        let level = severity_to_level(Severity::Error);
940        let copy = level;
941        // Both should still be usable (Copy semantics).
942        assert!(matches!(level, Level::Error));
943        assert!(matches!(copy, Level::Error));
944    }
945
946    // ── elide_common_prefix ─────────────────────────────────────────
947
948    #[test]
949    fn elide_common_prefix_shared_dir() {
950        assert_eq!(
951            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
952            "B.tsx"
953        );
954    }
955
956    #[test]
957    fn elide_common_prefix_partial_shared() {
958        assert_eq!(
959            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
960            "utils/B.tsx"
961        );
962    }
963
964    #[test]
965    fn elide_common_prefix_no_shared() {
966        assert_eq!(
967            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
968            "pkg-b/src/B.tsx"
969        );
970    }
971
972    #[test]
973    fn elide_common_prefix_identical_files() {
974        // Same dir, different file
975        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
976    }
977
978    #[test]
979    fn elide_common_prefix_no_dirs() {
980        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
981    }
982
983    #[test]
984    fn elide_common_prefix_deep_monorepo() {
985        assert_eq!(
986            elide_common_prefix(
987                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
988                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
989            ),
990            "SearchSelectItem.tsx"
991        );
992    }
993
994    // ── split_dir_filename ───────────────────────────────────────
995
996    #[test]
997    fn split_dir_filename_with_dir() {
998        let (dir, file) = split_dir_filename("src/utils/index.ts");
999        assert_eq!(dir, "src/utils/");
1000        assert_eq!(file, "index.ts");
1001    }
1002
1003    #[test]
1004    fn split_dir_filename_no_dir() {
1005        let (dir, file) = split_dir_filename("file.ts");
1006        assert_eq!(dir, "");
1007        assert_eq!(file, "file.ts");
1008    }
1009
1010    #[test]
1011    fn split_dir_filename_deeply_nested() {
1012        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1013        assert_eq!(dir, "a/b/c/d/");
1014        assert_eq!(file, "e.ts");
1015    }
1016
1017    #[test]
1018    fn split_dir_filename_trailing_slash() {
1019        let (dir, file) = split_dir_filename("src/");
1020        assert_eq!(dir, "src/");
1021        assert_eq!(file, "");
1022    }
1023
1024    #[test]
1025    fn split_dir_filename_empty() {
1026        let (dir, file) = split_dir_filename("");
1027        assert_eq!(dir, "");
1028        assert_eq!(file, "");
1029    }
1030
1031    // ── plural ──────────────────────────────────────────────────
1032
1033    #[test]
1034    fn plural_zero_is_plural() {
1035        assert_eq!(plural(0), "s");
1036    }
1037
1038    #[test]
1039    fn plural_one_is_singular() {
1040        assert_eq!(plural(1), "");
1041    }
1042
1043    #[test]
1044    fn plural_two_is_plural() {
1045        assert_eq!(plural(2), "s");
1046    }
1047
1048    #[test]
1049    fn plural_large_number() {
1050        assert_eq!(plural(999), "s");
1051    }
1052
1053    // ── elide_common_prefix edge cases ──────────────────────────
1054
1055    #[test]
1056    fn elide_common_prefix_empty_base() {
1057        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1058    }
1059
1060    #[test]
1061    fn elide_common_prefix_empty_target() {
1062        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1063    }
1064
1065    #[test]
1066    fn elide_common_prefix_both_empty() {
1067        assert_eq!(elide_common_prefix("", ""), "");
1068    }
1069
1070    #[test]
1071    fn elide_common_prefix_same_file_different_extension() {
1072        // "src/utils.ts" vs "src/utils.js" — common prefix is "src/"
1073        assert_eq!(
1074            elide_common_prefix("src/utils.ts", "src/utils.js"),
1075            "utils.js"
1076        );
1077    }
1078
1079    #[test]
1080    fn elide_common_prefix_partial_filename_match_not_stripped() {
1081        // "src/App.tsx" vs "src/AppUtils.tsx" — both in src/, but file names differ
1082        assert_eq!(
1083            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1084            "AppUtils.tsx"
1085        );
1086    }
1087
1088    #[test]
1089    fn elide_common_prefix_identical_paths() {
1090        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1091    }
1092
1093    #[test]
1094    fn split_dir_filename_single_slash() {
1095        let (dir, file) = split_dir_filename("/file.ts");
1096        assert_eq!(dir, "/");
1097        assert_eq!(file, "file.ts");
1098    }
1099
1100    #[test]
1101    fn emit_json_returns_success_for_valid_value() {
1102        let value = serde_json::json!({"key": "value"});
1103        let code = emit_json(&value, "test");
1104        assert_eq!(code, ExitCode::SUCCESS);
1105    }
1106
1107    mod proptests {
1108        use super::*;
1109        use proptest::prelude::*;
1110
1111        proptest! {
1112            /// split_dir_filename always reconstructs the original path.
1113            #[test]
1114            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1115                let (dir, file) = split_dir_filename(&path);
1116                let reconstructed = format!("{dir}{file}");
1117                prop_assert_eq!(
1118                    reconstructed, path,
1119                    "dir+file should reconstruct the original path"
1120                );
1121            }
1122
1123            /// plural returns either "" or "s", nothing else.
1124            #[test]
1125            fn plural_returns_empty_or_s(n: usize) {
1126                let result = plural(n);
1127                prop_assert!(
1128                    result.is_empty() || result == "s",
1129                    "plural should return \"\" or \"s\", got {:?}",
1130                    result
1131                );
1132            }
1133
1134            /// plural(1) is always "" and plural(n != 1) is always "s".
1135            #[test]
1136            fn plural_singular_only_for_one(n: usize) {
1137                let result = plural(n);
1138                if n == 1 {
1139                    prop_assert_eq!(result, "", "plural(1) should be empty");
1140                } else {
1141                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1142                }
1143            }
1144
1145            /// normalize_uri never panics and always replaces backslashes.
1146            #[test]
1147            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1148                let result = normalize_uri(&path);
1149                prop_assert!(
1150                    !result.contains('\\'),
1151                    "Result should not contain backslashes: {result}"
1152                );
1153            }
1154
1155            /// normalize_uri always encodes brackets.
1156            #[test]
1157            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1158                let result = normalize_uri(&path);
1159                prop_assert!(
1160                    !result.contains('[') && !result.contains(']'),
1161                    "Result should not contain raw brackets: {result}"
1162                );
1163            }
1164
1165            /// elide_common_prefix always returns a suffix of or equal to target.
1166            #[test]
1167            fn elide_common_prefix_returns_suffix_of_target(
1168                base in "[a-zA-Z0-9_./]{0,50}",
1169                target in "[a-zA-Z0-9_./]{0,50}",
1170            ) {
1171                let result = elide_common_prefix(&base, &target);
1172                prop_assert!(
1173                    target.ends_with(result),
1174                    "Result {:?} should be a suffix of target {:?}",
1175                    result, target
1176                );
1177            }
1178
1179            /// relative_path never panics.
1180            #[test]
1181            fn relative_path_never_panics(
1182                root in "/[a-zA-Z0-9_/]{0,30}",
1183                suffix in "[a-zA-Z0-9_./]{0,30}",
1184            ) {
1185                let root_path = Path::new(&root);
1186                let full = PathBuf::from(format!("{root}/{suffix}"));
1187                let _ = relative_path(&full, root_path);
1188            }
1189        }
1190    }
1191}