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;
30pub use human::health::{render_health_score, render_health_trend};
33
34pub struct ReportContext<'a> {
39 pub root: &'a Path,
40 pub rules: &'a RulesConfig,
41 pub elapsed: Duration,
42 pub quiet: bool,
43 pub explain: bool,
44 pub group_by: Option<OwnershipResolver>,
46 pub top: Option<usize>,
48 pub summary: bool,
50 pub show_explain_tip: bool,
52 pub baseline_matched: Option<(usize, usize)>,
54 pub config_fixable: bool,
59 pub skip_score_and_trend: bool,
64}
65
66#[must_use]
68pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
69 path.strip_prefix(root).unwrap_or(path)
70}
71
72#[must_use]
82pub fn format_display_path(path: &Path, root: &Path) -> String {
83 relative_path(path, root)
84 .display()
85 .to_string()
86 .replace('\\', "/")
87}
88
89#[must_use]
92pub fn split_dir_filename(path: &str) -> (&str, &str) {
93 path.rfind('/')
94 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
95}
96
97#[must_use]
99pub const fn plural(n: usize) -> &'static str {
100 if n == 1 { "" } else { "s" }
101}
102
103#[must_use]
108pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
109 match serde_json::to_string_pretty(value) {
110 Ok(json) => {
111 println!("{json}");
112 ExitCode::SUCCESS
113 }
114 Err(e) => {
115 eprintln!("Error: failed to serialize {kind} output: {e}");
116 ExitCode::from(2)
117 }
118 }
119}
120
121#[must_use]
127pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
128 let mut last_sep = 0;
129 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
130 if a != b {
131 break;
132 }
133 if a == b'/' {
134 last_sep = i + 1;
135 }
136 }
137 if last_sep > 0 && last_sep <= target.len() {
138 &target[last_sep..]
139 } else {
140 target
141 }
142}
143
144fn relative_uri(path: &Path, root: &Path) -> String {
146 normalize_uri(&relative_path(path, root).display().to_string())
147}
148
149#[must_use]
154pub fn normalize_uri(path_str: &str) -> String {
155 path_str
156 .replace('\\', "/")
157 .replace('[', "%5B")
158 .replace(']', "%5D")
159}
160
161#[derive(Clone, Copy, Debug)]
163pub enum Level {
164 Warn,
165 Info,
166 Error,
167}
168
169#[must_use]
170pub const fn severity_to_level(s: Severity) -> Level {
171 match s {
172 Severity::Error => Level::Error,
173 Severity::Warn => Level::Warn,
174 Severity::Off => Level::Info,
176 }
177}
178
179#[must_use]
185pub fn print_results(
186 results: &AnalysisResults,
187 ctx: &ReportContext<'_>,
188 output: OutputFormat,
189 regression: Option<&crate::regression::RegressionOutcome>,
190) -> ExitCode {
191 if let Some(ref resolver) = ctx.group_by {
193 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
194 return print_grouped_results(&groups, results, ctx, output, resolver);
195 }
196
197 match output {
198 OutputFormat::Human => {
199 if ctx.summary {
200 human::check::print_check_summary(results, ctx.rules, ctx.elapsed, ctx.quiet);
201 } else {
202 human::print_human(
203 results,
204 ctx.root,
205 ctx.rules,
206 ctx.elapsed,
207 ctx.quiet,
208 ctx.top,
209 ctx.show_explain_tip,
210 ctx.explain,
211 );
212 }
213 ExitCode::SUCCESS
214 }
215 OutputFormat::Json => json::print_json(
216 results,
217 ctx.root,
218 ctx.elapsed,
219 ctx.explain,
220 regression,
221 ctx.baseline_matched,
222 ctx.config_fixable,
223 ),
224 OutputFormat::Compact => {
225 compact::print_compact(results, ctx.root);
226 ExitCode::SUCCESS
227 }
228 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
229 OutputFormat::Markdown => {
230 markdown::print_markdown(results, ctx.root);
231 ExitCode::SUCCESS
232 }
233 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
234 OutputFormat::PrCommentGithub => {
235 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
236 let value = codeclimate::issues_to_value(&issues);
237 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
238 }
239 OutputFormat::PrCommentGitlab => {
240 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
241 let value = codeclimate::issues_to_value(&issues);
242 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
243 }
244 OutputFormat::ReviewGithub => {
245 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
246 let value = codeclimate::issues_to_value(&issues);
247 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
248 }
249 OutputFormat::ReviewGitlab => {
250 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
251 let value = codeclimate::issues_to_value(&issues);
252 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
253 }
254 OutputFormat::Badge => {
255 eprintln!("Error: badge format is only supported for the health command");
256 ExitCode::from(2)
257 }
258 }
259}
260
261#[must_use]
263fn print_grouped_results(
264 groups: &[grouping::ResultGroup],
265 original: &AnalysisResults,
266 ctx: &ReportContext<'_>,
267 output: OutputFormat,
268 resolver: &OwnershipResolver,
269) -> ExitCode {
270 match output {
271 OutputFormat::Human => {
272 human::print_grouped_human(
273 groups,
274 ctx.root,
275 ctx.rules,
276 ctx.elapsed,
277 ctx.quiet,
278 Some(resolver),
279 ctx.explain,
280 );
281 ExitCode::SUCCESS
282 }
283 OutputFormat::Json => json::print_grouped_json(
284 groups,
285 original,
286 ctx.root,
287 ctx.elapsed,
288 ctx.explain,
289 resolver,
290 ctx.config_fixable,
291 ),
292 OutputFormat::Compact => {
293 compact::print_grouped_compact(groups, ctx.root);
294 ExitCode::SUCCESS
295 }
296 OutputFormat::Markdown => {
297 markdown::print_grouped_markdown(groups, ctx.root);
298 ExitCode::SUCCESS
299 }
300 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
301 OutputFormat::CodeClimate => {
302 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
303 }
304 OutputFormat::PrCommentGithub => {
305 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
306 let value = codeclimate::issues_to_value(&issues);
307 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
308 }
309 OutputFormat::PrCommentGitlab => {
310 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
311 let value = codeclimate::issues_to_value(&issues);
312 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
313 }
314 OutputFormat::ReviewGithub => {
315 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
316 let value = codeclimate::issues_to_value(&issues);
317 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
318 }
319 OutputFormat::ReviewGitlab => {
320 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
321 let value = codeclimate::issues_to_value(&issues);
322 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
323 }
324 OutputFormat::Badge => {
325 eprintln!("Error: badge format is only supported for the health command");
326 ExitCode::from(2)
327 }
328 }
329}
330
331#[must_use]
335pub fn print_duplication_report(
336 report: &DuplicationReport,
337 ctx: &ReportContext<'_>,
338 output: OutputFormat,
339) -> ExitCode {
340 if let Some(ref resolver) = ctx.group_by {
344 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
345 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
346 }
347
348 match output {
349 OutputFormat::Human => {
350 if ctx.summary {
351 human::dupes::print_duplication_summary(report, ctx.elapsed, ctx.quiet);
352 } else {
353 human::print_duplication_human(
354 report,
355 ctx.root,
356 ctx.elapsed,
357 ctx.quiet,
358 ctx.show_explain_tip,
359 ctx.explain,
360 );
361 }
362 ExitCode::SUCCESS
363 }
364 OutputFormat::Json => {
365 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
366 }
367 OutputFormat::Compact => {
368 compact::print_duplication_compact(report, ctx.root);
369 ExitCode::SUCCESS
370 }
371 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
372 OutputFormat::Markdown => {
373 markdown::print_duplication_markdown(report, ctx.root);
374 ExitCode::SUCCESS
375 }
376 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
377 OutputFormat::PrCommentGithub => {
378 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
379 let value = codeclimate::issues_to_value(&issues);
380 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
381 }
382 OutputFormat::PrCommentGitlab => {
383 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
384 let value = codeclimate::issues_to_value(&issues);
385 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
386 }
387 OutputFormat::ReviewGithub => {
388 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
389 let value = codeclimate::issues_to_value(&issues);
390 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
391 }
392 OutputFormat::ReviewGitlab => {
393 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
394 let value = codeclimate::issues_to_value(&issues);
395 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
396 }
397 OutputFormat::Badge => {
398 eprintln!("Error: badge format is only supported for the health command");
399 ExitCode::from(2)
400 }
401 }
402}
403
404#[must_use]
406fn print_grouped_duplication_report(
407 report: &DuplicationReport,
408 grouping: &dupes_grouping::DuplicationGrouping,
409 ctx: &ReportContext<'_>,
410 output: OutputFormat,
411 resolver: &OwnershipResolver,
412) -> ExitCode {
413 match output {
414 OutputFormat::Human => {
415 human::print_grouped_duplication_human(
416 report,
417 grouping,
418 ctx.root,
419 ctx.elapsed,
420 ctx.quiet,
421 );
422 ExitCode::SUCCESS
423 }
424 OutputFormat::Json => json::print_grouped_duplication_json(
425 report,
426 grouping,
427 ctx.root,
428 ctx.elapsed,
429 ctx.explain,
430 ),
431 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
432 OutputFormat::CodeClimate => {
433 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
434 }
435 OutputFormat::PrCommentGithub => {
436 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
437 let value = codeclimate::issues_to_value(&issues);
438 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
439 }
440 OutputFormat::PrCommentGitlab => {
441 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
442 let value = codeclimate::issues_to_value(&issues);
443 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
444 }
445 OutputFormat::ReviewGithub => {
446 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
447 let value = codeclimate::issues_to_value(&issues);
448 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
449 }
450 OutputFormat::ReviewGitlab => {
451 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
452 let value = codeclimate::issues_to_value(&issues);
453 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
454 }
455 OutputFormat::Compact => {
456 compact::print_duplication_compact(report, ctx.root);
457 warn_dupes_grouping_unsupported(grouping, "compact");
458 ExitCode::SUCCESS
459 }
460 OutputFormat::Markdown => {
461 markdown::print_duplication_markdown(report, ctx.root);
462 warn_dupes_grouping_unsupported(grouping, "markdown");
463 ExitCode::SUCCESS
464 }
465 OutputFormat::Badge => {
466 eprintln!("Error: badge format is only supported for the health command");
467 ExitCode::from(2)
468 }
469 }
470}
471
472fn warn_dupes_grouping_unsupported(grouping: &dupes_grouping::DuplicationGrouping, format: &str) {
473 eprintln!(
474 "note: --group-by {} is not supported for {format} duplication output, falling back to \
475 ungrouped output (use --format json for the full grouped envelope)",
476 grouping.mode
477 );
478}
479
480#[must_use]
499pub fn print_health_report(
500 report: &crate::health_types::HealthReport,
501 grouping: Option<&crate::health_types::HealthGrouping>,
502 group_resolver: Option<&grouping::OwnershipResolver>,
503 ctx: &ReportContext<'_>,
504 output: OutputFormat,
505) -> ExitCode {
506 match output {
507 OutputFormat::Human => {
508 if ctx.summary {
509 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet);
510 } else {
511 human::print_health_human(
512 report,
513 ctx.root,
514 ctx.elapsed,
515 ctx.quiet,
516 ctx.show_explain_tip,
517 ctx.explain,
518 ctx.skip_score_and_trend,
519 );
520 if let Some(grouping) = grouping {
521 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
522 }
523 }
524 ExitCode::SUCCESS
525 }
526 OutputFormat::Compact => {
527 compact::print_health_compact(report, ctx.root);
528 warn_grouping_unsupported(grouping, "compact");
529 ExitCode::SUCCESS
530 }
531 OutputFormat::Markdown => {
532 markdown::print_health_markdown(report, ctx.root);
533 warn_grouping_unsupported(grouping, "markdown");
534 ExitCode::SUCCESS
535 }
536 OutputFormat::Sarif => match group_resolver {
537 Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
538 None => sarif::print_health_sarif(report, ctx.root),
539 },
540 OutputFormat::Json => match grouping {
541 Some(grouping) => json::print_grouped_health_json(
542 report,
543 grouping,
544 ctx.root,
545 ctx.elapsed,
546 ctx.explain,
547 ),
548 None => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
549 },
550 OutputFormat::CodeClimate => match group_resolver {
551 Some(resolver) => {
552 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
553 }
554 None => codeclimate::print_health_codeclimate(report, ctx.root),
555 },
556 OutputFormat::PrCommentGithub => {
557 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
558 let value = codeclimate::issues_to_value(&issues);
559 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Github, &value)
560 }
561 OutputFormat::PrCommentGitlab => {
562 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
563 let value = codeclimate::issues_to_value(&issues);
564 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Gitlab, &value)
565 }
566 OutputFormat::ReviewGithub => {
567 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
568 let value = codeclimate::issues_to_value(&issues);
569 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Github, &value)
570 }
571 OutputFormat::ReviewGitlab => {
572 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
573 let value = codeclimate::issues_to_value(&issues);
574 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Gitlab, &value)
575 }
576 OutputFormat::Badge => {
577 warn_grouping_unsupported(grouping, "badge");
578 badge::print_health_badge(report)
579 }
580 }
581}
582
583fn warn_grouping_unsupported(grouping: Option<&crate::health_types::HealthGrouping>, format: &str) {
584 if let Some(g) = grouping {
585 eprintln!(
586 "note: --group-by {} is not supported for {format} output, falling back to \
587 ungrouped output (use --format json for the full grouped envelope)",
588 g.mode
589 );
590 }
591}
592
593pub fn print_cross_reference_findings(
597 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
598 root: &Path,
599 quiet: bool,
600 output: OutputFormat,
601) {
602 human::print_cross_reference_findings(cross_ref, root, quiet, output);
603}
604
605pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
609 match format {
610 OutputFormat::Json => json::print_trace_json(trace),
611 _ => human::print_export_trace_human(trace),
612 }
613}
614
615pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
617 match format {
618 OutputFormat::Json => json::print_trace_json(trace),
619 _ => human::print_file_trace_human(trace),
620 }
621}
622
623pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
625 match format {
626 OutputFormat::Json => json::print_trace_json(trace),
627 _ => human::print_dependency_trace_human(trace),
628 }
629}
630
631pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
633 match format {
634 OutputFormat::Json => json::print_trace_json(trace),
635 _ => human::print_clone_trace_human(trace, root),
636 }
637}
638
639pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
642 match format {
643 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
644 Ok(json) => eprintln!("{json}"),
645 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
646 },
647 _ => human::print_performance_human(timings),
648 }
649}
650
651pub fn print_health_performance(
654 timings: &crate::health_types::HealthTimings,
655 format: OutputFormat,
656) {
657 match format {
658 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
659 Ok(json) => eprintln!("{json}"),
660 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
661 },
662 _ => human::print_health_performance_human(timings),
663 }
664}
665
666#[allow(
669 unused_imports,
670 reason = "target-dependent: used in lib, unused in bin"
671)]
672pub use codeclimate::build_codeclimate;
673#[allow(
674 unused_imports,
675 reason = "target-dependent: used in lib, unused in bin"
676)]
677pub use codeclimate::build_duplication_codeclimate;
678#[allow(
679 unused_imports,
680 reason = "target-dependent: used in lib, unused in bin"
681)]
682pub use codeclimate::build_health_codeclimate;
683#[allow(
684 unused_imports,
685 reason = "target-dependent: used in lib, unused in bin"
686)]
687pub use codeclimate::issues_to_value as codeclimate_issues_to_value;
688#[allow(
689 unused_imports,
690 reason = "target-dependent: used in lib, unused in bin"
691)]
692pub use compact::build_compact_lines;
693#[allow(
694 clippy::redundant_pub_crate,
695 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
696)]
697pub(crate) use json::SCHEMA_VERSION;
698pub use json::build_baseline_deltas_json;
699#[allow(
700 unused_imports,
701 reason = "target-dependent: used in lib, unused in bin"
702)]
703pub use json::build_duplication_json;
704#[allow(
705 unused_imports,
706 reason = "target-dependent: used in lib, unused in bin"
707)]
708pub use json::build_grouped_duplication_json;
709#[allow(
710 unused_imports,
711 reason = "target-dependent: used in lib, unused in bin"
712)]
713pub use json::build_health_json;
714#[allow(
715 unused_imports,
716 reason = "target-dependent: used in bin audit.rs, unused in lib"
717)]
718#[allow(
719 clippy::redundant_pub_crate,
720 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
721)]
722pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
723#[allow(
724 unused_imports,
725 reason = "target-dependent: used in lib, unused in bin"
726)]
727pub use json::{build_json, build_json_with_config_fixable};
728#[allow(
729 unused_imports,
730 reason = "target-dependent: used in lib, unused in bin"
731)]
732pub use markdown::build_duplication_markdown;
733#[allow(
734 unused_imports,
735 reason = "target-dependent: used in lib, unused in bin"
736)]
737pub use markdown::build_health_markdown;
738#[allow(
739 unused_imports,
740 reason = "target-dependent: used in lib, unused in bin"
741)]
742pub use markdown::build_markdown;
743#[allow(
744 unused_imports,
745 reason = "target-dependent: used in lib, unused in bin"
746)]
747pub use sarif::build_health_sarif;
748#[allow(
749 unused_imports,
750 reason = "target-dependent: used in lib, unused in bin"
751)]
752pub use sarif::build_sarif;
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use std::path::PathBuf;
758
759 #[test]
762 fn normalize_uri_forward_slashes_unchanged() {
763 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
764 }
765
766 #[test]
767 fn normalize_uri_backslashes_replaced() {
768 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
769 }
770
771 #[test]
772 fn normalize_uri_mixed_slashes() {
773 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
774 }
775
776 #[test]
777 fn normalize_uri_path_with_spaces() {
778 assert_eq!(
779 normalize_uri("src\\my folder\\file.ts"),
780 "src/my folder/file.ts"
781 );
782 }
783
784 #[test]
785 fn normalize_uri_empty_string() {
786 assert_eq!(normalize_uri(""), "");
787 }
788
789 #[test]
792 fn relative_path_strips_root_prefix() {
793 let root = Path::new("/project");
794 let path = Path::new("/project/src/utils.ts");
795 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
796 }
797
798 #[test]
799 fn relative_path_returns_full_path_when_no_prefix() {
800 let root = Path::new("/other");
801 let path = Path::new("/project/src/utils.ts");
802 assert_eq!(relative_path(path, root), path);
803 }
804
805 #[test]
806 fn relative_path_at_root_returns_empty_or_file() {
807 let root = Path::new("/project");
808 let path = Path::new("/project/file.ts");
809 assert_eq!(relative_path(path, root), Path::new("file.ts"));
810 }
811
812 #[test]
813 fn relative_path_deeply_nested() {
814 let root = Path::new("/project");
815 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
816 assert_eq!(
817 relative_path(path, root),
818 Path::new("packages/ui/src/components/Button.tsx")
819 );
820 }
821
822 #[test]
825 fn format_display_path_returns_workspace_relative() {
826 let root = Path::new("/project");
827 let path = Path::new("/project/apps/server/src/index.ts");
828 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
829 }
830
831 #[test]
832 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
833 let root = Path::new("/project");
837 let server = Path::new("/project/apps/server/src/index.ts");
838 let client = Path::new("/project/apps/client/src/index.ts");
839 assert_eq!(
840 format_display_path(server, root),
841 "apps/server/src/index.ts"
842 );
843 assert_eq!(
844 format_display_path(client, root),
845 "apps/client/src/index.ts"
846 );
847 }
848
849 #[test]
850 fn format_display_path_angular_component_renders_parent_directory() {
851 let root = Path::new("/project");
852 let path = Path::new(
853 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
854 );
855 assert_eq!(
856 format_display_path(path, root),
857 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
858 );
859 }
860
861 #[test]
862 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
863 let root = Path::new("/other");
866 let path = Path::new("/project/src/utils.ts");
867 let rendered = format_display_path(path, root);
868 assert!(rendered.contains("project"));
869 assert!(rendered.ends_with("utils.ts"));
870 assert!(!rendered.contains('\\'));
871 }
872
873 #[test]
874 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
875 let root = Path::new("/project");
880 let path = Path::new("/project/src/sub\\file.ts");
881 let rendered = format_display_path(path, root);
882 assert!(
883 !rendered.contains('\\'),
884 "backslashes must be normalized: {rendered}"
885 );
886 }
887
888 #[test]
889 fn format_display_path_handles_brackets_verbatim() {
890 let root = Path::new("/project");
893 let path = Path::new("/project/app/[slug]/page.tsx");
894 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
895 }
896
897 #[test]
898 fn format_display_path_path_equals_root_returns_empty() {
899 let root = Path::new("/project");
900 let path = Path::new("/project");
901 assert_eq!(format_display_path(path, root), "");
902 }
903
904 #[test]
905 fn format_display_path_basename_only_when_path_is_at_root() {
906 let root = Path::new("/project");
907 let path = Path::new("/project/Cargo.toml");
908 assert_eq!(format_display_path(path, root), "Cargo.toml");
909 }
910
911 #[test]
914 fn relative_uri_produces_forward_slash_path() {
915 let root = PathBuf::from("/project");
916 let path = root.join("src").join("utils.ts");
917 let uri = relative_uri(&path, &root);
918 assert_eq!(uri, "src/utils.ts");
919 }
920
921 #[test]
922 fn relative_uri_encodes_brackets() {
923 let root = PathBuf::from("/project");
924 let path = root.join("src/app/[...slug]/page.tsx");
925 let uri = relative_uri(&path, &root);
926 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
927 }
928
929 #[test]
930 fn relative_uri_encodes_nested_dynamic_routes() {
931 let root = PathBuf::from("/project");
932 let path = root.join("src/app/[slug]/[id]/page.tsx");
933 let uri = relative_uri(&path, &root);
934 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
935 }
936
937 #[test]
938 fn relative_uri_no_common_prefix_returns_full() {
939 let root = PathBuf::from("/other");
940 let path = PathBuf::from("/project/src/utils.ts");
941 let uri = relative_uri(&path, &root);
942 assert!(uri.contains("project"));
943 assert!(uri.contains("utils.ts"));
944 }
945
946 #[test]
949 fn severity_error_maps_to_level_error() {
950 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
951 }
952
953 #[test]
954 fn severity_warn_maps_to_level_warn() {
955 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
956 }
957
958 #[test]
959 fn severity_off_maps_to_level_info() {
960 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
961 }
962
963 #[test]
966 fn normalize_uri_single_bracket_pair() {
967 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
968 }
969
970 #[test]
971 fn normalize_uri_catch_all_route() {
972 assert_eq!(
973 normalize_uri("app/[...slug]/page.tsx"),
974 "app/%5B...slug%5D/page.tsx"
975 );
976 }
977
978 #[test]
979 fn normalize_uri_optional_catch_all_route() {
980 assert_eq!(
981 normalize_uri("app/[[...slug]]/page.tsx"),
982 "app/%5B%5B...slug%5D%5D/page.tsx"
983 );
984 }
985
986 #[test]
987 fn normalize_uri_multiple_dynamic_segments() {
988 assert_eq!(
989 normalize_uri("app/[lang]/posts/[id]"),
990 "app/%5Blang%5D/posts/%5Bid%5D"
991 );
992 }
993
994 #[test]
995 fn normalize_uri_no_special_chars() {
996 let plain = "src/components/Button.tsx";
997 assert_eq!(normalize_uri(plain), plain);
998 }
999
1000 #[test]
1001 fn normalize_uri_only_backslashes() {
1002 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1003 }
1004
1005 #[test]
1008 fn relative_path_identical_paths_returns_empty() {
1009 let root = Path::new("/project");
1010 assert_eq!(relative_path(root, root), Path::new(""));
1011 }
1012
1013 #[test]
1014 fn relative_path_partial_name_match_not_stripped() {
1015 let root = Path::new("/project");
1018 let path = Path::new("/project-two/src/a.ts");
1019 assert_eq!(relative_path(path, root), path);
1020 }
1021
1022 #[test]
1025 fn relative_uri_combines_stripping_and_encoding() {
1026 let root = PathBuf::from("/project");
1027 let path = root.join("src/app/[slug]/page.tsx");
1028 let uri = relative_uri(&path, &root);
1029 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1031 assert!(!uri.starts_with('/'));
1032 }
1033
1034 #[test]
1035 fn relative_uri_at_root_file() {
1036 let root = PathBuf::from("/project");
1037 let path = root.join("index.ts");
1038 assert_eq!(relative_uri(&path, &root), "index.ts");
1039 }
1040
1041 #[test]
1044 fn severity_to_level_is_const_evaluable() {
1045 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1047 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1048 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1049 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1050 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1051 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1052 }
1053
1054 #[test]
1057 fn level_is_copy() {
1058 let level = severity_to_level(Severity::Error);
1059 let copy = level;
1060 assert!(matches!(level, Level::Error));
1062 assert!(matches!(copy, Level::Error));
1063 }
1064
1065 #[test]
1068 fn elide_common_prefix_shared_dir() {
1069 assert_eq!(
1070 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1071 "B.tsx"
1072 );
1073 }
1074
1075 #[test]
1076 fn elide_common_prefix_partial_shared() {
1077 assert_eq!(
1078 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1079 "utils/B.tsx"
1080 );
1081 }
1082
1083 #[test]
1084 fn elide_common_prefix_no_shared() {
1085 assert_eq!(
1086 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1087 "pkg-b/src/B.tsx"
1088 );
1089 }
1090
1091 #[test]
1092 fn elide_common_prefix_identical_files() {
1093 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1095 }
1096
1097 #[test]
1098 fn elide_common_prefix_no_dirs() {
1099 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1100 }
1101
1102 #[test]
1103 fn elide_common_prefix_deep_monorepo() {
1104 assert_eq!(
1105 elide_common_prefix(
1106 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1107 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1108 ),
1109 "SearchSelectItem.tsx"
1110 );
1111 }
1112
1113 #[test]
1116 fn split_dir_filename_with_dir() {
1117 let (dir, file) = split_dir_filename("src/utils/index.ts");
1118 assert_eq!(dir, "src/utils/");
1119 assert_eq!(file, "index.ts");
1120 }
1121
1122 #[test]
1123 fn split_dir_filename_no_dir() {
1124 let (dir, file) = split_dir_filename("file.ts");
1125 assert_eq!(dir, "");
1126 assert_eq!(file, "file.ts");
1127 }
1128
1129 #[test]
1130 fn split_dir_filename_deeply_nested() {
1131 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1132 assert_eq!(dir, "a/b/c/d/");
1133 assert_eq!(file, "e.ts");
1134 }
1135
1136 #[test]
1137 fn split_dir_filename_trailing_slash() {
1138 let (dir, file) = split_dir_filename("src/");
1139 assert_eq!(dir, "src/");
1140 assert_eq!(file, "");
1141 }
1142
1143 #[test]
1144 fn split_dir_filename_empty() {
1145 let (dir, file) = split_dir_filename("");
1146 assert_eq!(dir, "");
1147 assert_eq!(file, "");
1148 }
1149
1150 #[test]
1153 fn plural_zero_is_plural() {
1154 assert_eq!(plural(0), "s");
1155 }
1156
1157 #[test]
1158 fn plural_one_is_singular() {
1159 assert_eq!(plural(1), "");
1160 }
1161
1162 #[test]
1163 fn plural_two_is_plural() {
1164 assert_eq!(plural(2), "s");
1165 }
1166
1167 #[test]
1168 fn plural_large_number() {
1169 assert_eq!(plural(999), "s");
1170 }
1171
1172 #[test]
1175 fn elide_common_prefix_empty_base() {
1176 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1177 }
1178
1179 #[test]
1180 fn elide_common_prefix_empty_target() {
1181 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1182 }
1183
1184 #[test]
1185 fn elide_common_prefix_both_empty() {
1186 assert_eq!(elide_common_prefix("", ""), "");
1187 }
1188
1189 #[test]
1190 fn elide_common_prefix_same_file_different_extension() {
1191 assert_eq!(
1193 elide_common_prefix("src/utils.ts", "src/utils.js"),
1194 "utils.js"
1195 );
1196 }
1197
1198 #[test]
1199 fn elide_common_prefix_partial_filename_match_not_stripped() {
1200 assert_eq!(
1202 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1203 "AppUtils.tsx"
1204 );
1205 }
1206
1207 #[test]
1208 fn elide_common_prefix_identical_paths() {
1209 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1210 }
1211
1212 #[test]
1213 fn split_dir_filename_single_slash() {
1214 let (dir, file) = split_dir_filename("/file.ts");
1215 assert_eq!(dir, "/");
1216 assert_eq!(file, "file.ts");
1217 }
1218
1219 #[test]
1220 fn emit_json_returns_success_for_valid_value() {
1221 let value = serde_json::json!({"key": "value"});
1222 let code = emit_json(&value, "test");
1223 assert_eq!(code, ExitCode::SUCCESS);
1224 }
1225
1226 mod proptests {
1227 use super::*;
1228 use proptest::prelude::*;
1229
1230 proptest! {
1231 #[test]
1233 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1234 let (dir, file) = split_dir_filename(&path);
1235 let reconstructed = format!("{dir}{file}");
1236 prop_assert_eq!(
1237 reconstructed, path,
1238 "dir+file should reconstruct the original path"
1239 );
1240 }
1241
1242 #[test]
1244 fn plural_returns_empty_or_s(n: usize) {
1245 let result = plural(n);
1246 prop_assert!(
1247 result.is_empty() || result == "s",
1248 "plural should return \"\" or \"s\", got {:?}",
1249 result
1250 );
1251 }
1252
1253 #[test]
1255 fn plural_singular_only_for_one(n: usize) {
1256 let result = plural(n);
1257 if n == 1 {
1258 prop_assert_eq!(result, "", "plural(1) should be empty");
1259 } else {
1260 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1261 }
1262 }
1263
1264 #[test]
1266 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1267 let result = normalize_uri(&path);
1268 prop_assert!(
1269 !result.contains('\\'),
1270 "Result should not contain backslashes: {result}"
1271 );
1272 }
1273
1274 #[test]
1276 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1277 let result = normalize_uri(&path);
1278 prop_assert!(
1279 !result.contains('[') && !result.contains(']'),
1280 "Result should not contain raw brackets: {result}"
1281 );
1282 }
1283
1284 #[test]
1286 fn elide_common_prefix_returns_suffix_of_target(
1287 base in "[a-zA-Z0-9_./]{0,50}",
1288 target in "[a-zA-Z0-9_./]{0,50}",
1289 ) {
1290 let result = elide_common_prefix(&base, &target);
1291 prop_assert!(
1292 target.ends_with(result),
1293 "Result {:?} should be a suffix of target {:?}",
1294 result, target
1295 );
1296 }
1297
1298 #[test]
1300 fn relative_path_never_panics(
1301 root in "/[a-zA-Z0-9_/]{0,30}",
1302 suffix in "[a-zA-Z0-9_./]{0,30}",
1303 ) {
1304 let root_path = Path::new(&root);
1305 let full = PathBuf::from(format!("{root}/{suffix}"));
1306 let _ = relative_path(&full, root_path);
1307 }
1308 }
1309 }
1310}