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