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