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