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
23pub 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 pub group_by: Option<OwnershipResolver>,
35 pub top: Option<usize>,
37 pub summary: bool,
39}
40
41#[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#[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#[must_use]
57pub const fn plural(n: usize) -> &'static str {
58 if n == 1 { "" } else { "s" }
59}
60
61#[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#[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
102fn relative_uri(path: &Path, root: &Path) -> String {
104 normalize_uri(&relative_path(path, root).display().to_string())
105}
106
107#[must_use]
112pub fn normalize_uri(path_str: &str) -> String {
113 path_str
114 .replace('\\', "/")
115 .replace('[', "%5B")
116 .replace(']', "%5D")
117}
118
119#[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 Severity::Off => Level::Info,
134 }
135}
136
137#[must_use]
143pub fn print_results(
144 results: &AnalysisResults,
145 ctx: &ReportContext<'_>,
146 output: OutputFormat,
147 regression: Option<&crate::regression::RegressionOutcome>,
148) -> ExitCode {
149 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#[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#[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#[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
308pub 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
320pub 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
330pub 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
338pub 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
346pub 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
354pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[test]
639 fn severity_to_level_is_const_evaluable() {
640 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 #[test]
652 fn level_is_copy() {
653 let level = severity_to_level(Severity::Error);
654 let copy = level;
655 assert!(matches!(level, Level::Error));
657 assert!(matches!(copy, Level::Error));
658 }
659
660 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}