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