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