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