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