1mod badge;
2mod codeclimate;
3mod compact;
4pub mod grouping;
5mod human;
6mod json;
7mod markdown;
8mod sarif;
9#[cfg(test)]
10pub mod 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 pub baseline_matched: Option<(usize, usize)>,
46}
47
48#[must_use]
50pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
51 path.strip_prefix(root).unwrap_or(path)
52}
53
54#[must_use]
57pub fn split_dir_filename(path: &str) -> (&str, &str) {
58 path.rfind('/')
59 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
60}
61
62#[must_use]
64pub const fn plural(n: usize) -> &'static str {
65 if n == 1 { "" } else { "s" }
66}
67
68#[must_use]
73pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
74 match serde_json::to_string_pretty(value) {
75 Ok(json) => {
76 println!("{json}");
77 ExitCode::SUCCESS
78 }
79 Err(e) => {
80 eprintln!("Error: failed to serialize {kind} output: {e}");
81 ExitCode::from(2)
82 }
83 }
84}
85
86#[must_use]
92pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
93 let mut last_sep = 0;
94 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
95 if a != b {
96 break;
97 }
98 if a == b'/' {
99 last_sep = i + 1;
100 }
101 }
102 if last_sep > 0 && last_sep <= target.len() {
103 &target[last_sep..]
104 } else {
105 target
106 }
107}
108
109fn relative_uri(path: &Path, root: &Path) -> String {
111 normalize_uri(&relative_path(path, root).display().to_string())
112}
113
114#[must_use]
119pub fn normalize_uri(path_str: &str) -> String {
120 path_str
121 .replace('\\', "/")
122 .replace('[', "%5B")
123 .replace(']', "%5D")
124}
125
126#[derive(Clone, Copy, Debug)]
128pub enum Level {
129 Warn,
130 Info,
131 Error,
132}
133
134#[must_use]
135pub const fn severity_to_level(s: Severity) -> Level {
136 match s {
137 Severity::Error => Level::Error,
138 Severity::Warn => Level::Warn,
139 Severity::Off => Level::Info,
141 }
142}
143
144#[must_use]
150pub fn print_results(
151 results: &AnalysisResults,
152 ctx: &ReportContext<'_>,
153 output: OutputFormat,
154 regression: Option<&crate::regression::RegressionOutcome>,
155) -> ExitCode {
156 if let Some(ref resolver) = ctx.group_by {
158 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
159 return print_grouped_results(&groups, results, ctx, output, resolver);
160 }
161
162 match output {
163 OutputFormat::Human => {
164 if ctx.summary {
165 human::check::print_check_summary(results, ctx.rules, ctx.elapsed, ctx.quiet);
166 } else {
167 human::print_human(
168 results,
169 ctx.root,
170 ctx.rules,
171 ctx.elapsed,
172 ctx.quiet,
173 ctx.top,
174 );
175 }
176 ExitCode::SUCCESS
177 }
178 OutputFormat::Json => json::print_json(
179 results,
180 ctx.root,
181 ctx.elapsed,
182 ctx.explain,
183 regression,
184 ctx.baseline_matched,
185 ),
186 OutputFormat::Compact => {
187 compact::print_compact(results, ctx.root);
188 ExitCode::SUCCESS
189 }
190 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
191 OutputFormat::Markdown => {
192 markdown::print_markdown(results, ctx.root);
193 ExitCode::SUCCESS
194 }
195 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
196 OutputFormat::Badge => {
197 eprintln!("Error: badge format is only supported for the health command");
198 ExitCode::from(2)
199 }
200 }
201}
202
203#[must_use]
205fn print_grouped_results(
206 groups: &[grouping::ResultGroup],
207 original: &AnalysisResults,
208 ctx: &ReportContext<'_>,
209 output: OutputFormat,
210 resolver: &OwnershipResolver,
211) -> ExitCode {
212 match output {
213 OutputFormat::Human => {
214 human::print_grouped_human(
215 groups,
216 ctx.root,
217 ctx.rules,
218 ctx.elapsed,
219 ctx.quiet,
220 Some(resolver),
221 );
222 ExitCode::SUCCESS
223 }
224 OutputFormat::Json => json::print_grouped_json(
225 groups,
226 original,
227 ctx.root,
228 ctx.elapsed,
229 ctx.explain,
230 resolver,
231 ),
232 OutputFormat::Compact => {
233 compact::print_grouped_compact(groups, ctx.root);
234 ExitCode::SUCCESS
235 }
236 OutputFormat::Markdown => {
237 markdown::print_grouped_markdown(groups, ctx.root);
238 ExitCode::SUCCESS
239 }
240 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
241 OutputFormat::CodeClimate => {
242 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
243 }
244 OutputFormat::Badge => {
245 eprintln!("Error: badge format is only supported for the health command");
246 ExitCode::from(2)
247 }
248 }
249}
250
251#[must_use]
255pub fn print_duplication_report(
256 report: &DuplicationReport,
257 ctx: &ReportContext<'_>,
258 output: OutputFormat,
259) -> ExitCode {
260 match output {
261 OutputFormat::Human => {
262 if ctx.summary {
263 human::dupes::print_duplication_summary(report, ctx.elapsed, ctx.quiet);
264 } else {
265 human::print_duplication_human(report, ctx.root, ctx.elapsed, ctx.quiet);
266 }
267 ExitCode::SUCCESS
268 }
269 OutputFormat::Json => {
270 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
271 }
272 OutputFormat::Compact => {
273 compact::print_duplication_compact(report, ctx.root);
274 ExitCode::SUCCESS
275 }
276 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
277 OutputFormat::Markdown => {
278 markdown::print_duplication_markdown(report, ctx.root);
279 ExitCode::SUCCESS
280 }
281 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
282 OutputFormat::Badge => {
283 eprintln!("Error: badge format is only supported for the health command");
284 ExitCode::from(2)
285 }
286 }
287}
288
289#[must_use]
308pub fn print_health_report(
309 report: &crate::health_types::HealthReport,
310 grouping: Option<&crate::health_types::HealthGrouping>,
311 group_resolver: Option<&grouping::OwnershipResolver>,
312 ctx: &ReportContext<'_>,
313 output: OutputFormat,
314) -> ExitCode {
315 match output {
316 OutputFormat::Human => {
317 if ctx.summary {
318 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet);
319 } else {
320 human::print_health_human(report, ctx.root, ctx.elapsed, ctx.quiet);
321 if let Some(grouping) = grouping {
322 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
323 }
324 }
325 ExitCode::SUCCESS
326 }
327 OutputFormat::Compact => {
328 compact::print_health_compact(report, ctx.root);
329 warn_grouping_unsupported(grouping, "compact");
330 ExitCode::SUCCESS
331 }
332 OutputFormat::Markdown => {
333 markdown::print_health_markdown(report, ctx.root);
334 warn_grouping_unsupported(grouping, "markdown");
335 ExitCode::SUCCESS
336 }
337 OutputFormat::Sarif => match group_resolver {
338 Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
339 None => sarif::print_health_sarif(report, ctx.root),
340 },
341 OutputFormat::Json => match grouping {
342 Some(grouping) => json::print_grouped_health_json(
343 report,
344 grouping,
345 ctx.root,
346 ctx.elapsed,
347 ctx.explain,
348 ),
349 None => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
350 },
351 OutputFormat::CodeClimate => match group_resolver {
352 Some(resolver) => {
353 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
354 }
355 None => codeclimate::print_health_codeclimate(report, ctx.root),
356 },
357 OutputFormat::Badge => {
358 warn_grouping_unsupported(grouping, "badge");
359 badge::print_health_badge(report)
360 }
361 }
362}
363
364fn warn_grouping_unsupported(grouping: Option<&crate::health_types::HealthGrouping>, format: &str) {
365 if let Some(g) = grouping {
366 eprintln!(
367 "note: --group-by {} output for {format} is not yet supported, falling back to \
368 ungrouped output (use --format json for the full grouped envelope)",
369 g.mode
370 );
371 }
372}
373
374pub fn print_cross_reference_findings(
378 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
379 root: &Path,
380 quiet: bool,
381 output: OutputFormat,
382) {
383 human::print_cross_reference_findings(cross_ref, root, quiet, output);
384}
385
386pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
390 match format {
391 OutputFormat::Json => json::print_trace_json(trace),
392 _ => human::print_export_trace_human(trace),
393 }
394}
395
396pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
398 match format {
399 OutputFormat::Json => json::print_trace_json(trace),
400 _ => human::print_file_trace_human(trace),
401 }
402}
403
404pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
406 match format {
407 OutputFormat::Json => json::print_trace_json(trace),
408 _ => human::print_dependency_trace_human(trace),
409 }
410}
411
412pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
414 match format {
415 OutputFormat::Json => json::print_trace_json(trace),
416 _ => human::print_clone_trace_human(trace, root),
417 }
418}
419
420pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
423 match format {
424 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
425 Ok(json) => eprintln!("{json}"),
426 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
427 },
428 _ => human::print_performance_human(timings),
429 }
430}
431
432pub fn print_health_performance(
435 timings: &crate::health_types::HealthTimings,
436 format: OutputFormat,
437) {
438 match format {
439 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
440 Ok(json) => eprintln!("{json}"),
441 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
442 },
443 _ => human::print_health_performance_human(timings),
444 }
445}
446
447#[allow(
450 unused_imports,
451 reason = "target-dependent: used in lib, unused in bin"
452)]
453pub use codeclimate::build_codeclimate;
454#[allow(
455 unused_imports,
456 reason = "target-dependent: used in lib, unused in bin"
457)]
458pub use codeclimate::build_duplication_codeclimate;
459#[allow(
460 unused_imports,
461 reason = "target-dependent: used in lib, unused in bin"
462)]
463pub use codeclimate::build_health_codeclimate;
464#[allow(
465 unused_imports,
466 reason = "target-dependent: used in lib, unused in bin"
467)]
468pub use compact::build_compact_lines;
469pub use json::build_baseline_deltas_json;
470#[allow(
471 unused_imports,
472 reason = "target-dependent: used in lib, unused in bin"
473)]
474pub use json::build_duplication_json;
475#[allow(
476 unused_imports,
477 reason = "target-dependent: used in lib, unused in bin"
478)]
479pub use json::build_health_json;
480#[allow(
481 unused_imports,
482 reason = "target-dependent: used in lib, unused in bin"
483)]
484pub use json::build_json;
485#[allow(
486 unused_imports,
487 reason = "target-dependent: used in bin audit.rs, unused in lib"
488)]
489#[allow(
490 clippy::redundant_pub_crate,
491 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
492)]
493pub(crate) use json::inject_dupes_actions;
494#[allow(
495 unused_imports,
496 reason = "target-dependent: used in bin audit.rs, unused in lib"
497)]
498#[allow(
499 clippy::redundant_pub_crate,
500 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
501)]
502pub(crate) use json::inject_health_actions;
503#[allow(
504 unused_imports,
505 reason = "target-dependent: used in lib, unused in bin"
506)]
507pub use markdown::build_duplication_markdown;
508#[allow(
509 unused_imports,
510 reason = "target-dependent: used in lib, unused in bin"
511)]
512pub use markdown::build_health_markdown;
513#[allow(
514 unused_imports,
515 reason = "target-dependent: used in lib, unused in bin"
516)]
517pub use markdown::build_markdown;
518#[allow(
519 unused_imports,
520 reason = "target-dependent: used in lib, unused in bin"
521)]
522pub use sarif::build_health_sarif;
523#[allow(
524 unused_imports,
525 reason = "target-dependent: used in lib, unused in bin"
526)]
527pub use sarif::build_sarif;
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use std::path::PathBuf;
533
534 #[test]
537 fn normalize_uri_forward_slashes_unchanged() {
538 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
539 }
540
541 #[test]
542 fn normalize_uri_backslashes_replaced() {
543 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
544 }
545
546 #[test]
547 fn normalize_uri_mixed_slashes() {
548 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
549 }
550
551 #[test]
552 fn normalize_uri_path_with_spaces() {
553 assert_eq!(
554 normalize_uri("src\\my folder\\file.ts"),
555 "src/my folder/file.ts"
556 );
557 }
558
559 #[test]
560 fn normalize_uri_empty_string() {
561 assert_eq!(normalize_uri(""), "");
562 }
563
564 #[test]
567 fn relative_path_strips_root_prefix() {
568 let root = Path::new("/project");
569 let path = Path::new("/project/src/utils.ts");
570 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
571 }
572
573 #[test]
574 fn relative_path_returns_full_path_when_no_prefix() {
575 let root = Path::new("/other");
576 let path = Path::new("/project/src/utils.ts");
577 assert_eq!(relative_path(path, root), path);
578 }
579
580 #[test]
581 fn relative_path_at_root_returns_empty_or_file() {
582 let root = Path::new("/project");
583 let path = Path::new("/project/file.ts");
584 assert_eq!(relative_path(path, root), Path::new("file.ts"));
585 }
586
587 #[test]
588 fn relative_path_deeply_nested() {
589 let root = Path::new("/project");
590 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
591 assert_eq!(
592 relative_path(path, root),
593 Path::new("packages/ui/src/components/Button.tsx")
594 );
595 }
596
597 #[test]
600 fn relative_uri_produces_forward_slash_path() {
601 let root = PathBuf::from("/project");
602 let path = root.join("src").join("utils.ts");
603 let uri = relative_uri(&path, &root);
604 assert_eq!(uri, "src/utils.ts");
605 }
606
607 #[test]
608 fn relative_uri_encodes_brackets() {
609 let root = PathBuf::from("/project");
610 let path = root.join("src/app/[...slug]/page.tsx");
611 let uri = relative_uri(&path, &root);
612 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
613 }
614
615 #[test]
616 fn relative_uri_encodes_nested_dynamic_routes() {
617 let root = PathBuf::from("/project");
618 let path = root.join("src/app/[slug]/[id]/page.tsx");
619 let uri = relative_uri(&path, &root);
620 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
621 }
622
623 #[test]
624 fn relative_uri_no_common_prefix_returns_full() {
625 let root = PathBuf::from("/other");
626 let path = PathBuf::from("/project/src/utils.ts");
627 let uri = relative_uri(&path, &root);
628 assert!(uri.contains("project"));
629 assert!(uri.contains("utils.ts"));
630 }
631
632 #[test]
635 fn severity_error_maps_to_level_error() {
636 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
637 }
638
639 #[test]
640 fn severity_warn_maps_to_level_warn() {
641 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
642 }
643
644 #[test]
645 fn severity_off_maps_to_level_info() {
646 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
647 }
648
649 #[test]
652 fn normalize_uri_single_bracket_pair() {
653 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
654 }
655
656 #[test]
657 fn normalize_uri_catch_all_route() {
658 assert_eq!(
659 normalize_uri("app/[...slug]/page.tsx"),
660 "app/%5B...slug%5D/page.tsx"
661 );
662 }
663
664 #[test]
665 fn normalize_uri_optional_catch_all_route() {
666 assert_eq!(
667 normalize_uri("app/[[...slug]]/page.tsx"),
668 "app/%5B%5B...slug%5D%5D/page.tsx"
669 );
670 }
671
672 #[test]
673 fn normalize_uri_multiple_dynamic_segments() {
674 assert_eq!(
675 normalize_uri("app/[lang]/posts/[id]"),
676 "app/%5Blang%5D/posts/%5Bid%5D"
677 );
678 }
679
680 #[test]
681 fn normalize_uri_no_special_chars() {
682 let plain = "src/components/Button.tsx";
683 assert_eq!(normalize_uri(plain), plain);
684 }
685
686 #[test]
687 fn normalize_uri_only_backslashes() {
688 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
689 }
690
691 #[test]
694 fn relative_path_identical_paths_returns_empty() {
695 let root = Path::new("/project");
696 assert_eq!(relative_path(root, root), Path::new(""));
697 }
698
699 #[test]
700 fn relative_path_partial_name_match_not_stripped() {
701 let root = Path::new("/project");
704 let path = Path::new("/project-two/src/a.ts");
705 assert_eq!(relative_path(path, root), path);
706 }
707
708 #[test]
711 fn relative_uri_combines_stripping_and_encoding() {
712 let root = PathBuf::from("/project");
713 let path = root.join("src/app/[slug]/page.tsx");
714 let uri = relative_uri(&path, &root);
715 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
717 assert!(!uri.starts_with('/'));
718 }
719
720 #[test]
721 fn relative_uri_at_root_file() {
722 let root = PathBuf::from("/project");
723 let path = root.join("index.ts");
724 assert_eq!(relative_uri(&path, &root), "index.ts");
725 }
726
727 #[test]
730 fn severity_to_level_is_const_evaluable() {
731 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
733 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
734 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
735 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
736 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
737 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
738 }
739
740 #[test]
743 fn level_is_copy() {
744 let level = severity_to_level(Severity::Error);
745 let copy = level;
746 assert!(matches!(level, Level::Error));
748 assert!(matches!(copy, Level::Error));
749 }
750
751 #[test]
754 fn elide_common_prefix_shared_dir() {
755 assert_eq!(
756 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
757 "B.tsx"
758 );
759 }
760
761 #[test]
762 fn elide_common_prefix_partial_shared() {
763 assert_eq!(
764 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
765 "utils/B.tsx"
766 );
767 }
768
769 #[test]
770 fn elide_common_prefix_no_shared() {
771 assert_eq!(
772 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
773 "pkg-b/src/B.tsx"
774 );
775 }
776
777 #[test]
778 fn elide_common_prefix_identical_files() {
779 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
781 }
782
783 #[test]
784 fn elide_common_prefix_no_dirs() {
785 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
786 }
787
788 #[test]
789 fn elide_common_prefix_deep_monorepo() {
790 assert_eq!(
791 elide_common_prefix(
792 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
793 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
794 ),
795 "SearchSelectItem.tsx"
796 );
797 }
798
799 #[test]
802 fn split_dir_filename_with_dir() {
803 let (dir, file) = split_dir_filename("src/utils/index.ts");
804 assert_eq!(dir, "src/utils/");
805 assert_eq!(file, "index.ts");
806 }
807
808 #[test]
809 fn split_dir_filename_no_dir() {
810 let (dir, file) = split_dir_filename("file.ts");
811 assert_eq!(dir, "");
812 assert_eq!(file, "file.ts");
813 }
814
815 #[test]
816 fn split_dir_filename_deeply_nested() {
817 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
818 assert_eq!(dir, "a/b/c/d/");
819 assert_eq!(file, "e.ts");
820 }
821
822 #[test]
823 fn split_dir_filename_trailing_slash() {
824 let (dir, file) = split_dir_filename("src/");
825 assert_eq!(dir, "src/");
826 assert_eq!(file, "");
827 }
828
829 #[test]
830 fn split_dir_filename_empty() {
831 let (dir, file) = split_dir_filename("");
832 assert_eq!(dir, "");
833 assert_eq!(file, "");
834 }
835
836 #[test]
839 fn plural_zero_is_plural() {
840 assert_eq!(plural(0), "s");
841 }
842
843 #[test]
844 fn plural_one_is_singular() {
845 assert_eq!(plural(1), "");
846 }
847
848 #[test]
849 fn plural_two_is_plural() {
850 assert_eq!(plural(2), "s");
851 }
852
853 #[test]
854 fn plural_large_number() {
855 assert_eq!(plural(999), "s");
856 }
857
858 #[test]
861 fn elide_common_prefix_empty_base() {
862 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
863 }
864
865 #[test]
866 fn elide_common_prefix_empty_target() {
867 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
868 }
869
870 #[test]
871 fn elide_common_prefix_both_empty() {
872 assert_eq!(elide_common_prefix("", ""), "");
873 }
874
875 #[test]
876 fn elide_common_prefix_same_file_different_extension() {
877 assert_eq!(
879 elide_common_prefix("src/utils.ts", "src/utils.js"),
880 "utils.js"
881 );
882 }
883
884 #[test]
885 fn elide_common_prefix_partial_filename_match_not_stripped() {
886 assert_eq!(
888 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
889 "AppUtils.tsx"
890 );
891 }
892
893 #[test]
894 fn elide_common_prefix_identical_paths() {
895 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
896 }
897
898 #[test]
899 fn split_dir_filename_single_slash() {
900 let (dir, file) = split_dir_filename("/file.ts");
901 assert_eq!(dir, "/");
902 assert_eq!(file, "file.ts");
903 }
904
905 #[test]
906 fn emit_json_returns_success_for_valid_value() {
907 let value = serde_json::json!({"key": "value"});
908 let code = emit_json(&value, "test");
909 assert_eq!(code, ExitCode::SUCCESS);
910 }
911
912 mod proptests {
913 use super::*;
914 use proptest::prelude::*;
915
916 proptest! {
917 #[test]
919 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
920 let (dir, file) = split_dir_filename(&path);
921 let reconstructed = format!("{dir}{file}");
922 prop_assert_eq!(
923 reconstructed, path,
924 "dir+file should reconstruct the original path"
925 );
926 }
927
928 #[test]
930 fn plural_returns_empty_or_s(n: usize) {
931 let result = plural(n);
932 prop_assert!(
933 result.is_empty() || result == "s",
934 "plural should return \"\" or \"s\", got {:?}",
935 result
936 );
937 }
938
939 #[test]
941 fn plural_singular_only_for_one(n: usize) {
942 let result = plural(n);
943 if n == 1 {
944 prop_assert_eq!(result, "", "plural(1) should be empty");
945 } else {
946 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
947 }
948 }
949
950 #[test]
952 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
953 let result = normalize_uri(&path);
954 prop_assert!(
955 !result.contains('\\'),
956 "Result should not contain backslashes: {result}"
957 );
958 }
959
960 #[test]
962 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
963 let result = normalize_uri(&path);
964 prop_assert!(
965 !result.contains('[') && !result.contains(']'),
966 "Result should not contain raw brackets: {result}"
967 );
968 }
969
970 #[test]
972 fn elide_common_prefix_returns_suffix_of_target(
973 base in "[a-zA-Z0-9_./]{0,50}",
974 target in "[a-zA-Z0-9_./]{0,50}",
975 ) {
976 let result = elide_common_prefix(&base, &target);
977 prop_assert!(
978 target.ends_with(result),
979 "Result {:?} should be a suffix of target {:?}",
980 result, target
981 );
982 }
983
984 #[test]
986 fn relative_path_never_panics(
987 root in "/[a-zA-Z0-9_/]{0,30}",
988 suffix in "[a-zA-Z0-9_./]{0,30}",
989 ) {
990 let root_path = Path::new(&root);
991 let full = PathBuf::from(format!("{root}/{suffix}"));
992 let _ = relative_path(&full, root_path);
993 }
994 }
995 }
996}