Skip to main content

fallow_cli/report/
mod.rs

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