Skip to main content

fallow_cli/report/
mod.rs

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