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::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_file_trace(trace: &FileTrace, format: OutputFormat) {
653 match format {
654 OutputFormat::Json => json::print_trace_json(trace),
655 _ => human::print_file_trace_human(trace),
656 }
657}
658
659pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
661 match format {
662 OutputFormat::Json => json::print_trace_json(trace),
663 _ => human::print_dependency_trace_human(trace),
664 }
665}
666
667pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
669 match format {
670 OutputFormat::Json => json::print_trace_json(trace),
671 _ => human::print_clone_trace_human(trace, root),
672 }
673}
674
675pub fn print_impact_closure_trace(trace: &ImpactClosureTrace, format: OutputFormat) {
678 match format {
679 OutputFormat::Json => json::print_trace_json(trace),
680 _ => {
681 outln!("Impact closure for {}", trace.seed);
682 outln!(
683 " affected beyond the diff: {} file{}",
684 trace.affected_not_shown.len(),
685 plural(trace.affected_not_shown.len())
686 );
687 for gap in &trace.coordination_gap {
688 outln!(
689 " coordination gap: {} consumes {}",
690 gap.consumer_file,
691 gap.consumed_symbols.join(", ")
692 );
693 }
694 }
695 }
696}
697
698pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
701 match format {
702 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
703 Ok(json) => eprintln!("{json}"),
704 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
705 },
706 _ => human::print_performance_human(timings),
707 }
708}
709
710pub fn print_health_performance(timings: &fallow_output::HealthTimings, format: OutputFormat) {
713 match format {
714 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
715 Ok(json) => eprintln!("{json}"),
716 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
717 },
718 _ => human::print_health_performance_human(timings),
719 }
720}
721
722#[allow(
723 unused_imports,
724 reason = "target-dependent: used in lib, unused in bin"
725)]
726pub use fallow_api::build_compact_lines;
727#[allow(
728 unused_imports,
729 reason = "target-dependent: used in lib, unused in bin"
730)]
731pub use fallow_api::build_duplication_markdown;
732#[allow(
733 unused_imports,
734 reason = "target-dependent: used in lib, unused in bin"
735)]
736pub use fallow_api::build_health_markdown;
737#[allow(
738 unused_imports,
739 reason = "target-dependent: used in lib, unused in bin"
740)]
741pub use fallow_api::build_markdown;
742#[allow(
743 clippy::redundant_pub_crate,
744 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
745)]
746pub(crate) use json::SCHEMA_VERSION;
747#[allow(
748 clippy::redundant_pub_crate,
749 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
750)]
751pub(crate) use json::api_check_json_payload_with_config_fixable;
752#[allow(
753 clippy::redundant_pub_crate,
754 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
755)]
756pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
757#[allow(
758 unused_imports,
759 reason = "target-dependent: used in lib, unused in bin"
760)]
761#[allow(
762 clippy::redundant_pub_crate,
763 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
764)]
765pub(crate) use sarif::api_health_sarif_document;
766#[allow(
767 unused_imports,
768 reason = "target-dependent: used in lib, unused in bin"
769)]
770#[allow(
771 clippy::redundant_pub_crate,
772 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
773)]
774pub(crate) use sarif::api_sarif_document;
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use std::path::{Path, PathBuf};
780
781 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
782 ReportContext {
783 root,
784 rules,
785 elapsed: Duration::default(),
786 quiet: true,
787 explain: false,
788 group_by: None,
789 top: None,
790 summary: false,
791 summary_heading: false,
792 show_explain_tip: false,
793 baseline_matched: None,
794 config_fixable: false,
795 skip_score_and_trend: false,
796 css_requested: false,
797 }
798 }
799
800 #[test]
801 fn normalize_uri_forward_slashes_unchanged() {
802 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
803 }
804
805 #[test]
806 fn normalize_uri_backslashes_replaced() {
807 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
808 }
809
810 #[test]
811 fn normalize_uri_mixed_slashes() {
812 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
813 }
814
815 #[test]
816 fn normalize_uri_path_with_spaces() {
817 assert_eq!(
818 normalize_uri("src\\my folder\\file.ts"),
819 "src/my folder/file.ts"
820 );
821 }
822
823 #[test]
824 fn normalize_uri_empty_string() {
825 assert_eq!(normalize_uri(""), "");
826 }
827
828 #[test]
829 fn relative_path_strips_root_prefix() {
830 let root = Path::new("/project");
831 let path = Path::new("/project/src/utils.ts");
832 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
833 }
834
835 #[test]
836 fn relative_path_returns_full_path_when_no_prefix() {
837 let root = Path::new("/other");
838 let path = Path::new("/project/src/utils.ts");
839 assert_eq!(relative_path(path, root), path);
840 }
841
842 #[test]
843 fn relative_path_at_root_returns_empty_or_file() {
844 let root = Path::new("/project");
845 let path = Path::new("/project/file.ts");
846 assert_eq!(relative_path(path, root), Path::new("file.ts"));
847 }
848
849 #[test]
850 fn relative_path_deeply_nested() {
851 let root = Path::new("/project");
852 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
853 assert_eq!(
854 relative_path(path, root),
855 Path::new("packages/ui/src/components/Button.tsx")
856 );
857 }
858
859 #[test]
860 fn format_display_path_returns_workspace_relative() {
861 let root = Path::new("/project");
862 let path = Path::new("/project/apps/server/src/index.ts");
863 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
864 }
865
866 #[test]
867 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
868 let root = Path::new("/project");
869 let server = Path::new("/project/apps/server/src/index.ts");
870 let client = Path::new("/project/apps/client/src/index.ts");
871 assert_eq!(
872 format_display_path(server, root),
873 "apps/server/src/index.ts"
874 );
875 assert_eq!(
876 format_display_path(client, root),
877 "apps/client/src/index.ts"
878 );
879 }
880
881 #[test]
882 fn format_display_path_angular_component_renders_parent_directory() {
883 let root = Path::new("/project");
884 let path = Path::new(
885 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
886 );
887 assert_eq!(
888 format_display_path(path, root),
889 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
890 );
891 }
892
893 #[test]
894 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
895 let root = Path::new("/other");
896 let path = Path::new("/project/src/utils.ts");
897 let rendered = format_display_path(path, root);
898 assert!(rendered.contains("project"));
899 assert!(rendered.ends_with("utils.ts"));
900 assert!(!rendered.contains('\\'));
901 }
902
903 #[test]
904 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
905 let root = Path::new("/project");
906 let path = Path::new("/project/src/sub\\file.ts");
907 let rendered = format_display_path(path, root);
908 assert!(
909 !rendered.contains('\\'),
910 "backslashes must be normalized: {rendered}"
911 );
912 }
913
914 #[test]
915 fn format_display_path_handles_brackets_verbatim() {
916 let root = Path::new("/project");
917 let path = Path::new("/project/app/[slug]/page.tsx");
918 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
919 }
920
921 #[test]
922 fn format_display_path_path_equals_root_returns_empty() {
923 let root = Path::new("/project");
924 let path = Path::new("/project");
925 assert_eq!(format_display_path(path, root), "");
926 }
927
928 #[test]
929 fn format_display_path_basename_only_when_path_is_at_root() {
930 let root = Path::new("/project");
931 let path = Path::new("/project/Cargo.toml");
932 assert_eq!(format_display_path(path, root), "Cargo.toml");
933 }
934
935 #[test]
936 fn relative_uri_produces_forward_slash_path() {
937 let root = PathBuf::from("/project");
938 let path = root.join("src").join("utils.ts");
939 let uri = relative_uri(&path, &root);
940 assert_eq!(uri, "src/utils.ts");
941 }
942
943 #[test]
944 fn relative_uri_encodes_brackets() {
945 let root = PathBuf::from("/project");
946 let path = root.join("src/app/[...slug]/page.tsx");
947 let uri = relative_uri(&path, &root);
948 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
949 }
950
951 #[test]
952 fn relative_uri_encodes_nested_dynamic_routes() {
953 let root = PathBuf::from("/project");
954 let path = root.join("src/app/[slug]/[id]/page.tsx");
955 let uri = relative_uri(&path, &root);
956 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
957 }
958
959 #[test]
960 fn relative_uri_no_common_prefix_returns_full() {
961 let root = PathBuf::from("/other");
962 let path = PathBuf::from("/project/src/utils.ts");
963 let uri = relative_uri(&path, &root);
964 assert!(uri.contains("project"));
965 assert!(uri.contains("utils.ts"));
966 }
967
968 #[test]
969 fn severity_error_maps_to_level_error() {
970 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
971 }
972
973 #[test]
974 fn severity_warn_maps_to_level_warn() {
975 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
976 }
977
978 #[test]
979 fn severity_off_maps_to_level_info() {
980 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
981 }
982
983 #[test]
984 fn normalize_uri_single_bracket_pair() {
985 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
986 }
987
988 #[test]
989 fn normalize_uri_catch_all_route() {
990 assert_eq!(
991 normalize_uri("app/[...slug]/page.tsx"),
992 "app/%5B...slug%5D/page.tsx"
993 );
994 }
995
996 #[test]
997 fn normalize_uri_optional_catch_all_route() {
998 assert_eq!(
999 normalize_uri("app/[[...slug]]/page.tsx"),
1000 "app/%5B%5B...slug%5D%5D/page.tsx"
1001 );
1002 }
1003
1004 #[test]
1005 fn normalize_uri_multiple_dynamic_segments() {
1006 assert_eq!(
1007 normalize_uri("app/[lang]/posts/[id]"),
1008 "app/%5Blang%5D/posts/%5Bid%5D"
1009 );
1010 }
1011
1012 #[test]
1013 fn normalize_uri_no_special_chars() {
1014 let plain = "src/components/Button.tsx";
1015 assert_eq!(normalize_uri(plain), plain);
1016 }
1017
1018 #[test]
1019 fn normalize_uri_only_backslashes() {
1020 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1021 }
1022
1023 #[test]
1024 fn relative_path_identical_paths_returns_empty() {
1025 let root = Path::new("/project");
1026 assert_eq!(relative_path(root, root), Path::new(""));
1027 }
1028
1029 #[test]
1030 fn relative_path_partial_name_match_not_stripped() {
1031 let root = Path::new("/project");
1032 let path = Path::new("/project-two/src/a.ts");
1033 assert_eq!(relative_path(path, root), path);
1034 }
1035
1036 #[test]
1037 fn relative_uri_combines_stripping_and_encoding() {
1038 let root = PathBuf::from("/project");
1039 let path = root.join("src/app/[slug]/page.tsx");
1040 let uri = relative_uri(&path, &root);
1041 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1042 assert!(!uri.starts_with('/'));
1043 }
1044
1045 #[test]
1046 fn relative_uri_at_root_file() {
1047 let root = PathBuf::from("/project");
1048 let path = root.join("index.ts");
1049 assert_eq!(relative_uri(&path, &root), "index.ts");
1050 }
1051
1052 #[test]
1053 fn severity_to_level_is_const_evaluable() {
1054 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1055 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1056 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1057 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1058 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1059 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1060 }
1061
1062 #[test]
1063 fn level_is_copy() {
1064 let level = severity_to_level(Severity::Error);
1065 let copy = level;
1066 assert!(matches!(level, Level::Error));
1067 assert!(matches!(copy, Level::Error));
1068 }
1069
1070 #[test]
1071 fn print_results_rejects_badge_for_dead_code_reports() {
1072 let root = Path::new("/project");
1073 let rules = RulesConfig::default();
1074 let ctx = test_context(root, &rules);
1075
1076 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1077
1078 assert_eq!(code, ExitCode::from(2));
1079 }
1080
1081 #[test]
1082 fn print_duplication_report_rejects_badge_format() {
1083 let root = Path::new("/project");
1084 let rules = RulesConfig::default();
1085 let ctx = test_context(root, &rules);
1086
1087 let code =
1088 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1089
1090 assert_eq!(code, ExitCode::from(2));
1091 }
1092
1093 #[test]
1094 fn elide_common_prefix_shared_dir() {
1095 assert_eq!(
1096 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1097 "B.tsx"
1098 );
1099 }
1100
1101 #[test]
1102 fn elide_common_prefix_partial_shared() {
1103 assert_eq!(
1104 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1105 "utils/B.tsx"
1106 );
1107 }
1108
1109 #[test]
1110 fn elide_common_prefix_no_shared() {
1111 assert_eq!(
1112 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1113 "pkg-b/src/B.tsx"
1114 );
1115 }
1116
1117 #[test]
1118 fn elide_common_prefix_identical_files() {
1119 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1120 }
1121
1122 #[test]
1123 fn elide_common_prefix_no_dirs() {
1124 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1125 }
1126
1127 #[test]
1128 fn elide_common_prefix_deep_monorepo() {
1129 assert_eq!(
1130 elide_common_prefix(
1131 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1132 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1133 ),
1134 "SearchSelectItem.tsx"
1135 );
1136 }
1137
1138 #[test]
1139 fn split_dir_filename_with_dir() {
1140 let (dir, file) = split_dir_filename("src/utils/index.ts");
1141 assert_eq!(dir, "src/utils/");
1142 assert_eq!(file, "index.ts");
1143 }
1144
1145 #[test]
1146 fn split_dir_filename_no_dir() {
1147 let (dir, file) = split_dir_filename("file.ts");
1148 assert_eq!(dir, "");
1149 assert_eq!(file, "file.ts");
1150 }
1151
1152 #[test]
1153 fn split_dir_filename_deeply_nested() {
1154 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1155 assert_eq!(dir, "a/b/c/d/");
1156 assert_eq!(file, "e.ts");
1157 }
1158
1159 #[test]
1160 fn split_dir_filename_trailing_slash() {
1161 let (dir, file) = split_dir_filename("src/");
1162 assert_eq!(dir, "src/");
1163 assert_eq!(file, "");
1164 }
1165
1166 #[test]
1167 fn split_dir_filename_empty() {
1168 let (dir, file) = split_dir_filename("");
1169 assert_eq!(dir, "");
1170 assert_eq!(file, "");
1171 }
1172
1173 #[test]
1174 fn plural_zero_is_plural() {
1175 assert_eq!(plural(0), "s");
1176 }
1177
1178 #[test]
1179 fn plural_one_is_singular() {
1180 assert_eq!(plural(1), "");
1181 }
1182
1183 #[test]
1184 fn plural_two_is_plural() {
1185 assert_eq!(plural(2), "s");
1186 }
1187
1188 #[test]
1189 fn plural_large_number() {
1190 assert_eq!(plural(999), "s");
1191 }
1192
1193 #[test]
1194 fn elide_common_prefix_empty_base() {
1195 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1196 }
1197
1198 #[test]
1199 fn elide_common_prefix_empty_target() {
1200 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1201 }
1202
1203 #[test]
1204 fn elide_common_prefix_both_empty() {
1205 assert_eq!(elide_common_prefix("", ""), "");
1206 }
1207
1208 #[test]
1209 fn elide_common_prefix_same_file_different_extension() {
1210 assert_eq!(
1211 elide_common_prefix("src/utils.ts", "src/utils.js"),
1212 "utils.js"
1213 );
1214 }
1215
1216 #[test]
1217 fn elide_common_prefix_partial_filename_match_not_stripped() {
1218 assert_eq!(
1219 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1220 "AppUtils.tsx"
1221 );
1222 }
1223
1224 #[test]
1225 fn elide_common_prefix_identical_paths() {
1226 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1227 }
1228
1229 #[test]
1230 fn split_dir_filename_single_slash() {
1231 let (dir, file) = split_dir_filename("/file.ts");
1232 assert_eq!(dir, "/");
1233 assert_eq!(file, "file.ts");
1234 }
1235
1236 #[test]
1237 fn emit_json_returns_success_for_valid_value() {
1238 let value = serde_json::json!({"key": "value"});
1239 let code = emit_json(&value, "test");
1240 assert_eq!(code, ExitCode::SUCCESS);
1241 }
1242
1243 mod proptests {
1244 use super::*;
1245 use proptest::prelude::*;
1246
1247 proptest! {
1248 #[test]
1250 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1251 let (dir, file) = split_dir_filename(&path);
1252 let reconstructed = format!("{dir}{file}");
1253 prop_assert_eq!(
1254 reconstructed, path,
1255 "dir+file should reconstruct the original path"
1256 );
1257 }
1258
1259 #[test]
1261 fn plural_returns_empty_or_s(n: usize) {
1262 let result = plural(n);
1263 prop_assert!(
1264 result.is_empty() || result == "s",
1265 "plural should return \"\" or \"s\", got {:?}",
1266 result
1267 );
1268 }
1269
1270 #[test]
1272 fn plural_singular_only_for_one(n: usize) {
1273 let result = plural(n);
1274 if n == 1 {
1275 prop_assert_eq!(result, "", "plural(1) should be empty");
1276 } else {
1277 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1278 }
1279 }
1280
1281 #[test]
1283 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1284 let result = normalize_uri(&path);
1285 prop_assert!(
1286 !result.contains('\\'),
1287 "Result should not contain backslashes: {result}"
1288 );
1289 }
1290
1291 #[test]
1293 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1294 let result = normalize_uri(&path);
1295 prop_assert!(
1296 !result.contains('[') && !result.contains(']'),
1297 "Result should not contain raw brackets: {result}"
1298 );
1299 }
1300
1301 #[test]
1303 fn elide_common_prefix_returns_suffix_of_target(
1304 base in "[a-zA-Z0-9_./]{0,50}",
1305 target in "[a-zA-Z0-9_./]{0,50}",
1306 ) {
1307 let result = elide_common_prefix(&base, &target);
1308 prop_assert!(
1309 target.ends_with(result),
1310 "Result {:?} should be a suffix of target {:?}",
1311 result, target
1312 );
1313 }
1314
1315 #[test]
1317 fn relative_path_never_panics(
1318 root in "/[a-zA-Z0-9_/]{0,30}",
1319 suffix in "[a-zA-Z0-9_./]{0,30}",
1320 ) {
1321 let root_path = Path::new(&root);
1322 let full = PathBuf::from(format!("{root}/{suffix}"));
1323 let _ = relative_path(&full, root_path);
1324 }
1325 }
1326 }
1327}