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