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