1mod badge;
2pub mod ci;
3mod codeclimate;
4mod compact;
5pub mod dupes_grouping;
6pub mod grouping;
7mod human;
8mod json;
9mod markdown;
10mod sarif;
11mod shared;
12#[cfg(test)]
13pub mod test_helpers;
14
15use std::path::Path;
16use std::process::ExitCode;
17use std::time::Duration;
18
19use fallow_config::{OutputFormat, RulesConfig, Severity};
20use fallow_core::duplicates::DuplicationReport;
21use fallow_core::results::AnalysisResults;
22use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
23
24pub use grouping::OwnershipResolver;
25#[allow(
26 unused_imports,
27 reason = "used by binary crate modules (combined.rs, audit.rs)"
28)]
29pub use json::strip_root_prefix;
30
31pub struct ReportContext<'a> {
36 pub root: &'a Path,
37 pub rules: &'a RulesConfig,
38 pub elapsed: Duration,
39 pub quiet: bool,
40 pub explain: bool,
41 pub group_by: Option<OwnershipResolver>,
43 pub top: Option<usize>,
45 pub summary: bool,
47 pub show_explain_tip: bool,
49 pub baseline_matched: Option<(usize, usize)>,
51 pub health_action_opts: HealthActionOptions,
57}
58
59#[must_use]
61pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
62 path.strip_prefix(root).unwrap_or(path)
63}
64
65#[must_use]
68pub fn split_dir_filename(path: &str) -> (&str, &str) {
69 path.rfind('/')
70 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
71}
72
73#[must_use]
75pub const fn plural(n: usize) -> &'static str {
76 if n == 1 { "" } else { "s" }
77}
78
79#[must_use]
84pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
85 match serde_json::to_string_pretty(value) {
86 Ok(json) => {
87 println!("{json}");
88 ExitCode::SUCCESS
89 }
90 Err(e) => {
91 eprintln!("Error: failed to serialize {kind} output: {e}");
92 ExitCode::from(2)
93 }
94 }
95}
96
97#[must_use]
103pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
104 let mut last_sep = 0;
105 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
106 if a != b {
107 break;
108 }
109 if a == b'/' {
110 last_sep = i + 1;
111 }
112 }
113 if last_sep > 0 && last_sep <= target.len() {
114 &target[last_sep..]
115 } else {
116 target
117 }
118}
119
120fn relative_uri(path: &Path, root: &Path) -> String {
122 normalize_uri(&relative_path(path, root).display().to_string())
123}
124
125#[must_use]
130pub fn normalize_uri(path_str: &str) -> String {
131 path_str
132 .replace('\\', "/")
133 .replace('[', "%5B")
134 .replace(']', "%5D")
135}
136
137#[derive(Clone, Copy, Debug)]
139pub enum Level {
140 Warn,
141 Info,
142 Error,
143}
144
145#[must_use]
146pub const fn severity_to_level(s: Severity) -> Level {
147 match s {
148 Severity::Error => Level::Error,
149 Severity::Warn => Level::Warn,
150 Severity::Off => Level::Info,
152 }
153}
154
155#[must_use]
161pub fn print_results(
162 results: &AnalysisResults,
163 ctx: &ReportContext<'_>,
164 output: OutputFormat,
165 regression: Option<&crate::regression::RegressionOutcome>,
166) -> ExitCode {
167 if let Some(ref resolver) = ctx.group_by {
169 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
170 return print_grouped_results(&groups, results, ctx, output, resolver);
171 }
172
173 match output {
174 OutputFormat::Human => {
175 if ctx.summary {
176 human::check::print_check_summary(results, ctx.rules, ctx.elapsed, ctx.quiet);
177 } else {
178 human::print_human(
179 results,
180 ctx.root,
181 ctx.rules,
182 ctx.elapsed,
183 ctx.quiet,
184 ctx.top,
185 ctx.show_explain_tip,
186 );
187 }
188 ExitCode::SUCCESS
189 }
190 OutputFormat::Json => json::print_json(
191 results,
192 ctx.root,
193 ctx.elapsed,
194 ctx.explain,
195 regression,
196 ctx.baseline_matched,
197 ),
198 OutputFormat::Compact => {
199 compact::print_compact(results, ctx.root);
200 ExitCode::SUCCESS
201 }
202 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
203 OutputFormat::Markdown => {
204 markdown::print_markdown(results, ctx.root);
205 ExitCode::SUCCESS
206 }
207 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
208 OutputFormat::PrCommentGithub => {
209 let value = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
210 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
211 }
212 OutputFormat::PrCommentGitlab => {
213 let value = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
214 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
215 }
216 OutputFormat::ReviewGithub => {
217 let value = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
218 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
219 }
220 OutputFormat::ReviewGitlab => {
221 let value = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
222 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
223 }
224 OutputFormat::Badge => {
225 eprintln!("Error: badge format is only supported for the health command");
226 ExitCode::from(2)
227 }
228 }
229}
230
231#[must_use]
233fn print_grouped_results(
234 groups: &[grouping::ResultGroup],
235 original: &AnalysisResults,
236 ctx: &ReportContext<'_>,
237 output: OutputFormat,
238 resolver: &OwnershipResolver,
239) -> ExitCode {
240 match output {
241 OutputFormat::Human => {
242 human::print_grouped_human(
243 groups,
244 ctx.root,
245 ctx.rules,
246 ctx.elapsed,
247 ctx.quiet,
248 Some(resolver),
249 );
250 ExitCode::SUCCESS
251 }
252 OutputFormat::Json => json::print_grouped_json(
253 groups,
254 original,
255 ctx.root,
256 ctx.elapsed,
257 ctx.explain,
258 resolver,
259 ),
260 OutputFormat::Compact => {
261 compact::print_grouped_compact(groups, ctx.root);
262 ExitCode::SUCCESS
263 }
264 OutputFormat::Markdown => {
265 markdown::print_grouped_markdown(groups, ctx.root);
266 ExitCode::SUCCESS
267 }
268 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
269 OutputFormat::CodeClimate => {
270 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
271 }
272 OutputFormat::PrCommentGithub => {
273 let value = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
274 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
275 }
276 OutputFormat::PrCommentGitlab => {
277 let value = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
278 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
279 }
280 OutputFormat::ReviewGithub => {
281 let value = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
282 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
283 }
284 OutputFormat::ReviewGitlab => {
285 let value = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
286 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
287 }
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]
299pub fn print_duplication_report(
300 report: &DuplicationReport,
301 ctx: &ReportContext<'_>,
302 output: OutputFormat,
303) -> ExitCode {
304 if let Some(ref resolver) = ctx.group_by {
308 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
309 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
310 }
311
312 match output {
313 OutputFormat::Human => {
314 if ctx.summary {
315 human::dupes::print_duplication_summary(report, ctx.elapsed, ctx.quiet);
316 } else {
317 human::print_duplication_human(
318 report,
319 ctx.root,
320 ctx.elapsed,
321 ctx.quiet,
322 ctx.show_explain_tip,
323 );
324 }
325 ExitCode::SUCCESS
326 }
327 OutputFormat::Json => {
328 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
329 }
330 OutputFormat::Compact => {
331 compact::print_duplication_compact(report, ctx.root);
332 ExitCode::SUCCESS
333 }
334 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
335 OutputFormat::Markdown => {
336 markdown::print_duplication_markdown(report, ctx.root);
337 ExitCode::SUCCESS
338 }
339 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
340 OutputFormat::PrCommentGithub => {
341 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
342 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
343 }
344 OutputFormat::PrCommentGitlab => {
345 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
346 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
347 }
348 OutputFormat::ReviewGithub => {
349 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
350 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
351 }
352 OutputFormat::ReviewGitlab => {
353 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
354 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
355 }
356 OutputFormat::Badge => {
357 eprintln!("Error: badge format is only supported for the health command");
358 ExitCode::from(2)
359 }
360 }
361}
362
363#[must_use]
365fn print_grouped_duplication_report(
366 report: &DuplicationReport,
367 grouping: &dupes_grouping::DuplicationGrouping,
368 ctx: &ReportContext<'_>,
369 output: OutputFormat,
370 resolver: &OwnershipResolver,
371) -> ExitCode {
372 match output {
373 OutputFormat::Human => {
374 human::print_grouped_duplication_human(
375 report,
376 grouping,
377 ctx.root,
378 ctx.elapsed,
379 ctx.quiet,
380 );
381 ExitCode::SUCCESS
382 }
383 OutputFormat::Json => json::print_grouped_duplication_json(
384 report,
385 grouping,
386 ctx.root,
387 ctx.elapsed,
388 ctx.explain,
389 ),
390 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
391 OutputFormat::CodeClimate => {
392 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
393 }
394 OutputFormat::PrCommentGithub => {
395 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
396 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
397 }
398 OutputFormat::PrCommentGitlab => {
399 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
400 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
401 }
402 OutputFormat::ReviewGithub => {
403 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
404 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
405 }
406 OutputFormat::ReviewGitlab => {
407 let value = codeclimate::build_duplication_codeclimate(report, ctx.root);
408 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
409 }
410 OutputFormat::Compact => {
411 compact::print_duplication_compact(report, ctx.root);
412 warn_dupes_grouping_unsupported(grouping, "compact");
413 ExitCode::SUCCESS
414 }
415 OutputFormat::Markdown => {
416 markdown::print_duplication_markdown(report, ctx.root);
417 warn_dupes_grouping_unsupported(grouping, "markdown");
418 ExitCode::SUCCESS
419 }
420 OutputFormat::Badge => {
421 eprintln!("Error: badge format is only supported for the health command");
422 ExitCode::from(2)
423 }
424 }
425}
426
427fn warn_dupes_grouping_unsupported(grouping: &dupes_grouping::DuplicationGrouping, format: &str) {
428 eprintln!(
429 "note: --group-by {} is not supported for {format} duplication output, falling back to \
430 ungrouped output (use --format json for the full grouped envelope)",
431 grouping.mode
432 );
433}
434
435#[must_use]
454pub fn print_health_report(
455 report: &crate::health_types::HealthReport,
456 grouping: Option<&crate::health_types::HealthGrouping>,
457 group_resolver: Option<&grouping::OwnershipResolver>,
458 ctx: &ReportContext<'_>,
459 output: OutputFormat,
460) -> ExitCode {
461 match output {
462 OutputFormat::Human => {
463 if ctx.summary {
464 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet);
465 } else {
466 human::print_health_human(
467 report,
468 ctx.root,
469 ctx.elapsed,
470 ctx.quiet,
471 ctx.show_explain_tip,
472 );
473 if let Some(grouping) = grouping {
474 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
475 }
476 }
477 ExitCode::SUCCESS
478 }
479 OutputFormat::Compact => {
480 compact::print_health_compact(report, ctx.root);
481 warn_grouping_unsupported(grouping, "compact");
482 ExitCode::SUCCESS
483 }
484 OutputFormat::Markdown => {
485 markdown::print_health_markdown(report, ctx.root);
486 warn_grouping_unsupported(grouping, "markdown");
487 ExitCode::SUCCESS
488 }
489 OutputFormat::Sarif => match group_resolver {
490 Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
491 None => sarif::print_health_sarif(report, ctx.root),
492 },
493 OutputFormat::Json => match grouping {
494 Some(grouping) => json::print_grouped_health_json(
495 report,
496 grouping,
497 ctx.root,
498 ctx.elapsed,
499 ctx.explain,
500 ctx.health_action_opts,
501 ),
502 None => json::print_health_json(
503 report,
504 ctx.root,
505 ctx.elapsed,
506 ctx.explain,
507 ctx.health_action_opts,
508 ),
509 },
510 OutputFormat::CodeClimate => match group_resolver {
511 Some(resolver) => {
512 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
513 }
514 None => codeclimate::print_health_codeclimate(report, ctx.root),
515 },
516 OutputFormat::PrCommentGithub => {
517 let value = codeclimate::build_health_codeclimate(report, ctx.root);
518 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Github, &value)
519 }
520 OutputFormat::PrCommentGitlab => {
521 let value = codeclimate::build_health_codeclimate(report, ctx.root);
522 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Gitlab, &value)
523 }
524 OutputFormat::ReviewGithub => {
525 let value = codeclimate::build_health_codeclimate(report, ctx.root);
526 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Github, &value)
527 }
528 OutputFormat::ReviewGitlab => {
529 let value = codeclimate::build_health_codeclimate(report, ctx.root);
530 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Gitlab, &value)
531 }
532 OutputFormat::Badge => {
533 warn_grouping_unsupported(grouping, "badge");
534 badge::print_health_badge(report)
535 }
536 }
537}
538
539fn warn_grouping_unsupported(grouping: Option<&crate::health_types::HealthGrouping>, format: &str) {
540 if let Some(g) = grouping {
541 eprintln!(
542 "note: --group-by {} is not supported for {format} output, falling back to \
543 ungrouped output (use --format json for the full grouped envelope)",
544 g.mode
545 );
546 }
547}
548
549pub fn print_cross_reference_findings(
553 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
554 root: &Path,
555 quiet: bool,
556 output: OutputFormat,
557) {
558 human::print_cross_reference_findings(cross_ref, root, quiet, output);
559}
560
561pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
565 match format {
566 OutputFormat::Json => json::print_trace_json(trace),
567 _ => human::print_export_trace_human(trace),
568 }
569}
570
571pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
573 match format {
574 OutputFormat::Json => json::print_trace_json(trace),
575 _ => human::print_file_trace_human(trace),
576 }
577}
578
579pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
581 match format {
582 OutputFormat::Json => json::print_trace_json(trace),
583 _ => human::print_dependency_trace_human(trace),
584 }
585}
586
587pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
589 match format {
590 OutputFormat::Json => json::print_trace_json(trace),
591 _ => human::print_clone_trace_human(trace, root),
592 }
593}
594
595pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
598 match format {
599 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
600 Ok(json) => eprintln!("{json}"),
601 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
602 },
603 _ => human::print_performance_human(timings),
604 }
605}
606
607pub fn print_health_performance(
610 timings: &crate::health_types::HealthTimings,
611 format: OutputFormat,
612) {
613 match format {
614 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
615 Ok(json) => eprintln!("{json}"),
616 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
617 },
618 _ => human::print_health_performance_human(timings),
619 }
620}
621
622#[allow(
625 unused_imports,
626 reason = "target-dependent: used in lib, unused in bin"
627)]
628pub use codeclimate::build_codeclimate;
629#[allow(
630 unused_imports,
631 reason = "target-dependent: used in lib, unused in bin"
632)]
633pub use codeclimate::build_duplication_codeclimate;
634#[allow(
635 unused_imports,
636 reason = "target-dependent: used in lib, unused in bin"
637)]
638pub use codeclimate::build_health_codeclimate;
639#[allow(
640 unused_imports,
641 reason = "target-dependent: used in lib, unused in bin"
642)]
643pub use compact::build_compact_lines;
644pub use json::HealthActionOptions;
645pub use json::build_baseline_deltas_json;
646#[allow(
647 unused_imports,
648 reason = "target-dependent: used in lib, unused in bin"
649)]
650pub use json::build_duplication_json;
651#[allow(
652 unused_imports,
653 reason = "target-dependent: used in lib, unused in bin"
654)]
655pub use json::build_grouped_duplication_json;
656#[allow(
657 unused_imports,
658 reason = "target-dependent: used in lib, unused in bin"
659)]
660pub use json::build_health_json;
661#[allow(
662 unused_imports,
663 reason = "target-dependent: used in lib, unused in bin"
664)]
665pub use json::build_json;
666#[allow(
667 unused_imports,
668 reason = "target-dependent: used in bin audit.rs, unused in lib"
669)]
670#[allow(
671 clippy::redundant_pub_crate,
672 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
673)]
674pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
675#[allow(
676 unused_imports,
677 reason = "target-dependent: used in bin audit.rs, unused in lib"
678)]
679#[allow(
680 clippy::redundant_pub_crate,
681 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
682)]
683pub(crate) use json::inject_dupes_actions;
684#[allow(
685 unused_imports,
686 reason = "target-dependent: used in bin audit.rs, unused in lib"
687)]
688#[allow(
689 clippy::redundant_pub_crate,
690 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
691)]
692pub(crate) use json::inject_health_actions;
693#[allow(
694 unused_imports,
695 reason = "target-dependent: used in lib, unused in bin"
696)]
697pub use markdown::build_duplication_markdown;
698#[allow(
699 unused_imports,
700 reason = "target-dependent: used in lib, unused in bin"
701)]
702pub use markdown::build_health_markdown;
703#[allow(
704 unused_imports,
705 reason = "target-dependent: used in lib, unused in bin"
706)]
707pub use markdown::build_markdown;
708#[allow(
709 unused_imports,
710 reason = "target-dependent: used in lib, unused in bin"
711)]
712pub use sarif::build_health_sarif;
713#[allow(
714 unused_imports,
715 reason = "target-dependent: used in lib, unused in bin"
716)]
717pub use sarif::build_sarif;
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use std::path::PathBuf;
723
724 #[test]
727 fn normalize_uri_forward_slashes_unchanged() {
728 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
729 }
730
731 #[test]
732 fn normalize_uri_backslashes_replaced() {
733 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
734 }
735
736 #[test]
737 fn normalize_uri_mixed_slashes() {
738 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
739 }
740
741 #[test]
742 fn normalize_uri_path_with_spaces() {
743 assert_eq!(
744 normalize_uri("src\\my folder\\file.ts"),
745 "src/my folder/file.ts"
746 );
747 }
748
749 #[test]
750 fn normalize_uri_empty_string() {
751 assert_eq!(normalize_uri(""), "");
752 }
753
754 #[test]
757 fn relative_path_strips_root_prefix() {
758 let root = Path::new("/project");
759 let path = Path::new("/project/src/utils.ts");
760 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
761 }
762
763 #[test]
764 fn relative_path_returns_full_path_when_no_prefix() {
765 let root = Path::new("/other");
766 let path = Path::new("/project/src/utils.ts");
767 assert_eq!(relative_path(path, root), path);
768 }
769
770 #[test]
771 fn relative_path_at_root_returns_empty_or_file() {
772 let root = Path::new("/project");
773 let path = Path::new("/project/file.ts");
774 assert_eq!(relative_path(path, root), Path::new("file.ts"));
775 }
776
777 #[test]
778 fn relative_path_deeply_nested() {
779 let root = Path::new("/project");
780 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
781 assert_eq!(
782 relative_path(path, root),
783 Path::new("packages/ui/src/components/Button.tsx")
784 );
785 }
786
787 #[test]
790 fn relative_uri_produces_forward_slash_path() {
791 let root = PathBuf::from("/project");
792 let path = root.join("src").join("utils.ts");
793 let uri = relative_uri(&path, &root);
794 assert_eq!(uri, "src/utils.ts");
795 }
796
797 #[test]
798 fn relative_uri_encodes_brackets() {
799 let root = PathBuf::from("/project");
800 let path = root.join("src/app/[...slug]/page.tsx");
801 let uri = relative_uri(&path, &root);
802 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
803 }
804
805 #[test]
806 fn relative_uri_encodes_nested_dynamic_routes() {
807 let root = PathBuf::from("/project");
808 let path = root.join("src/app/[slug]/[id]/page.tsx");
809 let uri = relative_uri(&path, &root);
810 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
811 }
812
813 #[test]
814 fn relative_uri_no_common_prefix_returns_full() {
815 let root = PathBuf::from("/other");
816 let path = PathBuf::from("/project/src/utils.ts");
817 let uri = relative_uri(&path, &root);
818 assert!(uri.contains("project"));
819 assert!(uri.contains("utils.ts"));
820 }
821
822 #[test]
825 fn severity_error_maps_to_level_error() {
826 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
827 }
828
829 #[test]
830 fn severity_warn_maps_to_level_warn() {
831 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
832 }
833
834 #[test]
835 fn severity_off_maps_to_level_info() {
836 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
837 }
838
839 #[test]
842 fn normalize_uri_single_bracket_pair() {
843 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
844 }
845
846 #[test]
847 fn normalize_uri_catch_all_route() {
848 assert_eq!(
849 normalize_uri("app/[...slug]/page.tsx"),
850 "app/%5B...slug%5D/page.tsx"
851 );
852 }
853
854 #[test]
855 fn normalize_uri_optional_catch_all_route() {
856 assert_eq!(
857 normalize_uri("app/[[...slug]]/page.tsx"),
858 "app/%5B%5B...slug%5D%5D/page.tsx"
859 );
860 }
861
862 #[test]
863 fn normalize_uri_multiple_dynamic_segments() {
864 assert_eq!(
865 normalize_uri("app/[lang]/posts/[id]"),
866 "app/%5Blang%5D/posts/%5Bid%5D"
867 );
868 }
869
870 #[test]
871 fn normalize_uri_no_special_chars() {
872 let plain = "src/components/Button.tsx";
873 assert_eq!(normalize_uri(plain), plain);
874 }
875
876 #[test]
877 fn normalize_uri_only_backslashes() {
878 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
879 }
880
881 #[test]
884 fn relative_path_identical_paths_returns_empty() {
885 let root = Path::new("/project");
886 assert_eq!(relative_path(root, root), Path::new(""));
887 }
888
889 #[test]
890 fn relative_path_partial_name_match_not_stripped() {
891 let root = Path::new("/project");
894 let path = Path::new("/project-two/src/a.ts");
895 assert_eq!(relative_path(path, root), path);
896 }
897
898 #[test]
901 fn relative_uri_combines_stripping_and_encoding() {
902 let root = PathBuf::from("/project");
903 let path = root.join("src/app/[slug]/page.tsx");
904 let uri = relative_uri(&path, &root);
905 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
907 assert!(!uri.starts_with('/'));
908 }
909
910 #[test]
911 fn relative_uri_at_root_file() {
912 let root = PathBuf::from("/project");
913 let path = root.join("index.ts");
914 assert_eq!(relative_uri(&path, &root), "index.ts");
915 }
916
917 #[test]
920 fn severity_to_level_is_const_evaluable() {
921 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
923 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
924 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
925 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
926 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
927 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
928 }
929
930 #[test]
933 fn level_is_copy() {
934 let level = severity_to_level(Severity::Error);
935 let copy = level;
936 assert!(matches!(level, Level::Error));
938 assert!(matches!(copy, Level::Error));
939 }
940
941 #[test]
944 fn elide_common_prefix_shared_dir() {
945 assert_eq!(
946 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
947 "B.tsx"
948 );
949 }
950
951 #[test]
952 fn elide_common_prefix_partial_shared() {
953 assert_eq!(
954 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
955 "utils/B.tsx"
956 );
957 }
958
959 #[test]
960 fn elide_common_prefix_no_shared() {
961 assert_eq!(
962 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
963 "pkg-b/src/B.tsx"
964 );
965 }
966
967 #[test]
968 fn elide_common_prefix_identical_files() {
969 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
971 }
972
973 #[test]
974 fn elide_common_prefix_no_dirs() {
975 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
976 }
977
978 #[test]
979 fn elide_common_prefix_deep_monorepo() {
980 assert_eq!(
981 elide_common_prefix(
982 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
983 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
984 ),
985 "SearchSelectItem.tsx"
986 );
987 }
988
989 #[test]
992 fn split_dir_filename_with_dir() {
993 let (dir, file) = split_dir_filename("src/utils/index.ts");
994 assert_eq!(dir, "src/utils/");
995 assert_eq!(file, "index.ts");
996 }
997
998 #[test]
999 fn split_dir_filename_no_dir() {
1000 let (dir, file) = split_dir_filename("file.ts");
1001 assert_eq!(dir, "");
1002 assert_eq!(file, "file.ts");
1003 }
1004
1005 #[test]
1006 fn split_dir_filename_deeply_nested() {
1007 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1008 assert_eq!(dir, "a/b/c/d/");
1009 assert_eq!(file, "e.ts");
1010 }
1011
1012 #[test]
1013 fn split_dir_filename_trailing_slash() {
1014 let (dir, file) = split_dir_filename("src/");
1015 assert_eq!(dir, "src/");
1016 assert_eq!(file, "");
1017 }
1018
1019 #[test]
1020 fn split_dir_filename_empty() {
1021 let (dir, file) = split_dir_filename("");
1022 assert_eq!(dir, "");
1023 assert_eq!(file, "");
1024 }
1025
1026 #[test]
1029 fn plural_zero_is_plural() {
1030 assert_eq!(plural(0), "s");
1031 }
1032
1033 #[test]
1034 fn plural_one_is_singular() {
1035 assert_eq!(plural(1), "");
1036 }
1037
1038 #[test]
1039 fn plural_two_is_plural() {
1040 assert_eq!(plural(2), "s");
1041 }
1042
1043 #[test]
1044 fn plural_large_number() {
1045 assert_eq!(plural(999), "s");
1046 }
1047
1048 #[test]
1051 fn elide_common_prefix_empty_base() {
1052 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1053 }
1054
1055 #[test]
1056 fn elide_common_prefix_empty_target() {
1057 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1058 }
1059
1060 #[test]
1061 fn elide_common_prefix_both_empty() {
1062 assert_eq!(elide_common_prefix("", ""), "");
1063 }
1064
1065 #[test]
1066 fn elide_common_prefix_same_file_different_extension() {
1067 assert_eq!(
1069 elide_common_prefix("src/utils.ts", "src/utils.js"),
1070 "utils.js"
1071 );
1072 }
1073
1074 #[test]
1075 fn elide_common_prefix_partial_filename_match_not_stripped() {
1076 assert_eq!(
1078 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1079 "AppUtils.tsx"
1080 );
1081 }
1082
1083 #[test]
1084 fn elide_common_prefix_identical_paths() {
1085 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1086 }
1087
1088 #[test]
1089 fn split_dir_filename_single_slash() {
1090 let (dir, file) = split_dir_filename("/file.ts");
1091 assert_eq!(dir, "/");
1092 assert_eq!(file, "file.ts");
1093 }
1094
1095 #[test]
1096 fn emit_json_returns_success_for_valid_value() {
1097 let value = serde_json::json!({"key": "value"});
1098 let code = emit_json(&value, "test");
1099 assert_eq!(code, ExitCode::SUCCESS);
1100 }
1101
1102 mod proptests {
1103 use super::*;
1104 use proptest::prelude::*;
1105
1106 proptest! {
1107 #[test]
1109 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1110 let (dir, file) = split_dir_filename(&path);
1111 let reconstructed = format!("{dir}{file}");
1112 prop_assert_eq!(
1113 reconstructed, path,
1114 "dir+file should reconstruct the original path"
1115 );
1116 }
1117
1118 #[test]
1120 fn plural_returns_empty_or_s(n: usize) {
1121 let result = plural(n);
1122 prop_assert!(
1123 result.is_empty() || result == "s",
1124 "plural should return \"\" or \"s\", got {:?}",
1125 result
1126 );
1127 }
1128
1129 #[test]
1131 fn plural_singular_only_for_one(n: usize) {
1132 let result = plural(n);
1133 if n == 1 {
1134 prop_assert_eq!(result, "", "plural(1) should be empty");
1135 } else {
1136 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1137 }
1138 }
1139
1140 #[test]
1142 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1143 let result = normalize_uri(&path);
1144 prop_assert!(
1145 !result.contains('\\'),
1146 "Result should not contain backslashes: {result}"
1147 );
1148 }
1149
1150 #[test]
1152 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1153 let result = normalize_uri(&path);
1154 prop_assert!(
1155 !result.contains('[') && !result.contains(']'),
1156 "Result should not contain raw brackets: {result}"
1157 );
1158 }
1159
1160 #[test]
1162 fn elide_common_prefix_returns_suffix_of_target(
1163 base in "[a-zA-Z0-9_./]{0,50}",
1164 target in "[a-zA-Z0-9_./]{0,50}",
1165 ) {
1166 let result = elide_common_prefix(&base, &target);
1167 prop_assert!(
1168 target.ends_with(result),
1169 "Result {:?} should be a suffix of target {:?}",
1170 result, target
1171 );
1172 }
1173
1174 #[test]
1176 fn relative_path_never_panics(
1177 root in "/[a-zA-Z0-9_/]{0,30}",
1178 suffix in "[a-zA-Z0-9_./]{0,30}",
1179 ) {
1180 let root_path = Path::new(&root);
1181 let full = PathBuf::from(format!("{root}/{suffix}"));
1182 let _ = relative_path(&full, root_path);
1183 }
1184 }
1185 }
1186}