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;
645#[allow(
646 clippy::redundant_pub_crate,
647 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
648)]
649pub(crate) use json::SCHEMA_VERSION;
650pub use json::build_baseline_deltas_json;
651#[allow(
652 unused_imports,
653 reason = "target-dependent: used in lib, unused in bin"
654)]
655pub use json::build_duplication_json;
656#[allow(
657 unused_imports,
658 reason = "target-dependent: used in lib, unused in bin"
659)]
660pub use json::build_grouped_duplication_json;
661#[allow(
662 unused_imports,
663 reason = "target-dependent: used in lib, unused in bin"
664)]
665pub use json::build_health_json;
666#[allow(
667 unused_imports,
668 reason = "target-dependent: used in lib, unused in bin"
669)]
670pub use json::build_json;
671#[allow(
672 unused_imports,
673 reason = "target-dependent: used in bin audit.rs, unused in lib"
674)]
675#[allow(
676 clippy::redundant_pub_crate,
677 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
678)]
679pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
680#[allow(
681 unused_imports,
682 reason = "target-dependent: used in bin audit.rs, unused in lib"
683)]
684#[allow(
685 clippy::redundant_pub_crate,
686 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
687)]
688pub(crate) use json::inject_dupes_actions;
689#[allow(
690 unused_imports,
691 reason = "target-dependent: used in bin audit.rs, unused in lib"
692)]
693#[allow(
694 clippy::redundant_pub_crate,
695 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
696)]
697pub(crate) use json::inject_health_actions;
698#[allow(
699 unused_imports,
700 reason = "target-dependent: used in lib, unused in bin"
701)]
702pub use markdown::build_duplication_markdown;
703#[allow(
704 unused_imports,
705 reason = "target-dependent: used in lib, unused in bin"
706)]
707pub use markdown::build_health_markdown;
708#[allow(
709 unused_imports,
710 reason = "target-dependent: used in lib, unused in bin"
711)]
712pub use markdown::build_markdown;
713#[allow(
714 unused_imports,
715 reason = "target-dependent: used in lib, unused in bin"
716)]
717pub use sarif::build_health_sarif;
718#[allow(
719 unused_imports,
720 reason = "target-dependent: used in lib, unused in bin"
721)]
722pub use sarif::build_sarif;
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use std::path::PathBuf;
728
729 #[test]
732 fn normalize_uri_forward_slashes_unchanged() {
733 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
734 }
735
736 #[test]
737 fn normalize_uri_backslashes_replaced() {
738 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
739 }
740
741 #[test]
742 fn normalize_uri_mixed_slashes() {
743 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
744 }
745
746 #[test]
747 fn normalize_uri_path_with_spaces() {
748 assert_eq!(
749 normalize_uri("src\\my folder\\file.ts"),
750 "src/my folder/file.ts"
751 );
752 }
753
754 #[test]
755 fn normalize_uri_empty_string() {
756 assert_eq!(normalize_uri(""), "");
757 }
758
759 #[test]
762 fn relative_path_strips_root_prefix() {
763 let root = Path::new("/project");
764 let path = Path::new("/project/src/utils.ts");
765 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
766 }
767
768 #[test]
769 fn relative_path_returns_full_path_when_no_prefix() {
770 let root = Path::new("/other");
771 let path = Path::new("/project/src/utils.ts");
772 assert_eq!(relative_path(path, root), path);
773 }
774
775 #[test]
776 fn relative_path_at_root_returns_empty_or_file() {
777 let root = Path::new("/project");
778 let path = Path::new("/project/file.ts");
779 assert_eq!(relative_path(path, root), Path::new("file.ts"));
780 }
781
782 #[test]
783 fn relative_path_deeply_nested() {
784 let root = Path::new("/project");
785 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
786 assert_eq!(
787 relative_path(path, root),
788 Path::new("packages/ui/src/components/Button.tsx")
789 );
790 }
791
792 #[test]
795 fn relative_uri_produces_forward_slash_path() {
796 let root = PathBuf::from("/project");
797 let path = root.join("src").join("utils.ts");
798 let uri = relative_uri(&path, &root);
799 assert_eq!(uri, "src/utils.ts");
800 }
801
802 #[test]
803 fn relative_uri_encodes_brackets() {
804 let root = PathBuf::from("/project");
805 let path = root.join("src/app/[...slug]/page.tsx");
806 let uri = relative_uri(&path, &root);
807 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
808 }
809
810 #[test]
811 fn relative_uri_encodes_nested_dynamic_routes() {
812 let root = PathBuf::from("/project");
813 let path = root.join("src/app/[slug]/[id]/page.tsx");
814 let uri = relative_uri(&path, &root);
815 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
816 }
817
818 #[test]
819 fn relative_uri_no_common_prefix_returns_full() {
820 let root = PathBuf::from("/other");
821 let path = PathBuf::from("/project/src/utils.ts");
822 let uri = relative_uri(&path, &root);
823 assert!(uri.contains("project"));
824 assert!(uri.contains("utils.ts"));
825 }
826
827 #[test]
830 fn severity_error_maps_to_level_error() {
831 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
832 }
833
834 #[test]
835 fn severity_warn_maps_to_level_warn() {
836 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
837 }
838
839 #[test]
840 fn severity_off_maps_to_level_info() {
841 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
842 }
843
844 #[test]
847 fn normalize_uri_single_bracket_pair() {
848 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
849 }
850
851 #[test]
852 fn normalize_uri_catch_all_route() {
853 assert_eq!(
854 normalize_uri("app/[...slug]/page.tsx"),
855 "app/%5B...slug%5D/page.tsx"
856 );
857 }
858
859 #[test]
860 fn normalize_uri_optional_catch_all_route() {
861 assert_eq!(
862 normalize_uri("app/[[...slug]]/page.tsx"),
863 "app/%5B%5B...slug%5D%5D/page.tsx"
864 );
865 }
866
867 #[test]
868 fn normalize_uri_multiple_dynamic_segments() {
869 assert_eq!(
870 normalize_uri("app/[lang]/posts/[id]"),
871 "app/%5Blang%5D/posts/%5Bid%5D"
872 );
873 }
874
875 #[test]
876 fn normalize_uri_no_special_chars() {
877 let plain = "src/components/Button.tsx";
878 assert_eq!(normalize_uri(plain), plain);
879 }
880
881 #[test]
882 fn normalize_uri_only_backslashes() {
883 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
884 }
885
886 #[test]
889 fn relative_path_identical_paths_returns_empty() {
890 let root = Path::new("/project");
891 assert_eq!(relative_path(root, root), Path::new(""));
892 }
893
894 #[test]
895 fn relative_path_partial_name_match_not_stripped() {
896 let root = Path::new("/project");
899 let path = Path::new("/project-two/src/a.ts");
900 assert_eq!(relative_path(path, root), path);
901 }
902
903 #[test]
906 fn relative_uri_combines_stripping_and_encoding() {
907 let root = PathBuf::from("/project");
908 let path = root.join("src/app/[slug]/page.tsx");
909 let uri = relative_uri(&path, &root);
910 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
912 assert!(!uri.starts_with('/'));
913 }
914
915 #[test]
916 fn relative_uri_at_root_file() {
917 let root = PathBuf::from("/project");
918 let path = root.join("index.ts");
919 assert_eq!(relative_uri(&path, &root), "index.ts");
920 }
921
922 #[test]
925 fn severity_to_level_is_const_evaluable() {
926 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
928 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
929 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
930 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
931 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
932 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
933 }
934
935 #[test]
938 fn level_is_copy() {
939 let level = severity_to_level(Severity::Error);
940 let copy = level;
941 assert!(matches!(level, Level::Error));
943 assert!(matches!(copy, Level::Error));
944 }
945
946 #[test]
949 fn elide_common_prefix_shared_dir() {
950 assert_eq!(
951 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
952 "B.tsx"
953 );
954 }
955
956 #[test]
957 fn elide_common_prefix_partial_shared() {
958 assert_eq!(
959 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
960 "utils/B.tsx"
961 );
962 }
963
964 #[test]
965 fn elide_common_prefix_no_shared() {
966 assert_eq!(
967 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
968 "pkg-b/src/B.tsx"
969 );
970 }
971
972 #[test]
973 fn elide_common_prefix_identical_files() {
974 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
976 }
977
978 #[test]
979 fn elide_common_prefix_no_dirs() {
980 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
981 }
982
983 #[test]
984 fn elide_common_prefix_deep_monorepo() {
985 assert_eq!(
986 elide_common_prefix(
987 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
988 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
989 ),
990 "SearchSelectItem.tsx"
991 );
992 }
993
994 #[test]
997 fn split_dir_filename_with_dir() {
998 let (dir, file) = split_dir_filename("src/utils/index.ts");
999 assert_eq!(dir, "src/utils/");
1000 assert_eq!(file, "index.ts");
1001 }
1002
1003 #[test]
1004 fn split_dir_filename_no_dir() {
1005 let (dir, file) = split_dir_filename("file.ts");
1006 assert_eq!(dir, "");
1007 assert_eq!(file, "file.ts");
1008 }
1009
1010 #[test]
1011 fn split_dir_filename_deeply_nested() {
1012 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1013 assert_eq!(dir, "a/b/c/d/");
1014 assert_eq!(file, "e.ts");
1015 }
1016
1017 #[test]
1018 fn split_dir_filename_trailing_slash() {
1019 let (dir, file) = split_dir_filename("src/");
1020 assert_eq!(dir, "src/");
1021 assert_eq!(file, "");
1022 }
1023
1024 #[test]
1025 fn split_dir_filename_empty() {
1026 let (dir, file) = split_dir_filename("");
1027 assert_eq!(dir, "");
1028 assert_eq!(file, "");
1029 }
1030
1031 #[test]
1034 fn plural_zero_is_plural() {
1035 assert_eq!(plural(0), "s");
1036 }
1037
1038 #[test]
1039 fn plural_one_is_singular() {
1040 assert_eq!(plural(1), "");
1041 }
1042
1043 #[test]
1044 fn plural_two_is_plural() {
1045 assert_eq!(plural(2), "s");
1046 }
1047
1048 #[test]
1049 fn plural_large_number() {
1050 assert_eq!(plural(999), "s");
1051 }
1052
1053 #[test]
1056 fn elide_common_prefix_empty_base() {
1057 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1058 }
1059
1060 #[test]
1061 fn elide_common_prefix_empty_target() {
1062 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1063 }
1064
1065 #[test]
1066 fn elide_common_prefix_both_empty() {
1067 assert_eq!(elide_common_prefix("", ""), "");
1068 }
1069
1070 #[test]
1071 fn elide_common_prefix_same_file_different_extension() {
1072 assert_eq!(
1074 elide_common_prefix("src/utils.ts", "src/utils.js"),
1075 "utils.js"
1076 );
1077 }
1078
1079 #[test]
1080 fn elide_common_prefix_partial_filename_match_not_stripped() {
1081 assert_eq!(
1083 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1084 "AppUtils.tsx"
1085 );
1086 }
1087
1088 #[test]
1089 fn elide_common_prefix_identical_paths() {
1090 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1091 }
1092
1093 #[test]
1094 fn split_dir_filename_single_slash() {
1095 let (dir, file) = split_dir_filename("/file.ts");
1096 assert_eq!(dir, "/");
1097 assert_eq!(file, "file.ts");
1098 }
1099
1100 #[test]
1101 fn emit_json_returns_success_for_valid_value() {
1102 let value = serde_json::json!({"key": "value"});
1103 let code = emit_json(&value, "test");
1104 assert_eq!(code, ExitCode::SUCCESS);
1105 }
1106
1107 mod proptests {
1108 use super::*;
1109 use proptest::prelude::*;
1110
1111 proptest! {
1112 #[test]
1114 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1115 let (dir, file) = split_dir_filename(&path);
1116 let reconstructed = format!("{dir}{file}");
1117 prop_assert_eq!(
1118 reconstructed, path,
1119 "dir+file should reconstruct the original path"
1120 );
1121 }
1122
1123 #[test]
1125 fn plural_returns_empty_or_s(n: usize) {
1126 let result = plural(n);
1127 prop_assert!(
1128 result.is_empty() || result == "s",
1129 "plural should return \"\" or \"s\", got {:?}",
1130 result
1131 );
1132 }
1133
1134 #[test]
1136 fn plural_singular_only_for_one(n: usize) {
1137 let result = plural(n);
1138 if n == 1 {
1139 prop_assert_eq!(result, "", "plural(1) should be empty");
1140 } else {
1141 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1142 }
1143 }
1144
1145 #[test]
1147 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1148 let result = normalize_uri(&path);
1149 prop_assert!(
1150 !result.contains('\\'),
1151 "Result should not contain backslashes: {result}"
1152 );
1153 }
1154
1155 #[test]
1157 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1158 let result = normalize_uri(&path);
1159 prop_assert!(
1160 !result.contains('[') && !result.contains(']'),
1161 "Result should not contain raw brackets: {result}"
1162 );
1163 }
1164
1165 #[test]
1167 fn elide_common_prefix_returns_suffix_of_target(
1168 base in "[a-zA-Z0-9_./]{0,50}",
1169 target in "[a-zA-Z0-9_./]{0,50}",
1170 ) {
1171 let result = elide_common_prefix(&base, &target);
1172 prop_assert!(
1173 target.ends_with(result),
1174 "Result {:?} should be a suffix of target {:?}",
1175 result, target
1176 );
1177 }
1178
1179 #[test]
1181 fn relative_path_never_panics(
1182 root in "/[a-zA-Z0-9_/]{0,30}",
1183 suffix in "[a-zA-Z0-9_./]{0,30}",
1184 ) {
1185 let root_path = Path::new(&root);
1186 let full = PathBuf::from(format!("{root}/{suffix}"));
1187 let _ = relative_path(&full, root_path);
1188 }
1189 }
1190 }
1191}