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