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