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