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#[allow(
23 unused_imports,
24 reason = "used by binary crate modules (combined.rs, audit.rs)"
25)]
26pub use json::strip_root_prefix;
27
28pub struct ReportContext<'a> {
33 pub root: &'a Path,
34 pub rules: &'a RulesConfig,
35 pub elapsed: Duration,
36 pub quiet: bool,
37 pub explain: bool,
38 pub group_by: Option<OwnershipResolver>,
40 pub top: Option<usize>,
42 pub summary: bool,
44}
45
46#[must_use]
48pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
49 path.strip_prefix(root).unwrap_or(path)
50}
51
52#[must_use]
55pub fn split_dir_filename(path: &str) -> (&str, &str) {
56 path.rfind('/')
57 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
58}
59
60#[must_use]
62pub const fn plural(n: usize) -> &'static str {
63 if n == 1 { "" } else { "s" }
64}
65
66#[must_use]
71pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
72 match serde_json::to_string_pretty(value) {
73 Ok(json) => {
74 println!("{json}");
75 ExitCode::SUCCESS
76 }
77 Err(e) => {
78 eprintln!("Error: failed to serialize {kind} output: {e}");
79 ExitCode::from(2)
80 }
81 }
82}
83
84#[must_use]
90pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
91 let mut last_sep = 0;
92 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
93 if a != b {
94 break;
95 }
96 if a == b'/' {
97 last_sep = i + 1;
98 }
99 }
100 if last_sep > 0 && last_sep <= target.len() {
101 &target[last_sep..]
102 } else {
103 target
104 }
105}
106
107fn relative_uri(path: &Path, root: &Path) -> String {
109 normalize_uri(&relative_path(path, root).display().to_string())
110}
111
112#[must_use]
117pub fn normalize_uri(path_str: &str) -> String {
118 path_str
119 .replace('\\', "/")
120 .replace('[', "%5B")
121 .replace(']', "%5D")
122}
123
124#[derive(Clone, Copy, Debug)]
126pub enum Level {
127 Warn,
128 Info,
129 Error,
130}
131
132#[must_use]
133pub const fn severity_to_level(s: Severity) -> Level {
134 match s {
135 Severity::Error => Level::Error,
136 Severity::Warn => Level::Warn,
137 Severity::Off => Level::Info,
139 }
140}
141
142#[must_use]
148pub fn print_results(
149 results: &AnalysisResults,
150 ctx: &ReportContext<'_>,
151 output: OutputFormat,
152 regression: Option<&crate::regression::RegressionOutcome>,
153) -> ExitCode {
154 if let Some(ref resolver) = ctx.group_by {
156 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
157 return print_grouped_results(&groups, results, ctx, output, resolver);
158 }
159
160 match output {
161 OutputFormat::Human => {
162 if ctx.summary {
163 human::check::print_check_summary(results, ctx.rules, ctx.elapsed, ctx.quiet);
164 } else {
165 human::print_human(
166 results,
167 ctx.root,
168 ctx.rules,
169 ctx.elapsed,
170 ctx.quiet,
171 ctx.top,
172 );
173 }
174 ExitCode::SUCCESS
175 }
176 OutputFormat::Json => {
177 json::print_json(results, ctx.root, ctx.elapsed, ctx.explain, regression)
178 }
179 OutputFormat::Compact => {
180 compact::print_compact(results, ctx.root);
181 ExitCode::SUCCESS
182 }
183 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
184 OutputFormat::Markdown => {
185 markdown::print_markdown(results, ctx.root);
186 ExitCode::SUCCESS
187 }
188 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
189 OutputFormat::Badge => {
190 eprintln!("Error: badge format is only supported for the health command");
191 ExitCode::from(2)
192 }
193 }
194}
195
196#[must_use]
198fn print_grouped_results(
199 groups: &[grouping::ResultGroup],
200 original: &AnalysisResults,
201 ctx: &ReportContext<'_>,
202 output: OutputFormat,
203 resolver: &OwnershipResolver,
204) -> ExitCode {
205 match output {
206 OutputFormat::Human => {
207 human::print_grouped_human(
208 groups,
209 ctx.root,
210 ctx.rules,
211 ctx.elapsed,
212 ctx.quiet,
213 Some(resolver),
214 );
215 ExitCode::SUCCESS
216 }
217 OutputFormat::Json => json::print_grouped_json(
218 groups,
219 original,
220 ctx.root,
221 ctx.elapsed,
222 ctx.explain,
223 resolver,
224 ),
225 OutputFormat::Compact => {
226 compact::print_grouped_compact(groups, ctx.root);
227 ExitCode::SUCCESS
228 }
229 OutputFormat::Markdown => {
230 markdown::print_grouped_markdown(groups, ctx.root);
231 ExitCode::SUCCESS
232 }
233 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
234 OutputFormat::CodeClimate => {
235 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
236 }
237 OutputFormat::Badge => {
238 eprintln!("Error: badge format is only supported for the health command");
239 ExitCode::from(2)
240 }
241 }
242}
243
244#[must_use]
248pub fn print_duplication_report(
249 report: &DuplicationReport,
250 ctx: &ReportContext<'_>,
251 output: OutputFormat,
252) -> ExitCode {
253 match output {
254 OutputFormat::Human => {
255 if ctx.summary {
256 human::dupes::print_duplication_summary(report, ctx.elapsed, ctx.quiet);
257 } else {
258 human::print_duplication_human(report, ctx.root, ctx.elapsed, ctx.quiet);
259 }
260 ExitCode::SUCCESS
261 }
262 OutputFormat::Json => {
263 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
264 }
265 OutputFormat::Compact => {
266 compact::print_duplication_compact(report, ctx.root);
267 ExitCode::SUCCESS
268 }
269 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
270 OutputFormat::Markdown => {
271 markdown::print_duplication_markdown(report, ctx.root);
272 ExitCode::SUCCESS
273 }
274 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
275 OutputFormat::Badge => {
276 eprintln!("Error: badge format is only supported for the health command");
277 ExitCode::from(2)
278 }
279 }
280}
281
282#[must_use]
286pub fn print_health_report(
287 report: &crate::health_types::HealthReport,
288 ctx: &ReportContext<'_>,
289 output: OutputFormat,
290) -> ExitCode {
291 match output {
292 OutputFormat::Human => {
293 if ctx.summary {
294 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet);
295 } else {
296 human::print_health_human(report, ctx.root, ctx.elapsed, ctx.quiet);
297 }
298 ExitCode::SUCCESS
299 }
300 OutputFormat::Compact => {
301 compact::print_health_compact(report, ctx.root);
302 ExitCode::SUCCESS
303 }
304 OutputFormat::Markdown => {
305 markdown::print_health_markdown(report, ctx.root);
306 ExitCode::SUCCESS
307 }
308 OutputFormat::Sarif => sarif::print_health_sarif(report, ctx.root),
309 OutputFormat::Json => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
310 OutputFormat::CodeClimate => codeclimate::print_health_codeclimate(report, ctx.root),
311 OutputFormat::Badge => badge::print_health_badge(report),
312 }
313}
314
315pub fn print_cross_reference_findings(
319 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
320 root: &Path,
321 quiet: bool,
322 output: OutputFormat,
323) {
324 human::print_cross_reference_findings(cross_ref, root, quiet, output);
325}
326
327pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
331 match format {
332 OutputFormat::Json => json::print_trace_json(trace),
333 _ => human::print_export_trace_human(trace),
334 }
335}
336
337pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
339 match format {
340 OutputFormat::Json => json::print_trace_json(trace),
341 _ => human::print_file_trace_human(trace),
342 }
343}
344
345pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
347 match format {
348 OutputFormat::Json => json::print_trace_json(trace),
349 _ => human::print_dependency_trace_human(trace),
350 }
351}
352
353pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
355 match format {
356 OutputFormat::Json => json::print_trace_json(trace),
357 _ => human::print_clone_trace_human(trace, root),
358 }
359}
360
361pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
364 match format {
365 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
366 Ok(json) => eprintln!("{json}"),
367 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
368 },
369 _ => human::print_performance_human(timings),
370 }
371}
372
373#[allow(
376 unused_imports,
377 reason = "target-dependent: used in lib, unused in bin"
378)]
379pub use codeclimate::build_codeclimate;
380#[allow(
381 unused_imports,
382 reason = "target-dependent: used in lib, unused in bin"
383)]
384pub use codeclimate::build_duplication_codeclimate;
385#[allow(
386 unused_imports,
387 reason = "target-dependent: used in lib, unused in bin"
388)]
389pub use codeclimate::build_health_codeclimate;
390#[allow(
391 unused_imports,
392 reason = "target-dependent: used in lib, unused in bin"
393)]
394pub use compact::build_compact_lines;
395pub use json::build_baseline_deltas_json;
396#[allow(
397 unused_imports,
398 reason = "target-dependent: used in lib, unused in bin"
399)]
400pub use json::build_json;
401#[allow(
402 unused_imports,
403 reason = "target-dependent: used in bin audit.rs, unused in lib"
404)]
405#[allow(
406 clippy::redundant_pub_crate,
407 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
408)]
409pub(crate) use json::inject_dupes_actions;
410#[allow(
411 unused_imports,
412 reason = "target-dependent: used in bin audit.rs, unused in lib"
413)]
414#[allow(
415 clippy::redundant_pub_crate,
416 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
417)]
418pub(crate) use json::inject_health_actions;
419#[allow(
420 unused_imports,
421 reason = "target-dependent: used in lib, unused in bin"
422)]
423pub use markdown::build_duplication_markdown;
424#[allow(
425 unused_imports,
426 reason = "target-dependent: used in lib, unused in bin"
427)]
428pub use markdown::build_health_markdown;
429#[allow(
430 unused_imports,
431 reason = "target-dependent: used in lib, unused in bin"
432)]
433pub use markdown::build_markdown;
434#[allow(
435 unused_imports,
436 reason = "target-dependent: used in lib, unused in bin"
437)]
438pub use sarif::build_health_sarif;
439#[allow(
440 unused_imports,
441 reason = "target-dependent: used in lib, unused in bin"
442)]
443pub use sarif::build_sarif;
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use std::path::PathBuf;
449
450 #[test]
453 fn normalize_uri_forward_slashes_unchanged() {
454 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
455 }
456
457 #[test]
458 fn normalize_uri_backslashes_replaced() {
459 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
460 }
461
462 #[test]
463 fn normalize_uri_mixed_slashes() {
464 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
465 }
466
467 #[test]
468 fn normalize_uri_path_with_spaces() {
469 assert_eq!(
470 normalize_uri("src\\my folder\\file.ts"),
471 "src/my folder/file.ts"
472 );
473 }
474
475 #[test]
476 fn normalize_uri_empty_string() {
477 assert_eq!(normalize_uri(""), "");
478 }
479
480 #[test]
483 fn relative_path_strips_root_prefix() {
484 let root = Path::new("/project");
485 let path = Path::new("/project/src/utils.ts");
486 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
487 }
488
489 #[test]
490 fn relative_path_returns_full_path_when_no_prefix() {
491 let root = Path::new("/other");
492 let path = Path::new("/project/src/utils.ts");
493 assert_eq!(relative_path(path, root), path);
494 }
495
496 #[test]
497 fn relative_path_at_root_returns_empty_or_file() {
498 let root = Path::new("/project");
499 let path = Path::new("/project/file.ts");
500 assert_eq!(relative_path(path, root), Path::new("file.ts"));
501 }
502
503 #[test]
504 fn relative_path_deeply_nested() {
505 let root = Path::new("/project");
506 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
507 assert_eq!(
508 relative_path(path, root),
509 Path::new("packages/ui/src/components/Button.tsx")
510 );
511 }
512
513 #[test]
516 fn relative_uri_produces_forward_slash_path() {
517 let root = PathBuf::from("/project");
518 let path = root.join("src").join("utils.ts");
519 let uri = relative_uri(&path, &root);
520 assert_eq!(uri, "src/utils.ts");
521 }
522
523 #[test]
524 fn relative_uri_encodes_brackets() {
525 let root = PathBuf::from("/project");
526 let path = root.join("src/app/[...slug]/page.tsx");
527 let uri = relative_uri(&path, &root);
528 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
529 }
530
531 #[test]
532 fn relative_uri_encodes_nested_dynamic_routes() {
533 let root = PathBuf::from("/project");
534 let path = root.join("src/app/[slug]/[id]/page.tsx");
535 let uri = relative_uri(&path, &root);
536 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
537 }
538
539 #[test]
540 fn relative_uri_no_common_prefix_returns_full() {
541 let root = PathBuf::from("/other");
542 let path = PathBuf::from("/project/src/utils.ts");
543 let uri = relative_uri(&path, &root);
544 assert!(uri.contains("project"));
545 assert!(uri.contains("utils.ts"));
546 }
547
548 #[test]
551 fn severity_error_maps_to_level_error() {
552 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
553 }
554
555 #[test]
556 fn severity_warn_maps_to_level_warn() {
557 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
558 }
559
560 #[test]
561 fn severity_off_maps_to_level_info() {
562 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
563 }
564
565 #[test]
568 fn normalize_uri_single_bracket_pair() {
569 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
570 }
571
572 #[test]
573 fn normalize_uri_catch_all_route() {
574 assert_eq!(
575 normalize_uri("app/[...slug]/page.tsx"),
576 "app/%5B...slug%5D/page.tsx"
577 );
578 }
579
580 #[test]
581 fn normalize_uri_optional_catch_all_route() {
582 assert_eq!(
583 normalize_uri("app/[[...slug]]/page.tsx"),
584 "app/%5B%5B...slug%5D%5D/page.tsx"
585 );
586 }
587
588 #[test]
589 fn normalize_uri_multiple_dynamic_segments() {
590 assert_eq!(
591 normalize_uri("app/[lang]/posts/[id]"),
592 "app/%5Blang%5D/posts/%5Bid%5D"
593 );
594 }
595
596 #[test]
597 fn normalize_uri_no_special_chars() {
598 let plain = "src/components/Button.tsx";
599 assert_eq!(normalize_uri(plain), plain);
600 }
601
602 #[test]
603 fn normalize_uri_only_backslashes() {
604 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
605 }
606
607 #[test]
610 fn relative_path_identical_paths_returns_empty() {
611 let root = Path::new("/project");
612 assert_eq!(relative_path(root, root), Path::new(""));
613 }
614
615 #[test]
616 fn relative_path_partial_name_match_not_stripped() {
617 let root = Path::new("/project");
620 let path = Path::new("/project-two/src/a.ts");
621 assert_eq!(relative_path(path, root), path);
622 }
623
624 #[test]
627 fn relative_uri_combines_stripping_and_encoding() {
628 let root = PathBuf::from("/project");
629 let path = root.join("src/app/[slug]/page.tsx");
630 let uri = relative_uri(&path, &root);
631 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
633 assert!(!uri.starts_with('/'));
634 }
635
636 #[test]
637 fn relative_uri_at_root_file() {
638 let root = PathBuf::from("/project");
639 let path = root.join("index.ts");
640 assert_eq!(relative_uri(&path, &root), "index.ts");
641 }
642
643 #[test]
646 fn severity_to_level_is_const_evaluable() {
647 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
649 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
650 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
651 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
652 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
653 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
654 }
655
656 #[test]
659 fn level_is_copy() {
660 let level = severity_to_level(Severity::Error);
661 let copy = level;
662 assert!(matches!(level, Level::Error));
664 assert!(matches!(copy, Level::Error));
665 }
666
667 #[test]
670 fn elide_common_prefix_shared_dir() {
671 assert_eq!(
672 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
673 "B.tsx"
674 );
675 }
676
677 #[test]
678 fn elide_common_prefix_partial_shared() {
679 assert_eq!(
680 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
681 "utils/B.tsx"
682 );
683 }
684
685 #[test]
686 fn elide_common_prefix_no_shared() {
687 assert_eq!(
688 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
689 "pkg-b/src/B.tsx"
690 );
691 }
692
693 #[test]
694 fn elide_common_prefix_identical_files() {
695 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
697 }
698
699 #[test]
700 fn elide_common_prefix_no_dirs() {
701 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
702 }
703
704 #[test]
705 fn elide_common_prefix_deep_monorepo() {
706 assert_eq!(
707 elide_common_prefix(
708 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
709 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
710 ),
711 "SearchSelectItem.tsx"
712 );
713 }
714
715 #[test]
718 fn split_dir_filename_with_dir() {
719 let (dir, file) = split_dir_filename("src/utils/index.ts");
720 assert_eq!(dir, "src/utils/");
721 assert_eq!(file, "index.ts");
722 }
723
724 #[test]
725 fn split_dir_filename_no_dir() {
726 let (dir, file) = split_dir_filename("file.ts");
727 assert_eq!(dir, "");
728 assert_eq!(file, "file.ts");
729 }
730
731 #[test]
732 fn split_dir_filename_deeply_nested() {
733 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
734 assert_eq!(dir, "a/b/c/d/");
735 assert_eq!(file, "e.ts");
736 }
737
738 #[test]
739 fn split_dir_filename_trailing_slash() {
740 let (dir, file) = split_dir_filename("src/");
741 assert_eq!(dir, "src/");
742 assert_eq!(file, "");
743 }
744
745 #[test]
746 fn split_dir_filename_empty() {
747 let (dir, file) = split_dir_filename("");
748 assert_eq!(dir, "");
749 assert_eq!(file, "");
750 }
751
752 #[test]
755 fn plural_zero_is_plural() {
756 assert_eq!(plural(0), "s");
757 }
758
759 #[test]
760 fn plural_one_is_singular() {
761 assert_eq!(plural(1), "");
762 }
763
764 #[test]
765 fn plural_two_is_plural() {
766 assert_eq!(plural(2), "s");
767 }
768
769 #[test]
770 fn plural_large_number() {
771 assert_eq!(plural(999), "s");
772 }
773
774 #[test]
777 fn elide_common_prefix_empty_base() {
778 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
779 }
780
781 #[test]
782 fn elide_common_prefix_empty_target() {
783 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
784 }
785
786 #[test]
787 fn elide_common_prefix_both_empty() {
788 assert_eq!(elide_common_prefix("", ""), "");
789 }
790
791 #[test]
792 fn elide_common_prefix_same_file_different_extension() {
793 assert_eq!(
795 elide_common_prefix("src/utils.ts", "src/utils.js"),
796 "utils.js"
797 );
798 }
799
800 #[test]
801 fn elide_common_prefix_partial_filename_match_not_stripped() {
802 assert_eq!(
804 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
805 "AppUtils.tsx"
806 );
807 }
808
809 #[test]
810 fn elide_common_prefix_identical_paths() {
811 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
812 }
813
814 #[test]
815 fn split_dir_filename_single_slash() {
816 let (dir, file) = split_dir_filename("/file.ts");
817 assert_eq!(dir, "/");
818 assert_eq!(file, "file.ts");
819 }
820
821 #[test]
822 fn emit_json_returns_success_for_valid_value() {
823 let value = serde_json::json!({"key": "value"});
824 let code = emit_json(&value, "test");
825 assert_eq!(code, ExitCode::SUCCESS);
826 }
827
828 mod proptests {
829 use super::*;
830 use proptest::prelude::*;
831
832 proptest! {
833 #[test]
835 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
836 let (dir, file) = split_dir_filename(&path);
837 let reconstructed = format!("{dir}{file}");
838 prop_assert_eq!(
839 reconstructed, path,
840 "dir+file should reconstruct the original path"
841 );
842 }
843
844 #[test]
846 fn plural_returns_empty_or_s(n: usize) {
847 let result = plural(n);
848 prop_assert!(
849 result.is_empty() || result == "s",
850 "plural should return \"\" or \"s\", got {:?}",
851 result
852 );
853 }
854
855 #[test]
857 fn plural_singular_only_for_one(n: usize) {
858 let result = plural(n);
859 if n == 1 {
860 prop_assert_eq!(result, "", "plural(1) should be empty");
861 } else {
862 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
863 }
864 }
865
866 #[test]
868 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
869 let result = normalize_uri(&path);
870 prop_assert!(
871 !result.contains('\\'),
872 "Result should not contain backslashes: {result}"
873 );
874 }
875
876 #[test]
878 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
879 let result = normalize_uri(&path);
880 prop_assert!(
881 !result.contains('[') && !result.contains(']'),
882 "Result should not contain raw brackets: {result}"
883 );
884 }
885
886 #[test]
888 fn elide_common_prefix_returns_suffix_of_target(
889 base in "[a-zA-Z0-9_./]{0,50}",
890 target in "[a-zA-Z0-9_./]{0,50}",
891 ) {
892 let result = elide_common_prefix(&base, &target);
893 prop_assert!(
894 target.ends_with(result),
895 "Result {:?} should be a suffix of target {:?}",
896 result, target
897 );
898 }
899
900 #[test]
902 fn relative_path_never_panics(
903 root in "/[a-zA-Z0-9_/]{0,30}",
904 suffix in "[a-zA-Z0-9_./]{0,30}",
905 ) {
906 let root_path = Path::new(&root);
907 let full = PathBuf::from(format!("{root}/{suffix}"));
908 let _ = relative_path(&full, root_path);
909 }
910 }
911 }
912}