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