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