1mod badge;
2pub mod ci;
3mod codeclimate;
4mod compact;
5pub mod dupes_grouping;
6pub mod grouping;
7mod human;
8mod json;
9mod markdown;
10mod sarif;
11mod shared;
12#[cfg(test)]
13pub mod test_helpers;
14
15use std::path::Path;
16use std::process::ExitCode;
17use std::time::Duration;
18
19use fallow_config::{OutputFormat, RulesConfig, Severity};
20use fallow_core::duplicates::DuplicationReport;
21use fallow_core::results::AnalysisResults;
22use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
23
24pub use grouping::OwnershipResolver;
25#[allow(
26 unused_imports,
27 reason = "used by binary crate modules (combined.rs, audit.rs)"
28)]
29pub use json::strip_root_prefix;
30pub use human::health::{render_health_score, render_health_trend};
33
34pub struct ReportContext<'a> {
39 pub root: &'a Path,
40 pub rules: &'a RulesConfig,
41 pub elapsed: Duration,
42 pub quiet: bool,
43 pub explain: bool,
44 pub group_by: Option<OwnershipResolver>,
46 pub top: Option<usize>,
48 pub summary: bool,
50 pub summary_heading: bool,
54 pub show_explain_tip: bool,
56 pub baseline_matched: Option<(usize, usize)>,
58 pub config_fixable: bool,
63 pub skip_score_and_trend: bool,
68}
69
70#[must_use]
72pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
73 path.strip_prefix(root).unwrap_or(path)
74}
75
76#[must_use]
86pub fn format_display_path(path: &Path, root: &Path) -> String {
87 relative_path(path, root)
88 .display()
89 .to_string()
90 .replace('\\', "/")
91}
92
93#[must_use]
96pub fn split_dir_filename(path: &str) -> (&str, &str) {
97 path.rfind('/')
98 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
99}
100
101#[must_use]
103pub const fn plural(n: usize) -> &'static str {
104 if n == 1 { "" } else { "s" }
105}
106
107#[must_use]
112pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
113 match serde_json::to_string_pretty(value) {
114 Ok(json) => {
115 println!("{json}");
116 ExitCode::SUCCESS
117 }
118 Err(e) => {
119 eprintln!("Error: failed to serialize {kind} output: {e}");
120 ExitCode::from(2)
121 }
122 }
123}
124
125#[must_use]
131pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
132 let mut last_sep = 0;
133 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
134 if a != b {
135 break;
136 }
137 if a == b'/' {
138 last_sep = i + 1;
139 }
140 }
141 if last_sep > 0 && last_sep <= target.len() {
142 &target[last_sep..]
143 } else {
144 target
145 }
146}
147
148fn relative_uri(path: &Path, root: &Path) -> String {
150 normalize_uri(&relative_path(path, root).display().to_string())
151}
152
153#[must_use]
158pub fn normalize_uri(path_str: &str) -> String {
159 path_str
160 .replace('\\', "/")
161 .replace('[', "%5B")
162 .replace(']', "%5D")
163}
164
165#[derive(Clone, Copy, Debug)]
167pub enum Level {
168 Warn,
169 Info,
170 Error,
171}
172
173#[must_use]
174pub const fn severity_to_level(s: Severity) -> Level {
175 match s {
176 Severity::Error => Level::Error,
177 Severity::Warn => Level::Warn,
178 Severity::Off => Level::Info,
180 }
181}
182
183#[must_use]
189pub fn print_results(
190 results: &AnalysisResults,
191 ctx: &ReportContext<'_>,
192 output: OutputFormat,
193 regression: Option<&crate::regression::RegressionOutcome>,
194) -> ExitCode {
195 if let Some(ref resolver) = ctx.group_by {
197 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
198 return print_grouped_results(&groups, results, ctx, output, resolver);
199 }
200
201 match output {
202 OutputFormat::Human => {
203 if ctx.summary {
204 human::check::print_check_summary(
205 results,
206 ctx.rules,
207 ctx.elapsed,
208 ctx.quiet,
209 ctx.summary_heading,
210 );
211 } else {
212 human::print_human(
213 results,
214 ctx.root,
215 ctx.rules,
216 ctx.elapsed,
217 ctx.quiet,
218 ctx.top,
219 ctx.show_explain_tip,
220 ctx.explain,
221 );
222 }
223 ExitCode::SUCCESS
224 }
225 OutputFormat::Json => json::print_json(
226 results,
227 ctx.root,
228 ctx.elapsed,
229 ctx.explain,
230 regression,
231 ctx.baseline_matched,
232 ctx.config_fixable,
233 ),
234 OutputFormat::Compact => {
235 compact::print_compact(results, ctx.root);
236 ExitCode::SUCCESS
237 }
238 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
239 OutputFormat::Markdown => {
240 markdown::print_markdown(results, ctx.root);
241 ExitCode::SUCCESS
242 }
243 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
244 OutputFormat::PrCommentGithub => {
245 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
246 let value = codeclimate::issues_to_value(&issues);
247 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
248 }
249 OutputFormat::PrCommentGitlab => {
250 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
251 let value = codeclimate::issues_to_value(&issues);
252 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
253 }
254 OutputFormat::ReviewGithub => {
255 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
256 let value = codeclimate::issues_to_value(&issues);
257 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
258 }
259 OutputFormat::ReviewGitlab => {
260 let issues = codeclimate::build_codeclimate(results, ctx.root, ctx.rules);
261 let value = codeclimate::issues_to_value(&issues);
262 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
263 }
264 OutputFormat::Badge => {
265 eprintln!("Error: badge format is only supported for the health command");
266 ExitCode::from(2)
267 }
268 }
269}
270
271#[must_use]
273fn print_grouped_results(
274 groups: &[grouping::ResultGroup],
275 original: &AnalysisResults,
276 ctx: &ReportContext<'_>,
277 output: OutputFormat,
278 resolver: &OwnershipResolver,
279) -> ExitCode {
280 match output {
281 OutputFormat::Human => {
282 human::print_grouped_human(
283 groups,
284 ctx.root,
285 ctx.rules,
286 ctx.elapsed,
287 ctx.quiet,
288 Some(resolver),
289 ctx.explain,
290 );
291 ExitCode::SUCCESS
292 }
293 OutputFormat::Json => json::print_grouped_json(
294 groups,
295 original,
296 ctx.root,
297 ctx.elapsed,
298 ctx.explain,
299 resolver,
300 ctx.config_fixable,
301 ),
302 OutputFormat::Compact => {
303 compact::print_grouped_compact(groups, ctx.root);
304 ExitCode::SUCCESS
305 }
306 OutputFormat::Markdown => {
307 markdown::print_grouped_markdown(groups, ctx.root);
308 ExitCode::SUCCESS
309 }
310 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
311 OutputFormat::CodeClimate => {
312 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
313 }
314 OutputFormat::PrCommentGithub => {
315 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
316 let value = codeclimate::issues_to_value(&issues);
317 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Github, &value)
318 }
319 OutputFormat::PrCommentGitlab => {
320 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
321 let value = codeclimate::issues_to_value(&issues);
322 ci::pr_comment::print_pr_comment("dead-code", ci::pr_comment::Provider::Gitlab, &value)
323 }
324 OutputFormat::ReviewGithub => {
325 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
326 let value = codeclimate::issues_to_value(&issues);
327 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Github, &value)
328 }
329 OutputFormat::ReviewGitlab => {
330 let issues = codeclimate::build_codeclimate(original, ctx.root, ctx.rules);
331 let value = codeclimate::issues_to_value(&issues);
332 ci::review::print_review_envelope("dead-code", ci::pr_comment::Provider::Gitlab, &value)
333 }
334 OutputFormat::Badge => {
335 eprintln!("Error: badge format is only supported for the health command");
336 ExitCode::from(2)
337 }
338 }
339}
340
341#[must_use]
345pub fn print_duplication_report(
346 report: &DuplicationReport,
347 ctx: &ReportContext<'_>,
348 output: OutputFormat,
349) -> ExitCode {
350 if let Some(ref resolver) = ctx.group_by {
354 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
355 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
356 }
357
358 match output {
359 OutputFormat::Human => {
360 if ctx.summary {
361 human::dupes::print_duplication_summary(
362 report,
363 ctx.elapsed,
364 ctx.quiet,
365 ctx.summary_heading,
366 );
367 } else {
368 human::print_duplication_human(
369 report,
370 ctx.root,
371 ctx.elapsed,
372 ctx.quiet,
373 ctx.show_explain_tip,
374 ctx.explain,
375 );
376 }
377 ExitCode::SUCCESS
378 }
379 OutputFormat::Json => {
380 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
381 }
382 OutputFormat::Compact => {
383 compact::print_duplication_compact(report, ctx.root);
384 ExitCode::SUCCESS
385 }
386 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
387 OutputFormat::Markdown => {
388 markdown::print_duplication_markdown(report, ctx.root);
389 ExitCode::SUCCESS
390 }
391 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
392 OutputFormat::PrCommentGithub => {
393 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
394 let value = codeclimate::issues_to_value(&issues);
395 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
396 }
397 OutputFormat::PrCommentGitlab => {
398 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
399 let value = codeclimate::issues_to_value(&issues);
400 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
401 }
402 OutputFormat::ReviewGithub => {
403 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
404 let value = codeclimate::issues_to_value(&issues);
405 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
406 }
407 OutputFormat::ReviewGitlab => {
408 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
409 let value = codeclimate::issues_to_value(&issues);
410 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
411 }
412 OutputFormat::Badge => {
413 eprintln!("Error: badge format is only supported for the health command");
414 ExitCode::from(2)
415 }
416 }
417}
418
419#[must_use]
421fn print_grouped_duplication_report(
422 report: &DuplicationReport,
423 grouping: &dupes_grouping::DuplicationGrouping,
424 ctx: &ReportContext<'_>,
425 output: OutputFormat,
426 resolver: &OwnershipResolver,
427) -> ExitCode {
428 match output {
429 OutputFormat::Human => {
430 human::print_grouped_duplication_human(
431 report,
432 grouping,
433 ctx.root,
434 ctx.elapsed,
435 ctx.quiet,
436 );
437 ExitCode::SUCCESS
438 }
439 OutputFormat::Json => json::print_grouped_duplication_json(
440 report,
441 grouping,
442 ctx.root,
443 ctx.elapsed,
444 ctx.explain,
445 ),
446 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
447 OutputFormat::CodeClimate => {
448 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
449 }
450 OutputFormat::PrCommentGithub => {
451 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
452 let value = codeclimate::issues_to_value(&issues);
453 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Github, &value)
454 }
455 OutputFormat::PrCommentGitlab => {
456 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
457 let value = codeclimate::issues_to_value(&issues);
458 ci::pr_comment::print_pr_comment("dupes", ci::pr_comment::Provider::Gitlab, &value)
459 }
460 OutputFormat::ReviewGithub => {
461 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
462 let value = codeclimate::issues_to_value(&issues);
463 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Github, &value)
464 }
465 OutputFormat::ReviewGitlab => {
466 let issues = codeclimate::build_duplication_codeclimate(report, ctx.root);
467 let value = codeclimate::issues_to_value(&issues);
468 ci::review::print_review_envelope("dupes", ci::pr_comment::Provider::Gitlab, &value)
469 }
470 OutputFormat::Compact => {
471 compact::print_duplication_compact(report, ctx.root);
472 warn_dupes_grouping_unsupported(grouping, "compact");
473 ExitCode::SUCCESS
474 }
475 OutputFormat::Markdown => {
476 markdown::print_duplication_markdown(report, ctx.root);
477 warn_dupes_grouping_unsupported(grouping, "markdown");
478 ExitCode::SUCCESS
479 }
480 OutputFormat::Badge => {
481 eprintln!("Error: badge format is only supported for the health command");
482 ExitCode::from(2)
483 }
484 }
485}
486
487fn warn_dupes_grouping_unsupported(grouping: &dupes_grouping::DuplicationGrouping, format: &str) {
488 eprintln!(
489 "note: --group-by {} is not supported for {format} duplication output, falling back to \
490 ungrouped output (use --format json for the full grouped envelope)",
491 grouping.mode
492 );
493}
494
495#[must_use]
514pub fn print_health_report(
515 report: &crate::health_types::HealthReport,
516 grouping: Option<&crate::health_types::HealthGrouping>,
517 group_resolver: Option<&grouping::OwnershipResolver>,
518 ctx: &ReportContext<'_>,
519 output: OutputFormat,
520) -> ExitCode {
521 match output {
522 OutputFormat::Human => {
523 if ctx.summary {
524 human::health::print_health_summary(
525 report,
526 ctx.elapsed,
527 ctx.quiet,
528 ctx.summary_heading,
529 );
530 } else {
531 human::print_health_human(
532 report,
533 ctx.root,
534 ctx.elapsed,
535 ctx.quiet,
536 ctx.show_explain_tip,
537 ctx.explain,
538 ctx.skip_score_and_trend,
539 );
540 if let Some(grouping) = grouping {
541 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
542 }
543 }
544 ExitCode::SUCCESS
545 }
546 OutputFormat::Compact => {
547 compact::print_health_compact(report, ctx.root);
548 warn_grouping_unsupported(grouping, "compact");
549 ExitCode::SUCCESS
550 }
551 OutputFormat::Markdown => {
552 markdown::print_health_markdown(report, ctx.root);
553 warn_grouping_unsupported(grouping, "markdown");
554 ExitCode::SUCCESS
555 }
556 OutputFormat::Sarif => match group_resolver {
557 Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
558 None => sarif::print_health_sarif(report, ctx.root),
559 },
560 OutputFormat::Json => match grouping {
561 Some(grouping) => json::print_grouped_health_json(
562 report,
563 grouping,
564 ctx.root,
565 ctx.elapsed,
566 ctx.explain,
567 ),
568 None => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
569 },
570 OutputFormat::CodeClimate => match group_resolver {
571 Some(resolver) => {
572 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
573 }
574 None => codeclimate::print_health_codeclimate(report, ctx.root),
575 },
576 OutputFormat::PrCommentGithub => {
577 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
578 let value = codeclimate::issues_to_value(&issues);
579 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Github, &value)
580 }
581 OutputFormat::PrCommentGitlab => {
582 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
583 let value = codeclimate::issues_to_value(&issues);
584 ci::pr_comment::print_pr_comment("health", ci::pr_comment::Provider::Gitlab, &value)
585 }
586 OutputFormat::ReviewGithub => {
587 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
588 let value = codeclimate::issues_to_value(&issues);
589 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Github, &value)
590 }
591 OutputFormat::ReviewGitlab => {
592 let issues = codeclimate::build_health_codeclimate(report, ctx.root);
593 let value = codeclimate::issues_to_value(&issues);
594 ci::review::print_review_envelope("health", ci::pr_comment::Provider::Gitlab, &value)
595 }
596 OutputFormat::Badge => {
597 warn_grouping_unsupported(grouping, "badge");
598 badge::print_health_badge(report)
599 }
600 }
601}
602
603fn warn_grouping_unsupported(grouping: Option<&crate::health_types::HealthGrouping>, format: &str) {
604 if let Some(g) = grouping {
605 eprintln!(
606 "note: --group-by {} is not supported for {format} output, falling back to \
607 ungrouped output (use --format json for the full grouped envelope)",
608 g.mode
609 );
610 }
611}
612
613pub fn print_cross_reference_findings(
617 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
618 root: &Path,
619 quiet: bool,
620 output: OutputFormat,
621) {
622 human::print_cross_reference_findings(cross_ref, root, quiet, output);
623}
624
625pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
629 match format {
630 OutputFormat::Json => json::print_trace_json(trace),
631 _ => human::print_export_trace_human(trace),
632 }
633}
634
635pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
637 match format {
638 OutputFormat::Json => json::print_trace_json(trace),
639 _ => human::print_file_trace_human(trace),
640 }
641}
642
643pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
645 match format {
646 OutputFormat::Json => json::print_trace_json(trace),
647 _ => human::print_dependency_trace_human(trace),
648 }
649}
650
651pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
653 match format {
654 OutputFormat::Json => json::print_trace_json(trace),
655 _ => human::print_clone_trace_human(trace, root),
656 }
657}
658
659pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
662 match format {
663 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
664 Ok(json) => eprintln!("{json}"),
665 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
666 },
667 _ => human::print_performance_human(timings),
668 }
669}
670
671pub fn print_health_performance(
674 timings: &crate::health_types::HealthTimings,
675 format: OutputFormat,
676) {
677 match format {
678 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
679 Ok(json) => eprintln!("{json}"),
680 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
681 },
682 _ => human::print_health_performance_human(timings),
683 }
684}
685
686#[allow(
689 unused_imports,
690 reason = "target-dependent: used in lib, unused in bin"
691)]
692pub use codeclimate::build_codeclimate;
693#[allow(
694 unused_imports,
695 reason = "target-dependent: used in lib, unused in bin"
696)]
697pub use codeclimate::build_duplication_codeclimate;
698#[allow(
699 unused_imports,
700 reason = "target-dependent: used in lib, unused in bin"
701)]
702pub use codeclimate::build_health_codeclimate;
703#[allow(
704 unused_imports,
705 reason = "target-dependent: used in lib, unused in bin"
706)]
707pub use codeclimate::issues_to_value as codeclimate_issues_to_value;
708#[allow(
709 unused_imports,
710 reason = "target-dependent: used in lib, unused in bin"
711)]
712pub use compact::build_compact_lines;
713#[allow(
714 clippy::redundant_pub_crate,
715 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
716)]
717pub(crate) use json::SCHEMA_VERSION;
718pub use json::build_baseline_deltas_json;
719#[allow(
720 unused_imports,
721 reason = "target-dependent: used in lib, unused in bin"
722)]
723pub use json::build_duplication_json;
724#[allow(
725 unused_imports,
726 reason = "target-dependent: used in lib, unused in bin"
727)]
728pub use json::build_grouped_duplication_json;
729#[allow(
730 unused_imports,
731 reason = "target-dependent: used in lib, unused in bin"
732)]
733pub use json::build_health_json;
734#[allow(
735 unused_imports,
736 reason = "target-dependent: used in bin audit.rs, unused in lib"
737)]
738#[allow(
739 clippy::redundant_pub_crate,
740 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
741)]
742pub(crate) use json::harmonize_multi_kind_suppress_line_actions;
743#[allow(
744 unused_imports,
745 reason = "target-dependent: used in lib, unused in bin"
746)]
747pub use json::{build_json, build_json_with_config_fixable};
748#[allow(
749 unused_imports,
750 reason = "target-dependent: used in lib, unused in bin"
751)]
752pub use markdown::build_duplication_markdown;
753#[allow(
754 unused_imports,
755 reason = "target-dependent: used in lib, unused in bin"
756)]
757pub use markdown::build_health_markdown;
758#[allow(
759 unused_imports,
760 reason = "target-dependent: used in lib, unused in bin"
761)]
762pub use markdown::build_markdown;
763#[allow(
764 unused_imports,
765 reason = "target-dependent: used in lib, unused in bin"
766)]
767pub use sarif::build_health_sarif;
768#[allow(
769 unused_imports,
770 reason = "target-dependent: used in lib, unused in bin"
771)]
772pub use sarif::build_sarif;
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use std::path::PathBuf;
778
779 #[test]
782 fn normalize_uri_forward_slashes_unchanged() {
783 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
784 }
785
786 #[test]
787 fn normalize_uri_backslashes_replaced() {
788 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
789 }
790
791 #[test]
792 fn normalize_uri_mixed_slashes() {
793 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
794 }
795
796 #[test]
797 fn normalize_uri_path_with_spaces() {
798 assert_eq!(
799 normalize_uri("src\\my folder\\file.ts"),
800 "src/my folder/file.ts"
801 );
802 }
803
804 #[test]
805 fn normalize_uri_empty_string() {
806 assert_eq!(normalize_uri(""), "");
807 }
808
809 #[test]
812 fn relative_path_strips_root_prefix() {
813 let root = Path::new("/project");
814 let path = Path::new("/project/src/utils.ts");
815 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
816 }
817
818 #[test]
819 fn relative_path_returns_full_path_when_no_prefix() {
820 let root = Path::new("/other");
821 let path = Path::new("/project/src/utils.ts");
822 assert_eq!(relative_path(path, root), path);
823 }
824
825 #[test]
826 fn relative_path_at_root_returns_empty_or_file() {
827 let root = Path::new("/project");
828 let path = Path::new("/project/file.ts");
829 assert_eq!(relative_path(path, root), Path::new("file.ts"));
830 }
831
832 #[test]
833 fn relative_path_deeply_nested() {
834 let root = Path::new("/project");
835 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
836 assert_eq!(
837 relative_path(path, root),
838 Path::new("packages/ui/src/components/Button.tsx")
839 );
840 }
841
842 #[test]
845 fn format_display_path_returns_workspace_relative() {
846 let root = Path::new("/project");
847 let path = Path::new("/project/apps/server/src/index.ts");
848 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
849 }
850
851 #[test]
852 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
853 let root = Path::new("/project");
857 let server = Path::new("/project/apps/server/src/index.ts");
858 let client = Path::new("/project/apps/client/src/index.ts");
859 assert_eq!(
860 format_display_path(server, root),
861 "apps/server/src/index.ts"
862 );
863 assert_eq!(
864 format_display_path(client, root),
865 "apps/client/src/index.ts"
866 );
867 }
868
869 #[test]
870 fn format_display_path_angular_component_renders_parent_directory() {
871 let root = Path::new("/project");
872 let path = Path::new(
873 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
874 );
875 assert_eq!(
876 format_display_path(path, root),
877 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
878 );
879 }
880
881 #[test]
882 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
883 let root = Path::new("/other");
886 let path = Path::new("/project/src/utils.ts");
887 let rendered = format_display_path(path, root);
888 assert!(rendered.contains("project"));
889 assert!(rendered.ends_with("utils.ts"));
890 assert!(!rendered.contains('\\'));
891 }
892
893 #[test]
894 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
895 let root = Path::new("/project");
900 let path = Path::new("/project/src/sub\\file.ts");
901 let rendered = format_display_path(path, root);
902 assert!(
903 !rendered.contains('\\'),
904 "backslashes must be normalized: {rendered}"
905 );
906 }
907
908 #[test]
909 fn format_display_path_handles_brackets_verbatim() {
910 let root = Path::new("/project");
913 let path = Path::new("/project/app/[slug]/page.tsx");
914 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
915 }
916
917 #[test]
918 fn format_display_path_path_equals_root_returns_empty() {
919 let root = Path::new("/project");
920 let path = Path::new("/project");
921 assert_eq!(format_display_path(path, root), "");
922 }
923
924 #[test]
925 fn format_display_path_basename_only_when_path_is_at_root() {
926 let root = Path::new("/project");
927 let path = Path::new("/project/Cargo.toml");
928 assert_eq!(format_display_path(path, root), "Cargo.toml");
929 }
930
931 #[test]
934 fn relative_uri_produces_forward_slash_path() {
935 let root = PathBuf::from("/project");
936 let path = root.join("src").join("utils.ts");
937 let uri = relative_uri(&path, &root);
938 assert_eq!(uri, "src/utils.ts");
939 }
940
941 #[test]
942 fn relative_uri_encodes_brackets() {
943 let root = PathBuf::from("/project");
944 let path = root.join("src/app/[...slug]/page.tsx");
945 let uri = relative_uri(&path, &root);
946 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
947 }
948
949 #[test]
950 fn relative_uri_encodes_nested_dynamic_routes() {
951 let root = PathBuf::from("/project");
952 let path = root.join("src/app/[slug]/[id]/page.tsx");
953 let uri = relative_uri(&path, &root);
954 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
955 }
956
957 #[test]
958 fn relative_uri_no_common_prefix_returns_full() {
959 let root = PathBuf::from("/other");
960 let path = PathBuf::from("/project/src/utils.ts");
961 let uri = relative_uri(&path, &root);
962 assert!(uri.contains("project"));
963 assert!(uri.contains("utils.ts"));
964 }
965
966 #[test]
969 fn severity_error_maps_to_level_error() {
970 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
971 }
972
973 #[test]
974 fn severity_warn_maps_to_level_warn() {
975 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
976 }
977
978 #[test]
979 fn severity_off_maps_to_level_info() {
980 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
981 }
982
983 #[test]
986 fn normalize_uri_single_bracket_pair() {
987 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
988 }
989
990 #[test]
991 fn normalize_uri_catch_all_route() {
992 assert_eq!(
993 normalize_uri("app/[...slug]/page.tsx"),
994 "app/%5B...slug%5D/page.tsx"
995 );
996 }
997
998 #[test]
999 fn normalize_uri_optional_catch_all_route() {
1000 assert_eq!(
1001 normalize_uri("app/[[...slug]]/page.tsx"),
1002 "app/%5B%5B...slug%5D%5D/page.tsx"
1003 );
1004 }
1005
1006 #[test]
1007 fn normalize_uri_multiple_dynamic_segments() {
1008 assert_eq!(
1009 normalize_uri("app/[lang]/posts/[id]"),
1010 "app/%5Blang%5D/posts/%5Bid%5D"
1011 );
1012 }
1013
1014 #[test]
1015 fn normalize_uri_no_special_chars() {
1016 let plain = "src/components/Button.tsx";
1017 assert_eq!(normalize_uri(plain), plain);
1018 }
1019
1020 #[test]
1021 fn normalize_uri_only_backslashes() {
1022 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1023 }
1024
1025 #[test]
1028 fn relative_path_identical_paths_returns_empty() {
1029 let root = Path::new("/project");
1030 assert_eq!(relative_path(root, root), Path::new(""));
1031 }
1032
1033 #[test]
1034 fn relative_path_partial_name_match_not_stripped() {
1035 let root = Path::new("/project");
1038 let path = Path::new("/project-two/src/a.ts");
1039 assert_eq!(relative_path(path, root), path);
1040 }
1041
1042 #[test]
1045 fn relative_uri_combines_stripping_and_encoding() {
1046 let root = PathBuf::from("/project");
1047 let path = root.join("src/app/[slug]/page.tsx");
1048 let uri = relative_uri(&path, &root);
1049 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1051 assert!(!uri.starts_with('/'));
1052 }
1053
1054 #[test]
1055 fn relative_uri_at_root_file() {
1056 let root = PathBuf::from("/project");
1057 let path = root.join("index.ts");
1058 assert_eq!(relative_uri(&path, &root), "index.ts");
1059 }
1060
1061 #[test]
1064 fn severity_to_level_is_const_evaluable() {
1065 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1067 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1068 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1069 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1070 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1071 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1072 }
1073
1074 #[test]
1077 fn level_is_copy() {
1078 let level = severity_to_level(Severity::Error);
1079 let copy = level;
1080 assert!(matches!(level, Level::Error));
1082 assert!(matches!(copy, Level::Error));
1083 }
1084
1085 #[test]
1088 fn elide_common_prefix_shared_dir() {
1089 assert_eq!(
1090 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1091 "B.tsx"
1092 );
1093 }
1094
1095 #[test]
1096 fn elide_common_prefix_partial_shared() {
1097 assert_eq!(
1098 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1099 "utils/B.tsx"
1100 );
1101 }
1102
1103 #[test]
1104 fn elide_common_prefix_no_shared() {
1105 assert_eq!(
1106 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1107 "pkg-b/src/B.tsx"
1108 );
1109 }
1110
1111 #[test]
1112 fn elide_common_prefix_identical_files() {
1113 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1115 }
1116
1117 #[test]
1118 fn elide_common_prefix_no_dirs() {
1119 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1120 }
1121
1122 #[test]
1123 fn elide_common_prefix_deep_monorepo() {
1124 assert_eq!(
1125 elide_common_prefix(
1126 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1127 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1128 ),
1129 "SearchSelectItem.tsx"
1130 );
1131 }
1132
1133 #[test]
1136 fn split_dir_filename_with_dir() {
1137 let (dir, file) = split_dir_filename("src/utils/index.ts");
1138 assert_eq!(dir, "src/utils/");
1139 assert_eq!(file, "index.ts");
1140 }
1141
1142 #[test]
1143 fn split_dir_filename_no_dir() {
1144 let (dir, file) = split_dir_filename("file.ts");
1145 assert_eq!(dir, "");
1146 assert_eq!(file, "file.ts");
1147 }
1148
1149 #[test]
1150 fn split_dir_filename_deeply_nested() {
1151 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1152 assert_eq!(dir, "a/b/c/d/");
1153 assert_eq!(file, "e.ts");
1154 }
1155
1156 #[test]
1157 fn split_dir_filename_trailing_slash() {
1158 let (dir, file) = split_dir_filename("src/");
1159 assert_eq!(dir, "src/");
1160 assert_eq!(file, "");
1161 }
1162
1163 #[test]
1164 fn split_dir_filename_empty() {
1165 let (dir, file) = split_dir_filename("");
1166 assert_eq!(dir, "");
1167 assert_eq!(file, "");
1168 }
1169
1170 #[test]
1173 fn plural_zero_is_plural() {
1174 assert_eq!(plural(0), "s");
1175 }
1176
1177 #[test]
1178 fn plural_one_is_singular() {
1179 assert_eq!(plural(1), "");
1180 }
1181
1182 #[test]
1183 fn plural_two_is_plural() {
1184 assert_eq!(plural(2), "s");
1185 }
1186
1187 #[test]
1188 fn plural_large_number() {
1189 assert_eq!(plural(999), "s");
1190 }
1191
1192 #[test]
1195 fn elide_common_prefix_empty_base() {
1196 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1197 }
1198
1199 #[test]
1200 fn elide_common_prefix_empty_target() {
1201 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1202 }
1203
1204 #[test]
1205 fn elide_common_prefix_both_empty() {
1206 assert_eq!(elide_common_prefix("", ""), "");
1207 }
1208
1209 #[test]
1210 fn elide_common_prefix_same_file_different_extension() {
1211 assert_eq!(
1213 elide_common_prefix("src/utils.ts", "src/utils.js"),
1214 "utils.js"
1215 );
1216 }
1217
1218 #[test]
1219 fn elide_common_prefix_partial_filename_match_not_stripped() {
1220 assert_eq!(
1222 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1223 "AppUtils.tsx"
1224 );
1225 }
1226
1227 #[test]
1228 fn elide_common_prefix_identical_paths() {
1229 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1230 }
1231
1232 #[test]
1233 fn split_dir_filename_single_slash() {
1234 let (dir, file) = split_dir_filename("/file.ts");
1235 assert_eq!(dir, "/");
1236 assert_eq!(file, "file.ts");
1237 }
1238
1239 #[test]
1240 fn emit_json_returns_success_for_valid_value() {
1241 let value = serde_json::json!({"key": "value"});
1242 let code = emit_json(&value, "test");
1243 assert_eq!(code, ExitCode::SUCCESS);
1244 }
1245
1246 mod proptests {
1247 use super::*;
1248 use proptest::prelude::*;
1249
1250 proptest! {
1251 #[test]
1253 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1254 let (dir, file) = split_dir_filename(&path);
1255 let reconstructed = format!("{dir}{file}");
1256 prop_assert_eq!(
1257 reconstructed, path,
1258 "dir+file should reconstruct the original path"
1259 );
1260 }
1261
1262 #[test]
1264 fn plural_returns_empty_or_s(n: usize) {
1265 let result = plural(n);
1266 prop_assert!(
1267 result.is_empty() || result == "s",
1268 "plural should return \"\" or \"s\", got {:?}",
1269 result
1270 );
1271 }
1272
1273 #[test]
1275 fn plural_singular_only_for_one(n: usize) {
1276 let result = plural(n);
1277 if n == 1 {
1278 prop_assert_eq!(result, "", "plural(1) should be empty");
1279 } else {
1280 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1281 }
1282 }
1283
1284 #[test]
1286 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1287 let result = normalize_uri(&path);
1288 prop_assert!(
1289 !result.contains('\\'),
1290 "Result should not contain backslashes: {result}"
1291 );
1292 }
1293
1294 #[test]
1296 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1297 let result = normalize_uri(&path);
1298 prop_assert!(
1299 !result.contains('[') && !result.contains(']'),
1300 "Result should not contain raw brackets: {result}"
1301 );
1302 }
1303
1304 #[test]
1306 fn elide_common_prefix_returns_suffix_of_target(
1307 base in "[a-zA-Z0-9_./]{0,50}",
1308 target in "[a-zA-Z0-9_./]{0,50}",
1309 ) {
1310 let result = elide_common_prefix(&base, &target);
1311 prop_assert!(
1312 target.ends_with(result),
1313 "Result {:?} should be a suffix of target {:?}",
1314 result, target
1315 );
1316 }
1317
1318 #[test]
1320 fn relative_path_never_panics(
1321 root in "/[a-zA-Z0-9_/]{0,30}",
1322 suffix in "[a-zA-Z0-9_./]{0,30}",
1323 ) {
1324 let root_path = Path::new(&root);
1325 let full = PathBuf::from(format!("{root}/{suffix}"));
1326 let _ = relative_path(&full, root_path);
1327 }
1328 }
1329 }
1330}