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