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