Skip to main content

fallow_cli/report/
mod.rs

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