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