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