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