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 let incomplete = ci::required_type_aware_incomplete(ctx.type_aware);
430 let conclusion = incomplete.then_some(fallow_output::PrDecisionConclusion::Failure);
431 let status_message = incomplete.then_some(ci::TYPE_AWARE_INCOMPLETE_MESSAGE);
432 print_ci_comment_format_with_status("dead-code", &value, output, conclusion, status_message)
433 .unwrap_or_else(|| {
434 eprintln!("Error: badge format is only supported for the health command");
435 ExitCode::from(2)
436 })
437}
438
439#[must_use]
441fn print_grouped_results(
442 groups: &[grouping::ResultGroup],
443 original: &AnalysisResults,
444 ctx: &ReportContext<'_>,
445 output: OutputFormat,
446 resolver: &OwnershipResolver,
447) -> ExitCode {
448 match output {
449 OutputFormat::Human => {
450 human::print_grouped_human(&human::PrintGroupedHumanInput {
451 groups,
452 root: ctx.root,
453 rules: ctx.rules,
454 elapsed: ctx.elapsed,
455 quiet: ctx.quiet,
456 resolver: Some(resolver),
457 explain: ctx.explain,
458 });
459 ExitCode::SUCCESS
460 }
461 OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
462 groups,
463 original,
464 root: ctx.root,
465 elapsed: ctx.elapsed,
466 explain: ctx.explain,
467 type_aware: ctx.type_aware,
468 resolver,
469 config_fixable: ctx.config_fixable,
470 json_style: ctx.json_style,
471 }),
472 OutputFormat::Compact => {
473 compact::print_grouped_compact(groups, ctx.root);
474 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
475 ExitCode::SUCCESS
476 }
477 OutputFormat::Markdown => {
478 markdown::print_grouped_markdown(groups, ctx.root);
479 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
480 ExitCode::SUCCESS
481 }
482 OutputFormat::Sarif => {
483 sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver, ctx.type_aware)
484 }
485 OutputFormat::CodeClimate => {
486 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
487 }
488 OutputFormat::GithubAnnotations => print_check_github_annotations(original, ctx),
491 OutputFormat::GithubSummary => {
492 print_check_github_format(original, ctx, GithubTarget::Summary)
493 }
494 ci_format => print_results_ci_comment(original, ctx, ci_format),
495 }
496}
497
498#[must_use]
500pub(crate) fn print_duplication_report(
501 report: &DuplicationReport,
502 ctx: &ReportContext<'_>,
503 output: OutputFormat,
504) -> ExitCode {
505 if let Some(ref resolver) = ctx.group_by {
506 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
507 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
508 }
509
510 match output {
511 OutputFormat::Human => {
512 if ctx.summary {
513 human::dupes::print_duplication_summary(
514 report,
515 ctx.elapsed,
516 ctx.quiet,
517 ctx.summary_heading,
518 );
519 } else {
520 human::print_duplication_human(
521 report,
522 ctx.root,
523 ctx.elapsed,
524 ctx.quiet,
525 ctx.show_explain_tip,
526 ctx.explain,
527 );
528 }
529 ExitCode::SUCCESS
530 }
531 OutputFormat::Json => {
532 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain, ctx.json_style)
533 }
534 OutputFormat::Compact => {
535 compact::print_duplication_compact(report, ctx.root);
536 ExitCode::SUCCESS
537 }
538 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
539 OutputFormat::Markdown => {
540 markdown::print_duplication_markdown(report, ctx.root);
541 ExitCode::SUCCESS
542 }
543 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
544 OutputFormat::GithubAnnotations => {
545 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
546 }
547 OutputFormat::GithubSummary => {
548 print_dupes_github_format(report, ctx, GithubTarget::Summary)
549 }
550 ci_format => print_duplication_ci_comment(report, ctx.root, ci_format),
551 }
552}
553
554fn print_dupes_github_format(
557 report: &DuplicationReport,
558 ctx: &ReportContext<'_>,
559 target: GithubTarget,
560) -> ExitCode {
561 match json::api_duplication_json_document(report, ctx.root, ctx.elapsed, ctx.explain) {
562 Ok(envelope) => print_github_format(
563 github_annotations::EnvelopeKind::Dupes,
564 &envelope,
565 ctx.root,
566 target,
567 ),
568 Err(e) => {
569 eprintln!("Error: failed to serialize duplication report: {e}");
570 ExitCode::from(2)
571 }
572 }
573}
574
575fn print_duplication_ci_comment(
577 report: &DuplicationReport,
578 root: &Path,
579 output: OutputFormat,
580) -> ExitCode {
581 let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
582 let value = fallow_output::codeclimate_issues_to_value(&issues);
583 print_ci_comment_format("dupes", &value, output).unwrap_or_else(|| {
584 eprintln!("Error: badge format is only supported for the health command");
585 ExitCode::from(2)
586 })
587}
588
589#[must_use]
591fn print_grouped_duplication_report(
592 report: &DuplicationReport,
593 grouping: &DuplicationGrouping,
594 ctx: &ReportContext<'_>,
595 output: OutputFormat,
596 resolver: &OwnershipResolver,
597) -> ExitCode {
598 match output {
599 OutputFormat::Human => {
600 human::print_grouped_duplication_human(
601 report,
602 grouping,
603 ctx.root,
604 ctx.elapsed,
605 ctx.quiet,
606 );
607 ExitCode::SUCCESS
608 }
609 OutputFormat::Json => json::print_grouped_duplication_json(
610 report,
611 grouping,
612 ctx.root,
613 ctx.elapsed,
614 ctx.explain,
615 ctx.json_style,
616 ),
617 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
618 OutputFormat::CodeClimate => {
619 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
620 }
621 OutputFormat::PrCommentGithub
622 | OutputFormat::PrCommentGitlab
623 | OutputFormat::ReviewGithub
624 | OutputFormat::ReviewGitlab => print_duplication_ci_comment(report, ctx.root, output),
625 OutputFormat::GithubAnnotations => {
628 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
629 }
630 OutputFormat::GithubSummary => {
631 print_dupes_github_format(report, ctx, GithubTarget::Summary)
632 }
633 OutputFormat::Compact => {
634 compact::print_duplication_compact(report, ctx.root);
635 warn_dupes_grouping_unsupported(grouping, "compact");
636 ExitCode::SUCCESS
637 }
638 OutputFormat::Markdown => {
639 markdown::print_duplication_markdown(report, ctx.root);
640 warn_dupes_grouping_unsupported(grouping, "markdown");
641 ExitCode::SUCCESS
642 }
643 OutputFormat::Badge => {
644 eprintln!("Error: badge format is only supported for the health command");
645 ExitCode::from(2)
646 }
647 }
648}
649
650fn print_ci_comment_format(
655 analysis: &str,
656 value: &serde_json::Value,
657 output: OutputFormat,
658) -> Option<ExitCode> {
659 print_ci_comment_format_with_status(analysis, value, output, None, None)
660}
661
662fn print_ci_comment_format_with_status(
663 analysis: &str,
664 value: &serde_json::Value,
665 output: OutputFormat,
666 conclusion: Option<fallow_output::PrDecisionConclusion>,
667 status_message: Option<&str>,
668) -> Option<ExitCode> {
669 let exit = match output {
670 OutputFormat::PrCommentGithub => conclusion.map_or_else(
671 || ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Github, value),
672 |conclusion| {
673 ci::pr_comment::print_pr_comment_with_status(
674 analysis,
675 ci::pr_comment::Provider::Github,
676 value,
677 conclusion,
678 status_message,
679 )
680 },
681 ),
682 OutputFormat::PrCommentGitlab => conclusion.map_or_else(
683 || ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Gitlab, value),
684 |conclusion| {
685 ci::pr_comment::print_pr_comment_with_status(
686 analysis,
687 ci::pr_comment::Provider::Gitlab,
688 value,
689 conclusion,
690 status_message,
691 )
692 },
693 ),
694 OutputFormat::ReviewGithub => conclusion.map_or_else(
695 || ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Github, value),
696 |conclusion| {
697 ci::review::print_review_envelope_with_conclusion(
698 analysis,
699 ci::pr_comment::Provider::Github,
700 value,
701 conclusion,
702 status_message,
703 )
704 },
705 ),
706 OutputFormat::ReviewGitlab => conclusion.map_or_else(
707 || ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Gitlab, value),
708 |conclusion| {
709 ci::review::print_review_envelope_with_conclusion(
710 analysis,
711 ci::pr_comment::Provider::Gitlab,
712 value,
713 conclusion,
714 status_message,
715 )
716 },
717 ),
718 _ => return None,
719 };
720 Some(exit)
721}
722
723fn warn_dupes_grouping_unsupported(grouping: &DuplicationGrouping, format: &str) {
724 eprintln!(
725 "note: --group-by {} is not supported for {format} duplication output, falling back to \
726 ungrouped output (use --format json for the full grouped envelope)",
727 grouping.mode
728 );
729}
730
731#[must_use]
748pub(crate) fn print_health_report(
749 report: &fallow_output::HealthReport,
750 grouping: Option<&fallow_output::HealthGrouping>,
751 group_resolver: Option<&grouping::OwnershipResolver>,
752 ctx: &ReportContext<'_>,
753 output: OutputFormat,
754) -> ExitCode {
755 match output {
756 OutputFormat::Human => {
757 print_health_human_report(report, grouping, ctx);
758 ExitCode::SUCCESS
759 }
760 OutputFormat::Compact => {
761 compact::print_health_compact(report, ctx.root);
762 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
763 warn_grouping_unsupported(grouping, "compact");
764 ExitCode::SUCCESS
765 }
766 OutputFormat::Markdown => {
767 markdown::print_health_markdown(report, ctx.root);
768 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
769 warn_grouping_unsupported(grouping, "markdown");
770 ExitCode::SUCCESS
771 }
772 OutputFormat::Sarif => match group_resolver {
773 Some(resolver) => {
774 sarif::print_grouped_health_sarif(report, ctx.root, resolver, ctx.type_aware)
775 }
776 None => sarif::print_health_sarif(report, ctx.root, ctx.type_aware),
777 },
778 OutputFormat::Json => match grouping {
779 Some(grouping) => json::print_grouped_health_json(
780 report,
781 grouping,
782 ctx.root,
783 ctx.elapsed,
784 ctx.explain,
785 ctx.type_aware,
786 ctx.json_style,
787 ),
788 None => json::print_health_json(
789 report,
790 ctx.root,
791 ctx.elapsed,
792 ctx.explain,
793 ctx.type_aware,
794 ctx.json_style,
795 ),
796 },
797 OutputFormat::CodeClimate => match group_resolver {
798 Some(resolver) => {
799 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
800 }
801 None => codeclimate::print_health_codeclimate(report, ctx.root),
802 },
803 OutputFormat::PrCommentGithub
804 | OutputFormat::PrCommentGitlab
805 | OutputFormat::ReviewGithub
806 | OutputFormat::ReviewGitlab => print_health_ci_comment(report, ctx.root, output),
807 OutputFormat::GithubAnnotations => {
810 print_health_github_format(report, ctx, GithubTarget::Annotations)
811 }
812 OutputFormat::GithubSummary => {
813 print_health_github_format(report, ctx, GithubTarget::Summary)
814 }
815 OutputFormat::Badge => {
816 warn_grouping_unsupported(grouping, "badge");
817 badge::print_health_badge(report)
818 }
819 }
820}
821
822fn print_health_github_format(
825 report: &fallow_output::HealthReport,
826 ctx: &ReportContext<'_>,
827 target: GithubTarget,
828) -> ExitCode {
829 match json::api_health_json_document(report, ctx.root, ctx.elapsed, ctx.explain, ctx.type_aware)
830 {
831 Ok(envelope) => print_github_format(
832 github_annotations::EnvelopeKind::Health,
833 &envelope,
834 ctx.root,
835 target,
836 ),
837 Err(e) => {
838 eprintln!("Error: failed to serialize health report: {e}");
839 ExitCode::from(2)
840 }
841 }
842}
843
844fn print_health_human_report(
846 report: &fallow_output::HealthReport,
847 grouping: Option<&fallow_output::HealthGrouping>,
848 ctx: &ReportContext<'_>,
849) {
850 if ctx.summary {
851 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
852 return;
853 }
854 human::print_health_human(&human::PrintHealthHumanInput {
855 report,
856 root: ctx.root,
857 elapsed: ctx.elapsed,
858 quiet: ctx.quiet,
859 show_explain_tip: ctx.show_explain_tip,
860 explain: ctx.explain,
861 skip_score_and_trend: ctx.skip_score_and_trend,
862 css_requested: ctx.css_requested,
863 type_aware: ctx.type_aware,
864 });
865 if let Some(grouping) = grouping {
866 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
867 }
868}
869
870fn print_health_ci_comment(
872 report: &fallow_output::HealthReport,
873 root: &Path,
874 output: OutputFormat,
875) -> ExitCode {
876 let issues = codeclimate::api_health_codeclimate_issues(report, root);
877 let value = fallow_output::codeclimate_issues_to_value(&issues);
878 print_ci_comment_format("health", &value, output).unwrap_or_else(|| {
879 eprintln!("Error: badge format is only supported for the health command");
880 ExitCode::from(2)
881 })
882}
883
884fn warn_grouping_unsupported(grouping: Option<&fallow_output::HealthGrouping>, format: &str) {
885 if let Some(g) = grouping {
886 eprintln!(
887 "note: --group-by {} is not supported for {format} output, falling back to \
888 ungrouped output (use --format json for the full grouped envelope)",
889 g.mode
890 );
891 }
892}
893
894pub(crate) fn print_cross_reference_findings(
898 cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
899 root: &Path,
900 quiet: bool,
901 output: OutputFormat,
902) {
903 human::print_cross_reference_findings(cross_ref, root, quiet, output);
904}
905
906pub(crate) fn print_export_trace(
908 trace: &ExportTrace,
909 format: OutputFormat,
910 json_style: crate::json_style::JsonStyle,
911) {
912 match format {
913 OutputFormat::Json => json::print_trace_json(trace, json_style),
914 _ => human::print_export_trace_human(trace),
915 }
916}
917
918pub(crate) fn print_semantic_export_trace(
921 trace: &ExportTrace,
922 format: OutputFormat,
923 explain: bool,
924 json_style: crate::json_style::JsonStyle,
925) {
926 match format {
927 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
928 _ => human::print_export_trace_human(trace),
929 }
930}
931
932pub(crate) fn print_class_member_trace(
934 trace: &fallow_engine::trace::ClassMemberTrace,
935 format: OutputFormat,
936 json_style: crate::json_style::JsonStyle,
937) {
938 match format {
939 OutputFormat::Json => json::print_trace_json(trace, json_style),
940 _ => human::print_class_member_trace_human(trace),
941 }
942}
943
944pub(crate) fn print_semantic_class_member_trace(
947 trace: &fallow_engine::trace::ClassMemberTrace,
948 format: OutputFormat,
949 explain: bool,
950 json_style: crate::json_style::JsonStyle,
951) {
952 match format {
953 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
954 _ => human::print_class_member_trace_human(trace),
955 }
956}
957
958pub(crate) fn print_file_trace(
960 trace: &FileTrace,
961 format: OutputFormat,
962 json_style: crate::json_style::JsonStyle,
963) {
964 match format {
965 OutputFormat::Json => json::print_trace_json(trace, json_style),
966 _ => human::print_file_trace_human(trace),
967 }
968}
969
970pub(crate) fn print_dependency_trace(
972 trace: &DependencyTrace,
973 format: OutputFormat,
974 json_style: crate::json_style::JsonStyle,
975) {
976 match format {
977 OutputFormat::Json => json::print_trace_json(trace, json_style),
978 _ => human::print_dependency_trace_human(trace),
979 }
980}
981
982pub(crate) fn print_clone_trace(
984 trace: &CloneTrace,
985 root: &Path,
986 format: OutputFormat,
987 json_style: crate::json_style::JsonStyle,
988) {
989 match format {
990 OutputFormat::Json => json::print_trace_json(trace, json_style),
991 _ => human::print_clone_trace_human(trace, root),
992 }
993}
994
995pub(crate) fn print_impact_closure_trace(
998 trace: &ImpactClosureTrace,
999 format: OutputFormat,
1000 json_style: crate::json_style::JsonStyle,
1001) {
1002 match format {
1003 OutputFormat::Json => json::print_trace_json(trace, json_style),
1004 _ => {
1005 outln!("Impact closure for {}", trace.seed);
1006 outln!(
1007 " affected beyond the diff: {} file{}",
1008 trace.affected_not_shown.len(),
1009 plural(trace.affected_not_shown.len())
1010 );
1011 for gap in &trace.coordination_gap {
1012 outln!(
1013 " coordination gap: {} consumes {}",
1014 gap.consumer_file,
1015 gap.consumed_symbols.join(", ")
1016 );
1017 }
1018 }
1019 }
1020}
1021
1022pub(crate) fn print_symbol_impact(
1024 impact: &SemanticSymbolImpact,
1025 format: OutputFormat,
1026 explain: bool,
1027 json_style: crate::json_style::JsonStyle,
1028) {
1029 match format {
1030 OutputFormat::Json => json::print_semantic_impact_json(impact, explain, json_style),
1031 _ => human::print_symbol_impact_human(impact),
1032 }
1033}
1034
1035pub(crate) fn print_performance(
1038 timings: &PipelineTimings,
1039 format: OutputFormat,
1040 json_style: crate::json_style::JsonStyle,
1041) {
1042 match format {
1043 OutputFormat::Json => match json_style.serialize(timings) {
1044 Ok(json) => eprintln!("{json}"),
1045 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1046 },
1047 _ => human::print_performance_human(timings),
1048 }
1049}
1050
1051pub(crate) fn print_health_performance(
1054 timings: &fallow_output::HealthTimings,
1055 format: OutputFormat,
1056 json_style: crate::json_style::JsonStyle,
1057) {
1058 match format {
1059 OutputFormat::Json => match json_style.serialize(timings) {
1060 Ok(json) => eprintln!("{json}"),
1061 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1062 },
1063 _ => human::print_health_performance_human(timings),
1064 }
1065}
1066
1067#[allow(
1068 unused_imports,
1069 reason = "target-dependent: used in lib, unused in bin"
1070)]
1071pub use fallow_api::build_compact_lines;
1072#[allow(
1073 unused_imports,
1074 reason = "target-dependent: used in lib, unused in bin"
1075)]
1076pub use fallow_api::build_duplication_markdown;
1077#[allow(
1078 unused_imports,
1079 reason = "target-dependent: used in lib, unused in bin"
1080)]
1081pub use fallow_api::build_health_markdown;
1082#[allow(
1083 unused_imports,
1084 reason = "target-dependent: used in lib, unused in bin"
1085)]
1086pub use fallow_api::build_markdown;
1087#[allow(
1088 clippy::redundant_pub_crate,
1089 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1090)]
1091pub(crate) use json::api_check_json_payload_with_config_fixable;
1092#[allow(
1093 clippy::redundant_pub_crate,
1094 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1095)]
1096pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1097#[allow(
1098 unused_imports,
1099 reason = "target-dependent: used in lib, unused in bin"
1100)]
1101#[allow(
1102 clippy::redundant_pub_crate,
1103 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1104)]
1105pub(crate) use sarif::api_health_sarif_document;
1106#[allow(
1107 unused_imports,
1108 reason = "target-dependent: used in lib, unused in bin"
1109)]
1110#[allow(
1111 clippy::redundant_pub_crate,
1112 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1113)]
1114pub(crate) use sarif::api_sarif_document;
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use std::path::{Path, PathBuf};
1120
1121 #[test]
1122 fn format_bytes_pivots_at_power_of_1024() {
1123 assert_eq!(format_bytes(0), "0 B");
1124 assert_eq!(format_bytes(1023), "1023 B");
1125 assert_eq!(format_bytes(1024), "1 KiB");
1126 assert_eq!(format_bytes(2048), "2 KiB");
1127 assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1128 assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1129 assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1130 }
1131
1132 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1133 ReportContext {
1134 root,
1135 rules,
1136 elapsed: Duration::default(),
1137 quiet: true,
1138 explain: false,
1139 type_aware: None,
1140 type_aware_scope: None,
1141 group_by: None,
1142 top: None,
1143 summary: false,
1144 summary_heading: false,
1145 show_explain_tip: false,
1146 baseline_matched: None,
1147 config_fixable: false,
1148 skip_score_and_trend: false,
1149 css_requested: false,
1150 json_style: crate::json_style::JsonStyle::Compact,
1151 }
1152 }
1153
1154 #[test]
1155 fn normalize_uri_forward_slashes_unchanged() {
1156 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1157 }
1158
1159 #[test]
1160 fn normalize_uri_backslashes_replaced() {
1161 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1162 }
1163
1164 #[test]
1165 fn normalize_uri_mixed_slashes() {
1166 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1167 }
1168
1169 #[test]
1170 fn normalize_uri_path_with_spaces() {
1171 assert_eq!(
1172 normalize_uri("src\\my folder\\file.ts"),
1173 "src/my folder/file.ts"
1174 );
1175 }
1176
1177 #[test]
1178 fn normalize_uri_empty_string() {
1179 assert_eq!(normalize_uri(""), "");
1180 }
1181
1182 #[test]
1183 fn relative_path_strips_root_prefix() {
1184 let root = Path::new("/project");
1185 let path = Path::new("/project/src/utils.ts");
1186 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1187 }
1188
1189 #[test]
1190 fn relative_path_returns_full_path_when_no_prefix() {
1191 let root = Path::new("/other");
1192 let path = Path::new("/project/src/utils.ts");
1193 assert_eq!(relative_path(path, root), path);
1194 }
1195
1196 #[test]
1197 fn relative_path_at_root_returns_empty_or_file() {
1198 let root = Path::new("/project");
1199 let path = Path::new("/project/file.ts");
1200 assert_eq!(relative_path(path, root), Path::new("file.ts"));
1201 }
1202
1203 #[test]
1204 fn relative_path_deeply_nested() {
1205 let root = Path::new("/project");
1206 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1207 assert_eq!(
1208 relative_path(path, root),
1209 Path::new("packages/ui/src/components/Button.tsx")
1210 );
1211 }
1212
1213 #[test]
1214 fn format_display_path_returns_workspace_relative() {
1215 let root = Path::new("/project");
1216 let path = Path::new("/project/apps/server/src/index.ts");
1217 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1218 }
1219
1220 #[test]
1221 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1222 let root = Path::new("/project");
1223 let server = Path::new("/project/apps/server/src/index.ts");
1224 let client = Path::new("/project/apps/client/src/index.ts");
1225 assert_eq!(
1226 format_display_path(server, root),
1227 "apps/server/src/index.ts"
1228 );
1229 assert_eq!(
1230 format_display_path(client, root),
1231 "apps/client/src/index.ts"
1232 );
1233 }
1234
1235 #[test]
1236 fn format_display_path_angular_component_renders_parent_directory() {
1237 let root = Path::new("/project");
1238 let path = Path::new(
1239 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1240 );
1241 assert_eq!(
1242 format_display_path(path, root),
1243 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1244 );
1245 }
1246
1247 #[test]
1248 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1249 let root = Path::new("/other");
1250 let path = Path::new("/project/src/utils.ts");
1251 let rendered = format_display_path(path, root);
1252 assert!(rendered.contains("project"));
1253 assert!(rendered.ends_with("utils.ts"));
1254 assert!(!rendered.contains('\\'));
1255 }
1256
1257 #[test]
1258 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1259 let root = Path::new("/project");
1260 let path = Path::new("/project/src/sub\\file.ts");
1261 let rendered = format_display_path(path, root);
1262 assert!(
1263 !rendered.contains('\\'),
1264 "backslashes must be normalized: {rendered}"
1265 );
1266 }
1267
1268 #[test]
1269 fn format_display_path_handles_brackets_verbatim() {
1270 let root = Path::new("/project");
1271 let path = Path::new("/project/app/[slug]/page.tsx");
1272 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1273 }
1274
1275 #[test]
1276 fn format_display_path_path_equals_root_returns_empty() {
1277 let root = Path::new("/project");
1278 let path = Path::new("/project");
1279 assert_eq!(format_display_path(path, root), "");
1280 }
1281
1282 #[test]
1283 fn format_display_path_basename_only_when_path_is_at_root() {
1284 let root = Path::new("/project");
1285 let path = Path::new("/project/Cargo.toml");
1286 assert_eq!(format_display_path(path, root), "Cargo.toml");
1287 }
1288
1289 #[test]
1290 fn relative_uri_produces_forward_slash_path() {
1291 let root = PathBuf::from("/project");
1292 let path = root.join("src").join("utils.ts");
1293 let uri = relative_uri(&path, &root);
1294 assert_eq!(uri, "src/utils.ts");
1295 }
1296
1297 #[test]
1298 fn relative_uri_encodes_brackets() {
1299 let root = PathBuf::from("/project");
1300 let path = root.join("src/app/[...slug]/page.tsx");
1301 let uri = relative_uri(&path, &root);
1302 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1303 }
1304
1305 #[test]
1306 fn relative_uri_encodes_nested_dynamic_routes() {
1307 let root = PathBuf::from("/project");
1308 let path = root.join("src/app/[slug]/[id]/page.tsx");
1309 let uri = relative_uri(&path, &root);
1310 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1311 }
1312
1313 #[test]
1314 fn relative_uri_no_common_prefix_returns_full() {
1315 let root = PathBuf::from("/other");
1316 let path = PathBuf::from("/project/src/utils.ts");
1317 let uri = relative_uri(&path, &root);
1318 assert!(uri.contains("project"));
1319 assert!(uri.contains("utils.ts"));
1320 }
1321
1322 #[test]
1323 fn severity_error_maps_to_level_error() {
1324 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1325 }
1326
1327 #[test]
1328 fn severity_warn_maps_to_level_warn() {
1329 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1330 }
1331
1332 #[test]
1333 fn severity_off_maps_to_level_info() {
1334 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1335 }
1336
1337 #[test]
1338 fn normalize_uri_single_bracket_pair() {
1339 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1340 }
1341
1342 #[test]
1343 fn normalize_uri_catch_all_route() {
1344 assert_eq!(
1345 normalize_uri("app/[...slug]/page.tsx"),
1346 "app/%5B...slug%5D/page.tsx"
1347 );
1348 }
1349
1350 #[test]
1351 fn normalize_uri_optional_catch_all_route() {
1352 assert_eq!(
1353 normalize_uri("app/[[...slug]]/page.tsx"),
1354 "app/%5B%5B...slug%5D%5D/page.tsx"
1355 );
1356 }
1357
1358 #[test]
1359 fn normalize_uri_multiple_dynamic_segments() {
1360 assert_eq!(
1361 normalize_uri("app/[lang]/posts/[id]"),
1362 "app/%5Blang%5D/posts/%5Bid%5D"
1363 );
1364 }
1365
1366 #[test]
1367 fn normalize_uri_no_special_chars() {
1368 let plain = "src/components/Button.tsx";
1369 assert_eq!(normalize_uri(plain), plain);
1370 }
1371
1372 #[test]
1373 fn normalize_uri_only_backslashes() {
1374 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1375 }
1376
1377 #[test]
1378 fn relative_path_identical_paths_returns_empty() {
1379 let root = Path::new("/project");
1380 assert_eq!(relative_path(root, root), Path::new(""));
1381 }
1382
1383 #[test]
1384 fn relative_path_partial_name_match_not_stripped() {
1385 let root = Path::new("/project");
1386 let path = Path::new("/project-two/src/a.ts");
1387 assert_eq!(relative_path(path, root), path);
1388 }
1389
1390 #[test]
1391 fn relative_uri_combines_stripping_and_encoding() {
1392 let root = PathBuf::from("/project");
1393 let path = root.join("src/app/[slug]/page.tsx");
1394 let uri = relative_uri(&path, &root);
1395 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1396 assert!(!uri.starts_with('/'));
1397 }
1398
1399 #[test]
1400 fn relative_uri_at_root_file() {
1401 let root = PathBuf::from("/project");
1402 let path = root.join("index.ts");
1403 assert_eq!(relative_uri(&path, &root), "index.ts");
1404 }
1405
1406 #[test]
1407 fn severity_to_level_is_const_evaluable() {
1408 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1409 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1410 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1411 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1412 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1413 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1414 }
1415
1416 #[test]
1417 fn level_is_copy() {
1418 let level = severity_to_level(Severity::Error);
1419 let copy = level;
1420 assert!(matches!(level, Level::Error));
1421 assert!(matches!(copy, Level::Error));
1422 }
1423
1424 #[test]
1425 fn print_results_rejects_badge_for_dead_code_reports() {
1426 let root = Path::new("/project");
1427 let rules = RulesConfig::default();
1428 let ctx = test_context(root, &rules);
1429
1430 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1431
1432 assert_eq!(code, ExitCode::from(2));
1433 }
1434
1435 #[test]
1436 fn print_duplication_report_rejects_badge_format() {
1437 let root = Path::new("/project");
1438 let rules = RulesConfig::default();
1439 let ctx = test_context(root, &rules);
1440
1441 let code =
1442 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1443
1444 assert_eq!(code, ExitCode::from(2));
1445 }
1446
1447 #[test]
1448 fn elide_common_prefix_shared_dir() {
1449 assert_eq!(
1450 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1451 "B.tsx"
1452 );
1453 }
1454
1455 #[test]
1456 fn elide_common_prefix_partial_shared() {
1457 assert_eq!(
1458 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1459 "utils/B.tsx"
1460 );
1461 }
1462
1463 #[test]
1464 fn elide_common_prefix_no_shared() {
1465 assert_eq!(
1466 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1467 "pkg-b/src/B.tsx"
1468 );
1469 }
1470
1471 #[test]
1472 fn elide_common_prefix_identical_files() {
1473 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1474 }
1475
1476 #[test]
1477 fn elide_common_prefix_no_dirs() {
1478 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1479 }
1480
1481 #[test]
1482 fn elide_common_prefix_deep_monorepo() {
1483 assert_eq!(
1484 elide_common_prefix(
1485 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1486 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1487 ),
1488 "SearchSelectItem.tsx"
1489 );
1490 }
1491
1492 #[test]
1493 fn split_dir_filename_with_dir() {
1494 let (dir, file) = split_dir_filename("src/utils/index.ts");
1495 assert_eq!(dir, "src/utils/");
1496 assert_eq!(file, "index.ts");
1497 }
1498
1499 #[test]
1500 fn split_dir_filename_no_dir() {
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 split_dir_filename_deeply_nested() {
1508 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1509 assert_eq!(dir, "a/b/c/d/");
1510 assert_eq!(file, "e.ts");
1511 }
1512
1513 #[test]
1514 fn split_dir_filename_trailing_slash() {
1515 let (dir, file) = split_dir_filename("src/");
1516 assert_eq!(dir, "src/");
1517 assert_eq!(file, "");
1518 }
1519
1520 #[test]
1521 fn split_dir_filename_empty() {
1522 let (dir, file) = split_dir_filename("");
1523 assert_eq!(dir, "");
1524 assert_eq!(file, "");
1525 }
1526
1527 #[test]
1528 fn plural_zero_is_plural() {
1529 assert_eq!(plural(0), "s");
1530 }
1531
1532 #[test]
1533 fn plural_one_is_singular() {
1534 assert_eq!(plural(1), "");
1535 }
1536
1537 #[test]
1538 fn plural_two_is_plural() {
1539 assert_eq!(plural(2), "s");
1540 }
1541
1542 #[test]
1543 fn plural_large_number() {
1544 assert_eq!(plural(999), "s");
1545 }
1546
1547 #[test]
1548 fn elide_common_prefix_empty_base() {
1549 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1550 }
1551
1552 #[test]
1553 fn elide_common_prefix_empty_target() {
1554 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1555 }
1556
1557 #[test]
1558 fn elide_common_prefix_both_empty() {
1559 assert_eq!(elide_common_prefix("", ""), "");
1560 }
1561
1562 #[test]
1563 fn elide_common_prefix_same_file_different_extension() {
1564 assert_eq!(
1565 elide_common_prefix("src/utils.ts", "src/utils.js"),
1566 "utils.js"
1567 );
1568 }
1569
1570 #[test]
1571 fn elide_common_prefix_partial_filename_match_not_stripped() {
1572 assert_eq!(
1573 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1574 "AppUtils.tsx"
1575 );
1576 }
1577
1578 #[test]
1579 fn elide_common_prefix_identical_paths() {
1580 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1581 }
1582
1583 #[test]
1584 fn split_dir_filename_single_slash() {
1585 let (dir, file) = split_dir_filename("/file.ts");
1586 assert_eq!(dir, "/");
1587 assert_eq!(file, "file.ts");
1588 }
1589
1590 #[test]
1591 fn emit_json_returns_success_for_valid_value() {
1592 let value = serde_json::json!({"key": "value"});
1593 let code = emit_json(&value, "test");
1594 assert_eq!(code, ExitCode::SUCCESS);
1595 }
1596
1597 mod proptests {
1598 use super::*;
1599 use proptest::prelude::*;
1600
1601 proptest! {
1602 #[test]
1604 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1605 let (dir, file) = split_dir_filename(&path);
1606 let reconstructed = format!("{dir}{file}");
1607 prop_assert_eq!(
1608 reconstructed, path,
1609 "dir+file should reconstruct the original path"
1610 );
1611 }
1612
1613 #[test]
1615 fn plural_returns_empty_or_s(n: usize) {
1616 let result = plural(n);
1617 prop_assert!(
1618 result.is_empty() || result == "s",
1619 "plural should return \"\" or \"s\", got {:?}",
1620 result
1621 );
1622 }
1623
1624 #[test]
1626 fn plural_singular_only_for_one(n: usize) {
1627 let result = plural(n);
1628 if n == 1 {
1629 prop_assert_eq!(result, "", "plural(1) should be empty");
1630 } else {
1631 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1632 }
1633 }
1634
1635 #[test]
1637 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1638 let result = normalize_uri(&path);
1639 prop_assert!(
1640 !result.contains('\\'),
1641 "Result should not contain backslashes: {result}"
1642 );
1643 }
1644
1645 #[test]
1647 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1648 let result = normalize_uri(&path);
1649 prop_assert!(
1650 !result.contains('[') && !result.contains(']'),
1651 "Result should not contain raw brackets: {result}"
1652 );
1653 }
1654
1655 #[test]
1657 fn elide_common_prefix_returns_suffix_of_target(
1658 base in "[a-zA-Z0-9_./]{0,50}",
1659 target in "[a-zA-Z0-9_./]{0,50}",
1660 ) {
1661 let result = elide_common_prefix(&base, &target);
1662 prop_assert!(
1663 target.ends_with(result),
1664 "Result {:?} should be a suffix of target {:?}",
1665 result, target
1666 );
1667 }
1668
1669 #[test]
1671 fn relative_path_never_panics(
1672 root in "/[a-zA-Z0-9_/]{0,30}",
1673 suffix in "[a-zA-Z0-9_./]{0,30}",
1674 ) {
1675 let root_path = Path::new(&root);
1676 let full = PathBuf::from(format!("{root}/{suffix}"));
1677 let _ = relative_path(&full, root_path);
1678 }
1679 }
1680 }
1681}