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