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}
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 ctx.config_fixable,
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 issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
210 let value = codeclimate::issues_to_value(&issues);
211 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
212 }
213 OutputFormat::PrCommentGitlab => {
214 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
215 let value = codeclimate::issues_to_value(&issues);
216 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
217 }
218 OutputFormat::ReviewGithub => {
219 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
220 let value = codeclimate::issues_to_value(&issues);
221 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
222 }
223 OutputFormat::ReviewGitlab => {
224 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
225 let value = codeclimate::issues_to_value(&issues);
226 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
227 }
228 OutputFormat::Badge => {
229 eprintln!("Error: badge format is only supported for the health command");
230 ExitCode::from(2)
231 }
232 }
233}
234
235#[must_use]
237fn print_grouped_results(
238 groups: &[grouping::ResultGroup],
239 original: &AnalysisResults,
240 ctx: &ReportContext<'_>,
241 output: OutputFormat,
242 resolver: &OwnershipResolver,
243) -> ExitCode {
244 match output {
245 OutputFormat::Human => {
246 human::print_grouped_human(
247 groups,
248 ctx.root,
249 ctx.rules,
250 ctx.elapsed,
251 ctx.quiet,
252 Some(resolver),
253 );
254 ExitCode::SUCCESS
255 }
256 OutputFormat::Json => json::print_grouped_json(
257 groups,
258 original,
259 ctx.root,
260 ctx.elapsed,
261 ctx.explain,
262 resolver,
263 ctx.config_fixable,
264 ),
265 OutputFormat::Compact => {
266 compact::print_grouped_compact(groups, ctx.root);
267 ExitCode::SUCCESS
268 }
269 OutputFormat::Markdown => {
270 markdown::print_grouped_markdown(groups, ctx.root);
271 ExitCode::SUCCESS
272 }
273 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
274 OutputFormat::CodeClimate => {
275 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
276 }
277 OutputFormat::PrCommentGithub => {
278 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
279 let value = codeclimate::issues_to_value(&issues);
280 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
281 }
282 OutputFormat::PrCommentGitlab => {
283 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
284 let value = codeclimate::issues_to_value(&issues);
285 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
286 }
287 OutputFormat::ReviewGithub => {
288 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
289 let value = codeclimate::issues_to_value(&issues);
290 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
291 }
292 OutputFormat::ReviewGitlab => {
293 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
294 let value = codeclimate::issues_to_value(&issues);
295 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
296 }
297 OutputFormat::Badge => {
298 eprintln!("Error: badge format is only supported for the health command");
299 ExitCode::from(2)
300 }
301 }
302}
303
304#[must_use]
308pub fn print_duplication_report(
309 report: &DuplicationReport,
310 ctx: &ReportContext<'_>,
311 output: OutputFormat,
312) -> ExitCode {
313 if let Some(ref resolver) = ctx.group_by {
317 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
318 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
319 }
320
321 match output {
322 OutputFormat::Human => {
323 if ctx.summary {
324 human::dupes::print_duplication_summary(report, ctx.elapsed, ctx.quiet);
325 } else {
326 human::print_duplication_human(
327 report,
328 ctx.root,
329 ctx.elapsed,
330 ctx.quiet,
331 ctx.show_explain_tip,
332 );
333 }
334 ExitCode::SUCCESS
335 }
336 OutputFormat::Json => {
337 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
338 }
339 OutputFormat::Compact => {
340 compact::print_duplication_compact(report, ctx.root);
341 ExitCode::SUCCESS
342 }
343 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
344 OutputFormat::Markdown => {
345 markdown::print_duplication_markdown(report, ctx.root);
346 ExitCode::SUCCESS
347 }
348 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
349 OutputFormat::PrCommentGithub => {
350 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
351 let value = codeclimate::issues_to_value(&issues);
352 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
353 }
354 OutputFormat::PrCommentGitlab => {
355 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
356 let value = codeclimate::issues_to_value(&issues);
357 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
358 }
359 OutputFormat::ReviewGithub => {
360 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
361 let value = codeclimate::issues_to_value(&issues);
362 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
363 }
364 OutputFormat::ReviewGitlab => {
365 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
366 let value = codeclimate::issues_to_value(&issues);
367 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
368 }
369 OutputFormat::Badge => {
370 eprintln!("Error: badge format is only supported for the health command");
371 ExitCode::from(2)
372 }
373 }
374}
375
376#[must_use]
378fn print_grouped_duplication_report(
379 report: &DuplicationReport,
380 grouping: &dupes_grouping::DuplicationGrouping,
381 ctx: &ReportContext<'_>,
382 output: OutputFormat,
383 resolver: &OwnershipResolver,
384) -> ExitCode {
385 match output {
386 OutputFormat::Human => {
387 human::print_grouped_duplication_human(
388 report,
389 grouping,
390 ctx.root,
391 ctx.elapsed,
392 ctx.quiet,
393 );
394 ExitCode::SUCCESS
395 }
396 OutputFormat::Json => json::print_grouped_duplication_json(
397 report,
398 grouping,
399 ctx.root,
400 ctx.elapsed,
401 ctx.explain,
402 ),
403 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
404 OutputFormat::CodeClimate => {
405 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
406 }
407 OutputFormat::PrCommentGithub => {
408 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
409 let value = codeclimate::issues_to_value(&issues);
410 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
411 }
412 OutputFormat::PrCommentGitlab => {
413 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
414 let value = codeclimate::issues_to_value(&issues);
415 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
416 }
417 OutputFormat::ReviewGithub => {
418 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
419 let value = codeclimate::issues_to_value(&issues);
420 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
421 }
422 OutputFormat::ReviewGitlab => {
423 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
424 let value = codeclimate::issues_to_value(&issues);
425 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
426 }
427 OutputFormat::Compact => {
428 compact::print_duplication_compact(report, ctx.root);
429 warn_dupes_grouping_unsupported(grouping, "compact");
430 ExitCode::SUCCESS
431 }
432 OutputFormat::Markdown => {
433 markdown::print_duplication_markdown(report, ctx.root);
434 warn_dupes_grouping_unsupported(grouping, "markdown");
435 ExitCode::SUCCESS
436 }
437 OutputFormat::Badge => {
438 eprintln!("Error: badge format is only supported for the health command");
439 ExitCode::from(2)
440 }
441 }
442}
443
444fn warn_dupes_grouping_unsupported(grouping: &dupes_grouping::DuplicationGrouping, format: &str) {
445 eprintln!(
446 "note: --group-by {} is not supported for {format} duplication output, falling back to \
447 ungrouped output (use --format json for the full grouped envelope)",
448 grouping.mode
449 );
450}
451
452#[must_use]
471pub fn print_health_report(
472 report: &crate::health_types::HealthReport,
473 grouping: Option<&crate::health_types::HealthGrouping>,
474 group_resolver: Option<&grouping::OwnershipResolver>,
475 ctx: &ReportContext<'_>,
476 output: OutputFormat,
477) -> ExitCode {
478 match output {
479 OutputFormat::Human => {
480 if ctx.summary {
481 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet);
482 } else {
483 human::print_health_human(
484 report,
485 ctx.root,
486 ctx.elapsed,
487 ctx.quiet,
488 ctx.show_explain_tip,
489 );
490 if let Some(grouping) = grouping {
491 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
492 }
493 }
494 ExitCode::SUCCESS
495 }
496 OutputFormat::Compact => {
497 compact::print_health_compact(report, ctx.root);
498 warn_grouping_unsupported(grouping, "compact");
499 ExitCode::SUCCESS
500 }
501 OutputFormat::Markdown => {
502 markdown::print_health_markdown(report, ctx.root);
503 warn_grouping_unsupported(grouping, "markdown");
504 ExitCode::SUCCESS
505 }
506 OutputFormat::Sarif => match group_resolver {
507 Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
508 None => sarif::print_health_sarif(report, ctx.root),
509 },
510 OutputFormat::Json => match grouping {
511 Some(grouping) => json::print_grouped_health_json(
512 report,
513 grouping,
514 ctx.root,
515 ctx.elapsed,
516 ctx.explain,
517 ),
518 None => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
519 },
520 OutputFormat::CodeClimate => match group_resolver {
521 Some(resolver) => {
522 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
523 }
524 None => codeclimate::print_health_codeclimate(report, ctx.root),
525 },
526 OutputFormat::PrCommentGithub => {
527 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
528 let value = codeclimate::issues_to_value(&issues);
529 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Github, &value)
530 }
531 OutputFormat::PrCommentGitlab => {
532 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
533 let value = codeclimate::issues_to_value(&issues);
534 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Gitlab, &value)
535 }
536 OutputFormat::ReviewGithub => {
537 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
538 let value = codeclimate::issues_to_value(&issues);
539 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Github, &value)
540 }
541 OutputFormat::ReviewGitlab => {
542 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
543 let value = codeclimate::issues_to_value(&issues);
544 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Gitlab, &value)
545 }
546 OutputFormat::Badge => {
547 warn_grouping_unsupported(grouping, "badge");
548 badge::print_health_badge(report)
549 }
550 }
551}
552
553fn warn_grouping_unsupported(grouping: Option<&crate::health_types::HealthGrouping>, format: &str) {
554 if let Some(g) = grouping {
555 eprintln!(
556 "note: --group-by {} is not supported for {format} output, falling back to \
557 ungrouped output (use --format json for the full grouped envelope)",
558 g.mode
559 );
560 }
561}
562
563pub fn print_cross_reference_findings(
567 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
568 root: &Path,
569 quiet: bool,
570 output: OutputFormat,
571) {
572 human::print_cross_reference_findings(cross_ref, root, quiet, output);
573}
574
575pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
579 match format {
580 OutputFormat::Json => json::print_trace_json(trace),
581 _ => human::print_export_trace_human(trace),
582 }
583}
584
585pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
587 match format {
588 OutputFormat::Json => json::print_trace_json(trace),
589 _ => human::print_file_trace_human(trace),
590 }
591}
592
593pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
595 match format {
596 OutputFormat::Json => json::print_trace_json(trace),
597 _ => human::print_dependency_trace_human(trace),
598 }
599}
600
601pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
603 match format {
604 OutputFormat::Json => json::print_trace_json(trace),
605 _ => human::print_clone_trace_human(trace, root),
606 }
607}
608
609pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
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_performance_human(timings),
618 }
619}
620
621pub fn print_health_performance(
624 timings: &crate::health_types::HealthTimings,
625 format: OutputFormat,
626) {
627 match format {
628 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
629 Ok(json) => eprintln!("{json}"),
630 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
631 },
632 _ => human::print_health_performance_human(timings),
633 }
634}
635
636#[allow(
639 unused_imports,
640 reason = "target-dependent: used in lib, unused in bin"
641)]
642pub use codeclimate::build_codeclimate;
643#[allow(
644 unused_imports,
645 reason = "target-dependent: used in lib, unused in bin"
646)]
647pub use codeclimate::build_duplication_codeclimate;
648#[allow(
649 unused_imports,
650 reason = "target-dependent: used in lib, unused in bin"
651)]
652pub use codeclimate::build_health_codeclimate;
653#[allow(
654 unused_imports,
655 reason = "target-dependent: used in lib, unused in bin"
656)]
657pub use codeclimate::issues_to_value as codeclimate_issues_to_value;
658#[allow(
659 unused_imports,
660 reason = "target-dependent: used in lib, unused in bin"
661)]
662pub use compact::build_compact_lines;
663#[allow(
664 clippy::redundant_pub_crate,
665 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
666)]
667pub(crate) use json::SCHEMA_VERSION;
668pub use json::build_baseline_deltas_json;
669#[allow(
670 unused_imports,
671 reason = "target-dependent: used in lib, unused in bin"
672)]
673pub use json::build_duplication_json;
674#[allow(
675 unused_imports,
676 reason = "target-dependent: used in lib, unused in bin"
677)]
678pub use json::build_grouped_duplication_json;
679#[allow(
680 unused_imports,
681 reason = "target-dependent: used in lib, unused in bin"
682)]
683pub use json::build_health_json;
684#[allow(
685 unused_imports,
686 reason = "target-dependent: used in bin audit.rs, unused in lib"
687)]
688#[allow(
689 clippy::redundant_pub_crate,
690 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
691)]
692pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
693#[allow(
694 unused_imports,
695 reason = "target-dependent: used in lib, unused in bin"
696)]
697pub use json::{build_json, build_json_with_config_fixable};
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}