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