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