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 => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
229        OutputFormat::CodeClimate => {
230            codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
231        }
232        OutputFormat::Badge => {
233            eprintln!("Error: badge format is only supported for the health command");
234            ExitCode::from(2)
235        }
236    }
237}
238
239// ── Duplication report ────────────────────────────────────────────
240
241/// Print duplication analysis results in the configured format.
242#[must_use]
243pub fn print_duplication_report(
244    report: &DuplicationReport,
245    ctx: &ReportContext<'_>,
246    output: OutputFormat,
247) -> ExitCode {
248    match output {
249        OutputFormat::Human => {
250            if ctx.summary {
251                human::dupes::print_duplication_summary(report, ctx.elapsed, ctx.quiet);
252            } else {
253                human::print_duplication_human(report, ctx.root, ctx.elapsed, ctx.quiet);
254            }
255            ExitCode::SUCCESS
256        }
257        OutputFormat::Json => json::print_duplication_json(report, ctx.elapsed, ctx.explain),
258        OutputFormat::Compact => {
259            compact::print_duplication_compact(report, ctx.root);
260            ExitCode::SUCCESS
261        }
262        OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
263        OutputFormat::Markdown => {
264            markdown::print_duplication_markdown(report, ctx.root);
265            ExitCode::SUCCESS
266        }
267        OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
268        OutputFormat::Badge => {
269            eprintln!("Error: badge format is only supported for the health command");
270            ExitCode::from(2)
271        }
272    }
273}
274
275// ── Health / complexity report ─────────────────────────────────────
276
277/// Print health (complexity) analysis results in the configured format.
278#[must_use]
279pub fn print_health_report(
280    report: &crate::health_types::HealthReport,
281    ctx: &ReportContext<'_>,
282    output: OutputFormat,
283) -> ExitCode {
284    match output {
285        OutputFormat::Human => {
286            if ctx.summary {
287                human::health::print_health_summary(report, ctx.elapsed, ctx.quiet);
288            } else {
289                human::print_health_human(report, ctx.root, ctx.elapsed, ctx.quiet);
290            }
291            ExitCode::SUCCESS
292        }
293        OutputFormat::Compact => {
294            compact::print_health_compact(report, ctx.root);
295            ExitCode::SUCCESS
296        }
297        OutputFormat::Markdown => {
298            markdown::print_health_markdown(report, ctx.root);
299            ExitCode::SUCCESS
300        }
301        OutputFormat::Sarif => sarif::print_health_sarif(report, ctx.root),
302        OutputFormat::Json => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
303        OutputFormat::CodeClimate => codeclimate::print_health_codeclimate(report, ctx.root),
304        OutputFormat::Badge => badge::print_health_badge(report),
305    }
306}
307
308/// Print cross-reference findings (duplicated code that is also dead code).
309///
310/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
311pub fn print_cross_reference_findings(
312    cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
313    root: &Path,
314    quiet: bool,
315    output: OutputFormat,
316) {
317    human::print_cross_reference_findings(cross_ref, root, quiet, output);
318}
319
320// ── Trace output ──────────────────────────────────────────────────
321
322/// Print export trace results.
323pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
324    match format {
325        OutputFormat::Json => json::print_trace_json(trace),
326        _ => human::print_export_trace_human(trace),
327    }
328}
329
330/// Print file trace results.
331pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
332    match format {
333        OutputFormat::Json => json::print_trace_json(trace),
334        _ => human::print_file_trace_human(trace),
335    }
336}
337
338/// Print dependency trace results.
339pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
340    match format {
341        OutputFormat::Json => json::print_trace_json(trace),
342        _ => human::print_dependency_trace_human(trace),
343    }
344}
345
346/// Print clone trace results.
347pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
348    match format {
349        OutputFormat::Json => json::print_trace_json(trace),
350        _ => human::print_clone_trace_human(trace, root),
351    }
352}
353
354/// Print pipeline performance timings.
355/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
356pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
357    match format {
358        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
359            Ok(json) => eprintln!("{json}"),
360            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
361        },
362        _ => human::print_performance_human(timings),
363    }
364}
365
366// Re-exported for snapshot testing via the lib target.
367// Uses #[allow] because unused_imports is target-dependent (used in lib, unused in bin).
368#[allow(
369    unused_imports,
370    reason = "target-dependent: used in lib, unused in bin"
371)]
372pub use codeclimate::build_codeclimate;
373#[allow(
374    unused_imports,
375    reason = "target-dependent: used in lib, unused in bin"
376)]
377pub use codeclimate::build_duplication_codeclimate;
378#[allow(
379    unused_imports,
380    reason = "target-dependent: used in lib, unused in bin"
381)]
382pub use codeclimate::build_health_codeclimate;
383#[allow(
384    unused_imports,
385    reason = "target-dependent: used in lib, unused in bin"
386)]
387pub use compact::build_compact_lines;
388pub use json::build_baseline_deltas_json;
389#[allow(
390    unused_imports,
391    reason = "target-dependent: used in lib, unused in bin"
392)]
393pub use json::build_json;
394#[allow(
395    unused_imports,
396    reason = "target-dependent: used in bin audit.rs, unused in lib"
397)]
398#[allow(
399    clippy::redundant_pub_crate,
400    reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
401)]
402pub(crate) use json::inject_dupes_actions;
403#[allow(
404    unused_imports,
405    reason = "target-dependent: used in bin audit.rs, unused in lib"
406)]
407#[allow(
408    clippy::redundant_pub_crate,
409    reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
410)]
411pub(crate) use json::inject_health_actions;
412#[allow(
413    unused_imports,
414    reason = "target-dependent: used in lib, unused in bin"
415)]
416pub use markdown::build_duplication_markdown;
417#[allow(
418    unused_imports,
419    reason = "target-dependent: used in lib, unused in bin"
420)]
421pub use markdown::build_health_markdown;
422#[allow(
423    unused_imports,
424    reason = "target-dependent: used in lib, unused in bin"
425)]
426pub use markdown::build_markdown;
427#[allow(
428    unused_imports,
429    reason = "target-dependent: used in lib, unused in bin"
430)]
431pub use sarif::build_health_sarif;
432#[allow(
433    unused_imports,
434    reason = "target-dependent: used in lib, unused in bin"
435)]
436pub use sarif::build_sarif;
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use std::path::PathBuf;
442
443    // ── normalize_uri ────────────────────────────────────────────────
444
445    #[test]
446    fn normalize_uri_forward_slashes_unchanged() {
447        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
448    }
449
450    #[test]
451    fn normalize_uri_backslashes_replaced() {
452        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
453    }
454
455    #[test]
456    fn normalize_uri_mixed_slashes() {
457        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
458    }
459
460    #[test]
461    fn normalize_uri_path_with_spaces() {
462        assert_eq!(
463            normalize_uri("src\\my folder\\file.ts"),
464            "src/my folder/file.ts"
465        );
466    }
467
468    #[test]
469    fn normalize_uri_empty_string() {
470        assert_eq!(normalize_uri(""), "");
471    }
472
473    // ── relative_path ────────────────────────────────────────────────
474
475    #[test]
476    fn relative_path_strips_root_prefix() {
477        let root = Path::new("/project");
478        let path = Path::new("/project/src/utils.ts");
479        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
480    }
481
482    #[test]
483    fn relative_path_returns_full_path_when_no_prefix() {
484        let root = Path::new("/other");
485        let path = Path::new("/project/src/utils.ts");
486        assert_eq!(relative_path(path, root), path);
487    }
488
489    #[test]
490    fn relative_path_at_root_returns_empty_or_file() {
491        let root = Path::new("/project");
492        let path = Path::new("/project/file.ts");
493        assert_eq!(relative_path(path, root), Path::new("file.ts"));
494    }
495
496    #[test]
497    fn relative_path_deeply_nested() {
498        let root = Path::new("/project");
499        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
500        assert_eq!(
501            relative_path(path, root),
502            Path::new("packages/ui/src/components/Button.tsx")
503        );
504    }
505
506    // ── relative_uri ─────────────────────────────────────────────────
507
508    #[test]
509    fn relative_uri_produces_forward_slash_path() {
510        let root = PathBuf::from("/project");
511        let path = root.join("src").join("utils.ts");
512        let uri = relative_uri(&path, &root);
513        assert_eq!(uri, "src/utils.ts");
514    }
515
516    #[test]
517    fn relative_uri_encodes_brackets() {
518        let root = PathBuf::from("/project");
519        let path = root.join("src/app/[...slug]/page.tsx");
520        let uri = relative_uri(&path, &root);
521        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
522    }
523
524    #[test]
525    fn relative_uri_encodes_nested_dynamic_routes() {
526        let root = PathBuf::from("/project");
527        let path = root.join("src/app/[slug]/[id]/page.tsx");
528        let uri = relative_uri(&path, &root);
529        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
530    }
531
532    #[test]
533    fn relative_uri_no_common_prefix_returns_full() {
534        let root = PathBuf::from("/other");
535        let path = PathBuf::from("/project/src/utils.ts");
536        let uri = relative_uri(&path, &root);
537        assert!(uri.contains("project"));
538        assert!(uri.contains("utils.ts"));
539    }
540
541    // ── severity_to_level ────────────────────────────────────────────
542
543    #[test]
544    fn severity_error_maps_to_level_error() {
545        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
546    }
547
548    #[test]
549    fn severity_warn_maps_to_level_warn() {
550        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
551    }
552
553    #[test]
554    fn severity_off_maps_to_level_info() {
555        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
556    }
557
558    // ── normalize_uri bracket encoding ──────────────────────────────
559
560    #[test]
561    fn normalize_uri_single_bracket_pair() {
562        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
563    }
564
565    #[test]
566    fn normalize_uri_catch_all_route() {
567        assert_eq!(
568            normalize_uri("app/[...slug]/page.tsx"),
569            "app/%5B...slug%5D/page.tsx"
570        );
571    }
572
573    #[test]
574    fn normalize_uri_optional_catch_all_route() {
575        assert_eq!(
576            normalize_uri("app/[[...slug]]/page.tsx"),
577            "app/%5B%5B...slug%5D%5D/page.tsx"
578        );
579    }
580
581    #[test]
582    fn normalize_uri_multiple_dynamic_segments() {
583        assert_eq!(
584            normalize_uri("app/[lang]/posts/[id]"),
585            "app/%5Blang%5D/posts/%5Bid%5D"
586        );
587    }
588
589    #[test]
590    fn normalize_uri_no_special_chars() {
591        let plain = "src/components/Button.tsx";
592        assert_eq!(normalize_uri(plain), plain);
593    }
594
595    #[test]
596    fn normalize_uri_only_backslashes() {
597        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
598    }
599
600    // ── relative_path edge cases ────────────────────────────────────
601
602    #[test]
603    fn relative_path_identical_paths_returns_empty() {
604        let root = Path::new("/project");
605        assert_eq!(relative_path(root, root), Path::new(""));
606    }
607
608    #[test]
609    fn relative_path_partial_name_match_not_stripped() {
610        // "/project-two/src/a.ts" should NOT strip "/project" because
611        // "/project" is not a proper prefix of "/project-two".
612        let root = Path::new("/project");
613        let path = Path::new("/project-two/src/a.ts");
614        assert_eq!(relative_path(path, root), path);
615    }
616
617    // ── relative_uri edge cases ─────────────────────────────────────
618
619    #[test]
620    fn relative_uri_combines_stripping_and_encoding() {
621        let root = PathBuf::from("/project");
622        let path = root.join("src/app/[slug]/page.tsx");
623        let uri = relative_uri(&path, &root);
624        // Should both strip the prefix AND encode brackets.
625        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
626        assert!(!uri.starts_with('/'));
627    }
628
629    #[test]
630    fn relative_uri_at_root_file() {
631        let root = PathBuf::from("/project");
632        let path = root.join("index.ts");
633        assert_eq!(relative_uri(&path, &root), "index.ts");
634    }
635
636    // ── severity_to_level exhaustiveness ────────────────────────────
637
638    #[test]
639    fn severity_to_level_is_const_evaluable() {
640        // Verify the function can be used in const context.
641        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
642        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
643        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
644        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
645        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
646        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
647    }
648
649    // ── Level is Copy ───────────────────────────────────────────────
650
651    #[test]
652    fn level_is_copy() {
653        let level = severity_to_level(Severity::Error);
654        let copy = level;
655        // Both should still be usable (Copy semantics).
656        assert!(matches!(level, Level::Error));
657        assert!(matches!(copy, Level::Error));
658    }
659
660    // ── elide_common_prefix ─────────────────────────────────────────
661
662    #[test]
663    fn elide_common_prefix_shared_dir() {
664        assert_eq!(
665            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
666            "B.tsx"
667        );
668    }
669
670    #[test]
671    fn elide_common_prefix_partial_shared() {
672        assert_eq!(
673            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
674            "utils/B.tsx"
675        );
676    }
677
678    #[test]
679    fn elide_common_prefix_no_shared() {
680        assert_eq!(
681            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
682            "pkg-b/src/B.tsx"
683        );
684    }
685
686    #[test]
687    fn elide_common_prefix_identical_files() {
688        // Same dir, different file
689        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
690    }
691
692    #[test]
693    fn elide_common_prefix_no_dirs() {
694        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
695    }
696
697    #[test]
698    fn elide_common_prefix_deep_monorepo() {
699        assert_eq!(
700            elide_common_prefix(
701                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
702                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
703            ),
704            "SearchSelectItem.tsx"
705        );
706    }
707
708    // ── split_dir_filename ───────────────────────────────────────
709
710    #[test]
711    fn split_dir_filename_with_dir() {
712        let (dir, file) = split_dir_filename("src/utils/index.ts");
713        assert_eq!(dir, "src/utils/");
714        assert_eq!(file, "index.ts");
715    }
716
717    #[test]
718    fn split_dir_filename_no_dir() {
719        let (dir, file) = split_dir_filename("file.ts");
720        assert_eq!(dir, "");
721        assert_eq!(file, "file.ts");
722    }
723
724    #[test]
725    fn split_dir_filename_deeply_nested() {
726        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
727        assert_eq!(dir, "a/b/c/d/");
728        assert_eq!(file, "e.ts");
729    }
730
731    #[test]
732    fn split_dir_filename_trailing_slash() {
733        let (dir, file) = split_dir_filename("src/");
734        assert_eq!(dir, "src/");
735        assert_eq!(file, "");
736    }
737
738    #[test]
739    fn split_dir_filename_empty() {
740        let (dir, file) = split_dir_filename("");
741        assert_eq!(dir, "");
742        assert_eq!(file, "");
743    }
744
745    // ── plural ──────────────────────────────────────────────────
746
747    #[test]
748    fn plural_zero_is_plural() {
749        assert_eq!(plural(0), "s");
750    }
751
752    #[test]
753    fn plural_one_is_singular() {
754        assert_eq!(plural(1), "");
755    }
756
757    #[test]
758    fn plural_two_is_plural() {
759        assert_eq!(plural(2), "s");
760    }
761
762    #[test]
763    fn plural_large_number() {
764        assert_eq!(plural(999), "s");
765    }
766
767    // ── elide_common_prefix edge cases ──────────────────────────
768
769    #[test]
770    fn elide_common_prefix_empty_base() {
771        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
772    }
773
774    #[test]
775    fn elide_common_prefix_empty_target() {
776        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
777    }
778
779    #[test]
780    fn elide_common_prefix_both_empty() {
781        assert_eq!(elide_common_prefix("", ""), "");
782    }
783
784    #[test]
785    fn elide_common_prefix_same_file_different_extension() {
786        // "src/utils.ts" vs "src/utils.js" — common prefix is "src/"
787        assert_eq!(
788            elide_common_prefix("src/utils.ts", "src/utils.js"),
789            "utils.js"
790        );
791    }
792
793    #[test]
794    fn elide_common_prefix_partial_filename_match_not_stripped() {
795        // "src/App.tsx" vs "src/AppUtils.tsx" — both in src/, but file names differ
796        assert_eq!(
797            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
798            "AppUtils.tsx"
799        );
800    }
801
802    #[test]
803    fn elide_common_prefix_identical_paths() {
804        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
805    }
806
807    #[test]
808    fn split_dir_filename_single_slash() {
809        let (dir, file) = split_dir_filename("/file.ts");
810        assert_eq!(dir, "/");
811        assert_eq!(file, "file.ts");
812    }
813
814    #[test]
815    fn emit_json_returns_success_for_valid_value() {
816        let value = serde_json::json!({"key": "value"});
817        let code = emit_json(&value, "test");
818        assert_eq!(code, ExitCode::SUCCESS);
819    }
820
821    mod proptests {
822        use super::*;
823        use proptest::prelude::*;
824
825        proptest! {
826            /// split_dir_filename always reconstructs the original path.
827            #[test]
828            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
829                let (dir, file) = split_dir_filename(&path);
830                let reconstructed = format!("{dir}{file}");
831                prop_assert_eq!(
832                    reconstructed, path,
833                    "dir+file should reconstruct the original path"
834                );
835            }
836
837            /// plural returns either "" or "s", nothing else.
838            #[test]
839            fn plural_returns_empty_or_s(n: usize) {
840                let result = plural(n);
841                prop_assert!(
842                    result.is_empty() || result == "s",
843                    "plural should return \"\" or \"s\", got {:?}",
844                    result
845                );
846            }
847
848            /// plural(1) is always "" and plural(n != 1) is always "s".
849            #[test]
850            fn plural_singular_only_for_one(n: usize) {
851                let result = plural(n);
852                if n == 1 {
853                    prop_assert_eq!(result, "", "plural(1) should be empty");
854                } else {
855                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
856                }
857            }
858
859            /// normalize_uri never panics and always replaces backslashes.
860            #[test]
861            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
862                let result = normalize_uri(&path);
863                prop_assert!(
864                    !result.contains('\\'),
865                    "Result should not contain backslashes: {result}"
866                );
867            }
868
869            /// normalize_uri always encodes brackets.
870            #[test]
871            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
872                let result = normalize_uri(&path);
873                prop_assert!(
874                    !result.contains('[') && !result.contains(']'),
875                    "Result should not contain raw brackets: {result}"
876                );
877            }
878
879            /// elide_common_prefix always returns a suffix of or equal to target.
880            #[test]
881            fn elide_common_prefix_returns_suffix_of_target(
882                base in "[a-zA-Z0-9_./]{0,50}",
883                target in "[a-zA-Z0-9_./]{0,50}",
884            ) {
885                let result = elide_common_prefix(&base, &target);
886                prop_assert!(
887                    target.ends_with(result),
888                    "Result {:?} should be a suffix of target {:?}",
889                    result, target
890                );
891            }
892
893            /// relative_path never panics.
894            #[test]
895            fn relative_path_never_panics(
896                root in "/[a-zA-Z0-9_/]{0,30}",
897                suffix in "[a-zA-Z0-9_./]{0,30}",
898            ) {
899                let root_path = Path::new(&root);
900                let full = PathBuf::from(format!("{root}/{suffix}"));
901                let _ = relative_path(&full, root_path);
902            }
903        }
904    }
905}