Skip to main content

fallow_cli/report/
mod.rs

1mod badge;
2mod codeclimate;
3mod compact;
4pub mod grouping;
5mod human;
6mod json;
7mod markdown;
8mod sarif;
9#[cfg(test)]
10mod test_helpers;
11
12use std::path::Path;
13use std::process::ExitCode;
14use std::time::Duration;
15
16use fallow_config::{OutputFormat, RulesConfig, Severity};
17use fallow_core::duplicates::DuplicationReport;
18use fallow_core::results::AnalysisResults;
19use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
20
21pub use grouping::OwnershipResolver;
22
23/// Shared context for all report dispatch functions.
24///
25/// Bundles the common parameters that every format renderer needs,
26/// replacing per-parameter threading through the dispatch match arms.
27pub struct ReportContext<'a> {
28    pub root: &'a Path,
29    pub rules: &'a RulesConfig,
30    pub elapsed: Duration,
31    pub quiet: bool,
32    pub explain: bool,
33    /// When set, group all output by this resolver.
34    pub group_by: Option<OwnershipResolver>,
35}
36
37/// Strip the project root prefix from a path for display, falling back to the full path.
38#[must_use]
39pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
40    path.strip_prefix(root).unwrap_or(path)
41}
42
43/// Split a path string into (directory, filename) for display.
44/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
45#[must_use]
46pub fn split_dir_filename(path: &str) -> (&str, &str) {
47    path.rfind('/')
48        .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
49}
50
51/// Return `"s"` for plural or `""` for singular.
52#[must_use]
53pub const fn plural(n: usize) -> &'static str {
54    if n == 1 { "" } else { "s" }
55}
56
57/// Serialize a JSON value to pretty-printed stdout, returning the appropriate exit code.
58///
59/// On success prints the JSON and returns `ExitCode::SUCCESS`.
60/// On serialization failure prints an error to stderr and returns exit code 2.
61#[must_use]
62pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
63    match serde_json::to_string_pretty(value) {
64        Ok(json) => {
65            println!("{json}");
66            ExitCode::SUCCESS
67        }
68        Err(e) => {
69            eprintln!("Error: failed to serialize {kind} output: {e}");
70            ExitCode::from(2)
71        }
72    }
73}
74
75/// Elide the common directory prefix between a base path and a target path.
76/// Only strips complete directory segments (never partial filenames).
77/// Returns the remaining suffix of `target`.
78///
79/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
80#[must_use]
81pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
82    let mut last_sep = 0;
83    for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
84        if a != b {
85            break;
86        }
87        if a == b'/' {
88            last_sep = i + 1;
89        }
90    }
91    if last_sep > 0 && last_sep <= target.len() {
92        &target[last_sep..]
93    } else {
94        target
95    }
96}
97
98/// Compute a SARIF-compatible relative URI from an absolute path and project root.
99fn relative_uri(path: &Path, root: &Path) -> String {
100    normalize_uri(&relative_path(path, root).display().to_string())
101}
102
103/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
104///
105/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
106/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
107#[must_use]
108pub fn normalize_uri(path_str: &str) -> String {
109    path_str
110        .replace('\\', "/")
111        .replace('[', "%5B")
112        .replace(']', "%5D")
113}
114
115/// Severity level for human-readable output.
116#[derive(Clone, Copy, Debug)]
117pub enum Level {
118    Warn,
119    Info,
120    Error,
121}
122
123#[must_use]
124pub const fn severity_to_level(s: Severity) -> Level {
125    match s {
126        Severity::Error => Level::Error,
127        Severity::Warn => Level::Warn,
128        // Off issues are filtered before reporting; fall back to Info.
129        Severity::Off => Level::Info,
130    }
131}
132
133/// Print analysis results in the configured format.
134/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
135///
136/// When `regression` is `Some`, the JSON format includes a `regression` key in the output envelope.
137/// When `ctx.group_by` is `Some`, results are partitioned into labeled groups before rendering.
138#[must_use]
139pub fn print_results(
140    results: &AnalysisResults,
141    ctx: &ReportContext<'_>,
142    output: OutputFormat,
143    regression: Option<&crate::regression::RegressionOutcome>,
144) -> ExitCode {
145    // Grouped output: partition results and render per-group
146    if let Some(ref resolver) = ctx.group_by {
147        let groups = grouping::group_analysis_results(results, ctx.root, resolver);
148        return print_grouped_results(&groups, results, ctx, output, resolver);
149    }
150
151    match output {
152        OutputFormat::Human => {
153            human::print_human(results, ctx.root, ctx.rules, ctx.elapsed, ctx.quiet);
154            ExitCode::SUCCESS
155        }
156        OutputFormat::Json => {
157            json::print_json(results, ctx.root, ctx.elapsed, ctx.explain, regression)
158        }
159        OutputFormat::Compact => {
160            compact::print_compact(results, ctx.root);
161            ExitCode::SUCCESS
162        }
163        OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
164        OutputFormat::Markdown => {
165            markdown::print_markdown(results, ctx.root);
166            ExitCode::SUCCESS
167        }
168        OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
169        OutputFormat::Badge => {
170            eprintln!("Error: badge format is only supported for the health command");
171            ExitCode::from(2)
172        }
173    }
174}
175
176/// Render grouped results across all output formats.
177#[must_use]
178fn print_grouped_results(
179    groups: &[grouping::ResultGroup],
180    original: &AnalysisResults,
181    ctx: &ReportContext<'_>,
182    output: OutputFormat,
183    resolver: &OwnershipResolver,
184) -> ExitCode {
185    match output {
186        OutputFormat::Human => {
187            human::print_grouped_human(
188                groups,
189                ctx.root,
190                ctx.rules,
191                ctx.elapsed,
192                ctx.quiet,
193                Some(resolver),
194            );
195            ExitCode::SUCCESS
196        }
197        OutputFormat::Json => json::print_grouped_json(
198            groups,
199            original,
200            ctx.root,
201            ctx.elapsed,
202            ctx.explain,
203            resolver,
204        ),
205        OutputFormat::Compact => {
206            compact::print_grouped_compact(groups, ctx.root);
207            ExitCode::SUCCESS
208        }
209        OutputFormat::Markdown => {
210            markdown::print_grouped_markdown(groups, ctx.root);
211            ExitCode::SUCCESS
212        }
213        OutputFormat::Sarif => {
214            sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver);
215            ExitCode::SUCCESS
216        }
217        OutputFormat::CodeClimate => {
218            codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver);
219            ExitCode::SUCCESS
220        }
221        OutputFormat::Badge => {
222            eprintln!("Error: badge format is only supported for the health command");
223            ExitCode::from(2)
224        }
225    }
226}
227
228// ── Duplication report ────────────────────────────────────────────
229
230/// Print duplication analysis results in the configured format.
231#[must_use]
232pub fn print_duplication_report(
233    report: &DuplicationReport,
234    ctx: &ReportContext<'_>,
235    output: OutputFormat,
236) -> ExitCode {
237    match output {
238        OutputFormat::Human => {
239            human::print_duplication_human(report, ctx.root, ctx.elapsed, ctx.quiet);
240            ExitCode::SUCCESS
241        }
242        OutputFormat::Json => json::print_duplication_json(report, ctx.elapsed, ctx.explain),
243        OutputFormat::Compact => {
244            compact::print_duplication_compact(report, ctx.root);
245            ExitCode::SUCCESS
246        }
247        OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
248        OutputFormat::Markdown => {
249            markdown::print_duplication_markdown(report, ctx.root);
250            ExitCode::SUCCESS
251        }
252        OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
253        OutputFormat::Badge => {
254            eprintln!("Error: badge format is only supported for the health command");
255            ExitCode::from(2)
256        }
257    }
258}
259
260// ── Health / complexity report ─────────────────────────────────────
261
262/// Print health (complexity) analysis results in the configured format.
263#[must_use]
264pub fn print_health_report(
265    report: &crate::health_types::HealthReport,
266    ctx: &ReportContext<'_>,
267    output: OutputFormat,
268) -> ExitCode {
269    match output {
270        OutputFormat::Human => {
271            human::print_health_human(report, ctx.root, ctx.elapsed, ctx.quiet);
272            ExitCode::SUCCESS
273        }
274        OutputFormat::Compact => {
275            compact::print_health_compact(report, ctx.root);
276            ExitCode::SUCCESS
277        }
278        OutputFormat::Markdown => {
279            markdown::print_health_markdown(report, ctx.root);
280            ExitCode::SUCCESS
281        }
282        OutputFormat::Sarif => sarif::print_health_sarif(report, ctx.root),
283        OutputFormat::Json => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
284        OutputFormat::CodeClimate => codeclimate::print_health_codeclimate(report, ctx.root),
285        OutputFormat::Badge => badge::print_health_badge(report),
286    }
287}
288
289/// Print cross-reference findings (duplicated code that is also dead code).
290///
291/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
292pub fn print_cross_reference_findings(
293    cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
294    root: &Path,
295    quiet: bool,
296    output: OutputFormat,
297) {
298    human::print_cross_reference_findings(cross_ref, root, quiet, output);
299}
300
301// ── Trace output ──────────────────────────────────────────────────
302
303/// Print export trace results.
304pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
305    match format {
306        OutputFormat::Json => json::print_trace_json(trace),
307        _ => human::print_export_trace_human(trace),
308    }
309}
310
311/// Print file trace results.
312pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
313    match format {
314        OutputFormat::Json => json::print_trace_json(trace),
315        _ => human::print_file_trace_human(trace),
316    }
317}
318
319/// Print dependency trace results.
320pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
321    match format {
322        OutputFormat::Json => json::print_trace_json(trace),
323        _ => human::print_dependency_trace_human(trace),
324    }
325}
326
327/// Print clone trace results.
328pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
329    match format {
330        OutputFormat::Json => json::print_trace_json(trace),
331        _ => human::print_clone_trace_human(trace, root),
332    }
333}
334
335/// Print pipeline performance timings.
336/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
337pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
338    match format {
339        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
340            Ok(json) => eprintln!("{json}"),
341            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
342        },
343        _ => human::print_performance_human(timings),
344    }
345}
346
347// Re-exported for snapshot testing via the lib target.
348// Uses #[allow] because unused_imports is target-dependent (used in lib, unused in bin).
349#[allow(
350    unused_imports,
351    reason = "target-dependent: used in lib, unused in bin"
352)]
353pub use codeclimate::build_codeclimate;
354#[allow(
355    unused_imports,
356    reason = "target-dependent: used in lib, unused in bin"
357)]
358pub use codeclimate::build_duplication_codeclimate;
359#[allow(
360    unused_imports,
361    reason = "target-dependent: used in lib, unused in bin"
362)]
363pub use codeclimate::build_health_codeclimate;
364#[allow(
365    unused_imports,
366    reason = "target-dependent: used in lib, unused in bin"
367)]
368pub use compact::build_compact_lines;
369#[allow(
370    unused_imports,
371    reason = "target-dependent: used in lib, unused in bin"
372)]
373pub use json::build_json;
374#[allow(
375    unused_imports,
376    reason = "target-dependent: used in bin audit.rs, unused in lib"
377)]
378#[allow(
379    clippy::redundant_pub_crate,
380    reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
381)]
382pub(crate) use json::inject_dupes_actions;
383#[allow(
384    unused_imports,
385    reason = "target-dependent: used in bin audit.rs, unused in lib"
386)]
387#[allow(
388    clippy::redundant_pub_crate,
389    reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
390)]
391pub(crate) use json::inject_health_actions;
392#[allow(
393    unused_imports,
394    reason = "target-dependent: used in lib, unused in bin"
395)]
396pub use markdown::build_duplication_markdown;
397#[allow(
398    unused_imports,
399    reason = "target-dependent: used in lib, unused in bin"
400)]
401pub use markdown::build_health_markdown;
402#[allow(
403    unused_imports,
404    reason = "target-dependent: used in lib, unused in bin"
405)]
406pub use markdown::build_markdown;
407#[allow(
408    unused_imports,
409    reason = "target-dependent: used in lib, unused in bin"
410)]
411pub use sarif::build_health_sarif;
412#[allow(
413    unused_imports,
414    reason = "target-dependent: used in lib, unused in bin"
415)]
416pub use sarif::build_sarif;
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::path::PathBuf;
422
423    // ── normalize_uri ────────────────────────────────────────────────
424
425    #[test]
426    fn normalize_uri_forward_slashes_unchanged() {
427        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
428    }
429
430    #[test]
431    fn normalize_uri_backslashes_replaced() {
432        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
433    }
434
435    #[test]
436    fn normalize_uri_mixed_slashes() {
437        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
438    }
439
440    #[test]
441    fn normalize_uri_path_with_spaces() {
442        assert_eq!(
443            normalize_uri("src\\my folder\\file.ts"),
444            "src/my folder/file.ts"
445        );
446    }
447
448    #[test]
449    fn normalize_uri_empty_string() {
450        assert_eq!(normalize_uri(""), "");
451    }
452
453    // ── relative_path ────────────────────────────────────────────────
454
455    #[test]
456    fn relative_path_strips_root_prefix() {
457        let root = Path::new("/project");
458        let path = Path::new("/project/src/utils.ts");
459        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
460    }
461
462    #[test]
463    fn relative_path_returns_full_path_when_no_prefix() {
464        let root = Path::new("/other");
465        let path = Path::new("/project/src/utils.ts");
466        assert_eq!(relative_path(path, root), path);
467    }
468
469    #[test]
470    fn relative_path_at_root_returns_empty_or_file() {
471        let root = Path::new("/project");
472        let path = Path::new("/project/file.ts");
473        assert_eq!(relative_path(path, root), Path::new("file.ts"));
474    }
475
476    #[test]
477    fn relative_path_deeply_nested() {
478        let root = Path::new("/project");
479        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
480        assert_eq!(
481            relative_path(path, root),
482            Path::new("packages/ui/src/components/Button.tsx")
483        );
484    }
485
486    // ── relative_uri ─────────────────────────────────────────────────
487
488    #[test]
489    fn relative_uri_produces_forward_slash_path() {
490        let root = PathBuf::from("/project");
491        let path = root.join("src").join("utils.ts");
492        let uri = relative_uri(&path, &root);
493        assert_eq!(uri, "src/utils.ts");
494    }
495
496    #[test]
497    fn relative_uri_encodes_brackets() {
498        let root = PathBuf::from("/project");
499        let path = root.join("src/app/[...slug]/page.tsx");
500        let uri = relative_uri(&path, &root);
501        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
502    }
503
504    #[test]
505    fn relative_uri_encodes_nested_dynamic_routes() {
506        let root = PathBuf::from("/project");
507        let path = root.join("src/app/[slug]/[id]/page.tsx");
508        let uri = relative_uri(&path, &root);
509        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
510    }
511
512    #[test]
513    fn relative_uri_no_common_prefix_returns_full() {
514        let root = PathBuf::from("/other");
515        let path = PathBuf::from("/project/src/utils.ts");
516        let uri = relative_uri(&path, &root);
517        assert!(uri.contains("project"));
518        assert!(uri.contains("utils.ts"));
519    }
520
521    // ── severity_to_level ────────────────────────────────────────────
522
523    #[test]
524    fn severity_error_maps_to_level_error() {
525        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
526    }
527
528    #[test]
529    fn severity_warn_maps_to_level_warn() {
530        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
531    }
532
533    #[test]
534    fn severity_off_maps_to_level_info() {
535        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
536    }
537
538    // ── normalize_uri bracket encoding ──────────────────────────────
539
540    #[test]
541    fn normalize_uri_single_bracket_pair() {
542        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
543    }
544
545    #[test]
546    fn normalize_uri_catch_all_route() {
547        assert_eq!(
548            normalize_uri("app/[...slug]/page.tsx"),
549            "app/%5B...slug%5D/page.tsx"
550        );
551    }
552
553    #[test]
554    fn normalize_uri_optional_catch_all_route() {
555        assert_eq!(
556            normalize_uri("app/[[...slug]]/page.tsx"),
557            "app/%5B%5B...slug%5D%5D/page.tsx"
558        );
559    }
560
561    #[test]
562    fn normalize_uri_multiple_dynamic_segments() {
563        assert_eq!(
564            normalize_uri("app/[lang]/posts/[id]"),
565            "app/%5Blang%5D/posts/%5Bid%5D"
566        );
567    }
568
569    #[test]
570    fn normalize_uri_no_special_chars() {
571        let plain = "src/components/Button.tsx";
572        assert_eq!(normalize_uri(plain), plain);
573    }
574
575    #[test]
576    fn normalize_uri_only_backslashes() {
577        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
578    }
579
580    // ── relative_path edge cases ────────────────────────────────────
581
582    #[test]
583    fn relative_path_identical_paths_returns_empty() {
584        let root = Path::new("/project");
585        assert_eq!(relative_path(root, root), Path::new(""));
586    }
587
588    #[test]
589    fn relative_path_partial_name_match_not_stripped() {
590        // "/project-two/src/a.ts" should NOT strip "/project" because
591        // "/project" is not a proper prefix of "/project-two".
592        let root = Path::new("/project");
593        let path = Path::new("/project-two/src/a.ts");
594        assert_eq!(relative_path(path, root), path);
595    }
596
597    // ── relative_uri edge cases ─────────────────────────────────────
598
599    #[test]
600    fn relative_uri_combines_stripping_and_encoding() {
601        let root = PathBuf::from("/project");
602        let path = root.join("src/app/[slug]/page.tsx");
603        let uri = relative_uri(&path, &root);
604        // Should both strip the prefix AND encode brackets.
605        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
606        assert!(!uri.starts_with('/'));
607    }
608
609    #[test]
610    fn relative_uri_at_root_file() {
611        let root = PathBuf::from("/project");
612        let path = root.join("index.ts");
613        assert_eq!(relative_uri(&path, &root), "index.ts");
614    }
615
616    // ── severity_to_level exhaustiveness ────────────────────────────
617
618    #[test]
619    fn severity_to_level_is_const_evaluable() {
620        // Verify the function can be used in const context.
621        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
622        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
623        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
624        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
625        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
626        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
627    }
628
629    // ── Level is Copy ───────────────────────────────────────────────
630
631    #[test]
632    fn level_is_copy() {
633        let level = severity_to_level(Severity::Error);
634        let copy = level;
635        // Both should still be usable (Copy semantics).
636        assert!(matches!(level, Level::Error));
637        assert!(matches!(copy, Level::Error));
638    }
639
640    // ── elide_common_prefix ─────────────────────────────────────────
641
642    #[test]
643    fn elide_common_prefix_shared_dir() {
644        assert_eq!(
645            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
646            "B.tsx"
647        );
648    }
649
650    #[test]
651    fn elide_common_prefix_partial_shared() {
652        assert_eq!(
653            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
654            "utils/B.tsx"
655        );
656    }
657
658    #[test]
659    fn elide_common_prefix_no_shared() {
660        assert_eq!(
661            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
662            "pkg-b/src/B.tsx"
663        );
664    }
665
666    #[test]
667    fn elide_common_prefix_identical_files() {
668        // Same dir, different file
669        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
670    }
671
672    #[test]
673    fn elide_common_prefix_no_dirs() {
674        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
675    }
676
677    #[test]
678    fn elide_common_prefix_deep_monorepo() {
679        assert_eq!(
680            elide_common_prefix(
681                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
682                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
683            ),
684            "SearchSelectItem.tsx"
685        );
686    }
687
688    // ── split_dir_filename ───────────────────────────────────────
689
690    #[test]
691    fn split_dir_filename_with_dir() {
692        let (dir, file) = split_dir_filename("src/utils/index.ts");
693        assert_eq!(dir, "src/utils/");
694        assert_eq!(file, "index.ts");
695    }
696
697    #[test]
698    fn split_dir_filename_no_dir() {
699        let (dir, file) = split_dir_filename("file.ts");
700        assert_eq!(dir, "");
701        assert_eq!(file, "file.ts");
702    }
703
704    #[test]
705    fn split_dir_filename_deeply_nested() {
706        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
707        assert_eq!(dir, "a/b/c/d/");
708        assert_eq!(file, "e.ts");
709    }
710
711    #[test]
712    fn split_dir_filename_trailing_slash() {
713        let (dir, file) = split_dir_filename("src/");
714        assert_eq!(dir, "src/");
715        assert_eq!(file, "");
716    }
717
718    #[test]
719    fn split_dir_filename_empty() {
720        let (dir, file) = split_dir_filename("");
721        assert_eq!(dir, "");
722        assert_eq!(file, "");
723    }
724
725    // ── plural ──────────────────────────────────────────────────
726
727    #[test]
728    fn plural_zero_is_plural() {
729        assert_eq!(plural(0), "s");
730    }
731
732    #[test]
733    fn plural_one_is_singular() {
734        assert_eq!(plural(1), "");
735    }
736
737    #[test]
738    fn plural_two_is_plural() {
739        assert_eq!(plural(2), "s");
740    }
741
742    #[test]
743    fn plural_large_number() {
744        assert_eq!(plural(999), "s");
745    }
746
747    // ── elide_common_prefix edge cases ──────────────────────────
748
749    #[test]
750    fn elide_common_prefix_empty_base() {
751        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
752    }
753
754    #[test]
755    fn elide_common_prefix_empty_target() {
756        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
757    }
758
759    #[test]
760    fn elide_common_prefix_both_empty() {
761        assert_eq!(elide_common_prefix("", ""), "");
762    }
763
764    #[test]
765    fn elide_common_prefix_same_file_different_extension() {
766        // "src/utils.ts" vs "src/utils.js" — common prefix is "src/"
767        assert_eq!(
768            elide_common_prefix("src/utils.ts", "src/utils.js"),
769            "utils.js"
770        );
771    }
772
773    #[test]
774    fn elide_common_prefix_partial_filename_match_not_stripped() {
775        // "src/App.tsx" vs "src/AppUtils.tsx" — both in src/, but file names differ
776        assert_eq!(
777            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
778            "AppUtils.tsx"
779        );
780    }
781
782    #[test]
783    fn elide_common_prefix_identical_paths() {
784        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
785    }
786
787    #[test]
788    fn split_dir_filename_single_slash() {
789        let (dir, file) = split_dir_filename("/file.ts");
790        assert_eq!(dir, "/");
791        assert_eq!(file, "file.ts");
792    }
793
794    #[test]
795    fn emit_json_returns_success_for_valid_value() {
796        let value = serde_json::json!({"key": "value"});
797        let code = emit_json(&value, "test");
798        assert_eq!(code, ExitCode::SUCCESS);
799    }
800
801    mod proptests {
802        use super::*;
803        use proptest::prelude::*;
804
805        proptest! {
806            /// split_dir_filename always reconstructs the original path.
807            #[test]
808            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
809                let (dir, file) = split_dir_filename(&path);
810                let reconstructed = format!("{dir}{file}");
811                prop_assert_eq!(
812                    reconstructed, path,
813                    "dir+file should reconstruct the original path"
814                );
815            }
816
817            /// plural returns either "" or "s", nothing else.
818            #[test]
819            fn plural_returns_empty_or_s(n: usize) {
820                let result = plural(n);
821                prop_assert!(
822                    result.is_empty() || result == "s",
823                    "plural should return \"\" or \"s\", got {:?}",
824                    result
825                );
826            }
827
828            /// plural(1) is always "" and plural(n != 1) is always "s".
829            #[test]
830            fn plural_singular_only_for_one(n: usize) {
831                let result = plural(n);
832                if n == 1 {
833                    prop_assert_eq!(result, "", "plural(1) should be empty");
834                } else {
835                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
836                }
837            }
838
839            /// normalize_uri never panics and always replaces backslashes.
840            #[test]
841            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
842                let result = normalize_uri(&path);
843                prop_assert!(
844                    !result.contains('\\'),
845                    "Result should not contain backslashes: {result}"
846                );
847            }
848
849            /// normalize_uri always encodes brackets.
850            #[test]
851            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
852                let result = normalize_uri(&path);
853                prop_assert!(
854                    !result.contains('[') && !result.contains(']'),
855                    "Result should not contain raw brackets: {result}"
856                );
857            }
858
859            /// elide_common_prefix always returns a suffix of or equal to target.
860            #[test]
861            fn elide_common_prefix_returns_suffix_of_target(
862                base in "[a-zA-Z0-9_./]{0,50}",
863                target in "[a-zA-Z0-9_./]{0,50}",
864            ) {
865                let result = elide_common_prefix(&base, &target);
866                prop_assert!(
867                    target.ends_with(result),
868                    "Result {:?} should be a suffix of target {:?}",
869                    result, target
870                );
871            }
872
873            /// relative_path never panics.
874            #[test]
875            fn relative_path_never_panics(
876                root in "/[a-zA-Z0-9_/]{0,30}",
877                suffix in "[a-zA-Z0-9_./]{0,30}",
878            ) {
879                let root_path = Path::new(&root);
880                let full = PathBuf::from(format!("{root}/{suffix}"));
881                let _ = relative_path(&full, root_path);
882            }
883        }
884    }
885}