Skip to main content

fallow_cli/report/
mod.rs

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