Skip to main content

fallow_cli/report/
mod.rs

1mod badge;
2pub mod ci;
3pub mod codeclimate;
4mod compact;
5pub mod dupes_grouping;
6pub mod github;
7pub mod github_annotations;
8pub mod github_summary;
9pub mod grouping;
10mod human;
11mod json;
12mod markdown;
13mod sarif;
14mod shared;
15pub mod sink;
16pub mod suggestions;
17#[cfg(test)]
18pub mod test_helpers;
19
20use std::path::Path;
21use std::process::ExitCode;
22use std::time::Duration;
23
24use fallow_api::DuplicationGrouping;
25use fallow_config::{OutputFormat, RulesConfig, Severity};
26use fallow_types::duplicates::DuplicationReport;
27use fallow_types::results::AnalysisResults;
28use fallow_types::trace::{
29    CloneTrace, DependencyTrace, ExportTrace, FileTrace, ImpactClosureTrace, PipelineTimings,
30};
31
32use crate::report::sink::outln;
33
34#[allow(
35    unused_imports,
36    reason = "used by binary crate modules (combined.rs, audit.rs)"
37)]
38pub use fallow_output::strip_root_prefix;
39pub use grouping::OwnershipResolver;
40pub use human::health::{render_health_score, render_health_trend};
41
42/// The three line-groups of a human `fallow review --walkthrough` render: the
43/// orientation header and final status (stderr), and the staged tour body
44/// (stdout). The entry point in `audit_brief.rs` owns the stream split; this
45/// keeps the pure line builder behind the private `human` module while exposing
46/// exactly what the entry point needs.
47pub struct WalkthroughHumanRender {
48    /// Review Focus orientation header lines (stderr).
49    pub header: Vec<String>,
50    /// The staged tour body lines (stdout).
51    pub body: Vec<String>,
52    /// The final green status line (stderr).
53    pub status: String,
54}
55
56/// The root-relative files (in `direction.order`) the local ledger marked viewed
57/// against the guide's current hash. Exposed so the markdown surface can collapse
58/// the same viewed files into Cleared that the human surface does, keeping the two
59/// formats consistent on the same on-disk `--mark-viewed` state.
60#[must_use]
61pub fn walkthrough_viewed_files(
62    guide: &fallow_output::StandardWalkthroughGuide,
63    viewed: &crate::walkthrough_state::ViewedState,
64) -> Vec<String> {
65    human::walkthrough::viewed_files_for(guide, viewed)
66}
67
68/// Build the human walkthrough tour from the in-memory guide. Pure: no IO, no
69/// mutation. `viewed` decorates each file row; `show_cleared` expands the
70/// Cleared panel.
71#[must_use]
72pub fn build_walkthrough_human(
73    guide: &fallow_output::StandardWalkthroughGuide,
74    viewed: &crate::walkthrough_state::ViewedState,
75    show_cleared: bool,
76) -> WalkthroughHumanRender {
77    let input = human::walkthrough::WalkthroughHumanInput {
78        guide,
79        viewed,
80        show_cleared,
81    };
82    WalkthroughHumanRender {
83        header: human::walkthrough::build_focus_header(guide, viewed),
84        body: human::walkthrough::build_walkthrough_human_lines(&input),
85        status: human::walkthrough::build_status_line(guide, viewed),
86    }
87}
88
89/// Shared context for all report dispatch functions.
90///
91/// Bundles the common parameters that every format renderer needs,
92/// replacing per-parameter threading through the dispatch match arms.
93pub struct ReportContext<'a> {
94    pub root: &'a Path,
95    pub rules: &'a RulesConfig,
96    pub elapsed: Duration,
97    pub quiet: bool,
98    pub explain: bool,
99    /// When set, group all output by this resolver.
100    pub group_by: Option<OwnershipResolver>,
101    /// Limit displayed items per section (--top N).
102    pub top: Option<usize>,
103    /// When set, print a concise summary instead of the full report.
104    pub summary: bool,
105    /// Human-only: print the summary renderer's own title line. Combined mode
106    /// already prints section headers, so it disables this to avoid duplicate
107    /// "Dead Code" / "Dead Code Summary" headings.
108    pub summary_heading: bool,
109    /// Human-only: print a one-line hint pointing at `fallow explain`.
110    pub show_explain_tip: bool,
111    /// When a baseline was loaded: (total entries in baseline, entries that matched).
112    pub baseline_matched: Option<(usize, usize)>,
113    /// Whether config-edit actions can be applied by `fallow fix`.
114    ///
115    /// This is caller-provided because an explicit `--config` path is fixable
116    /// even when default config discovery from the root would find nothing.
117    pub config_fixable: bool,
118    /// When set, the human health renderer skips the `● Health score:` and
119    /// trend table sections because they have already been rendered upstream
120    /// (combined-mode orientation header). Standalone `fallow health` keeps
121    /// the default `false` and renders both sections inline.
122    pub skip_score_and_trend: bool,
123    /// Human-only: whether `--css` was requested. When `true` but no stylesheet
124    /// was import-reachable, the CSS-health section renders an explanatory note
125    /// instead of being silently omitted. Defaults `false` for non-css callers.
126    pub css_requested: bool,
127}
128
129/// Strip the project root prefix from a path for display, falling back to the full path.
130#[must_use]
131pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
132    path.strip_prefix(root).unwrap_or(path)
133}
134
135/// Format a path for human-facing display: project-relative when the path is
136/// under `root`, falling back to the full path otherwise. Always
137/// forward-slash-normalized so Windows backslashes do not leak into
138/// terminal output.
139///
140/// Use this for any human-output site that today renders bare `file_name()`,
141/// since bare basenames are ambiguous in Nx / Angular / Rust-workspace layouts
142/// where many files share names like `index.ts`, `mod.rs`, or
143/// `*.component.ts`. See issue #547.
144#[must_use]
145pub fn format_display_path(path: &Path, root: &Path) -> String {
146    relative_path(path, root)
147        .display()
148        .to_string()
149        .replace('\\', "/")
150}
151
152/// Split a path string into (directory, filename) for display.
153/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
154#[must_use]
155pub fn split_dir_filename(path: &str) -> (&str, &str) {
156    path.rfind('/')
157        .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
158}
159
160/// Return `"s"` for plural or `""` for singular.
161#[must_use]
162pub const fn plural(n: usize) -> &'static str {
163    if n == 1 { "" } else { "s" }
164}
165
166/// Serialize a JSON value to pretty-printed stdout, returning the appropriate exit code.
167///
168/// On success prints the JSON and returns `ExitCode::SUCCESS`.
169/// On serialization failure prints an error to stderr and returns exit code 2.
170#[must_use]
171pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
172    match serde_json::to_string_pretty(value) {
173        Ok(json) => {
174            outln!("{json}");
175            ExitCode::SUCCESS
176        }
177        Err(e) => {
178            eprintln!("Error: failed to serialize {kind} output: {e}");
179            ExitCode::from(2)
180        }
181    }
182}
183
184/// Elide the common directory prefix between a base path and a target path.
185/// Only strips complete directory segments (never partial filenames).
186/// Returns the remaining suffix of `target`.
187///
188/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
189#[must_use]
190pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
191    let mut last_sep = 0;
192    for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
193        if a != b {
194            break;
195        }
196        if a == b'/' {
197            last_sep = i + 1;
198        }
199    }
200    if last_sep > 0 && last_sep <= target.len() {
201        &target[last_sep..]
202    } else {
203        target
204    }
205}
206
207/// Compute a SARIF-compatible relative URI from an absolute path and project root.
208#[cfg(test)]
209fn relative_uri(path: &Path, root: &Path) -> String {
210    normalize_uri(&relative_path(path, root).display().to_string())
211}
212
213/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
214///
215/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
216/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
217#[must_use]
218pub fn normalize_uri(path_str: &str) -> String {
219    fallow_output::normalize_uri(path_str)
220}
221
222/// Severity level for human-readable output.
223#[derive(Clone, Copy, Debug)]
224pub enum Level {
225    Warn,
226    Info,
227    Error,
228}
229
230#[must_use]
231pub const fn severity_to_level(s: Severity) -> Level {
232    match s {
233        Severity::Error => Level::Error,
234        Severity::Warn => Level::Warn,
235        Severity::Off => Level::Info,
236    }
237}
238
239/// Print analysis results in the configured format.
240/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
241///
242/// When `regression` is `Some`, the JSON format includes a `regression` key in the output envelope.
243/// When `ctx.group_by` is `Some`, results are partitioned into labeled groups before rendering.
244#[must_use]
245pub fn print_results(
246    results: &AnalysisResults,
247    ctx: &ReportContext<'_>,
248    output: OutputFormat,
249    regression: Option<&crate::regression::RegressionOutcome>,
250) -> ExitCode {
251    if let Some(ref resolver) = ctx.group_by {
252        let groups = grouping::group_analysis_results(results, ctx.root, resolver);
253        return print_grouped_results(&groups, results, ctx, output, resolver);
254    }
255
256    match output {
257        OutputFormat::Human => {
258            if ctx.summary {
259                human::check::print_check_summary(
260                    results,
261                    ctx.rules,
262                    ctx.elapsed,
263                    ctx.quiet,
264                    ctx.summary_heading,
265                );
266            } else {
267                human::print_human(&human::PrintHumanInput {
268                    results,
269                    root: ctx.root,
270                    rules: ctx.rules,
271                    elapsed: ctx.elapsed,
272                    quiet: ctx.quiet,
273                    top: ctx.top,
274                    show_explain_tip: ctx.show_explain_tip,
275                    explain: ctx.explain,
276                });
277            }
278            ExitCode::SUCCESS
279        }
280        OutputFormat::Json => json::print_json(&json::PrintJsonInput {
281            results,
282            root: ctx.root,
283            elapsed: ctx.elapsed,
284            explain: ctx.explain,
285            regression,
286            baseline_matched: ctx.baseline_matched,
287            config_fixable: ctx.config_fixable,
288        }),
289        OutputFormat::Compact => {
290            compact::print_compact(results, ctx.root);
291            ExitCode::SUCCESS
292        }
293        OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
294        OutputFormat::Markdown => {
295            markdown::print_markdown(results, ctx.root);
296            ExitCode::SUCCESS
297        }
298        OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
299        OutputFormat::GithubAnnotations => print_check_github_annotations(results, ctx),
300        OutputFormat::GithubSummary => {
301            print_check_github_format(results, ctx, GithubTarget::Summary)
302        }
303        ci_format => print_results_ci_comment(results, ctx, ci_format),
304    }
305}
306
307/// Which GitHub-native renderer a dispatch arm targets.
308#[derive(Clone, Copy)]
309enum GithubTarget {
310    Annotations,
311    Summary,
312}
313
314fn print_github_format(
315    kind: github_annotations::EnvelopeKind,
316    envelope: &serde_json::Value,
317    root: &Path,
318    target: GithubTarget,
319) -> ExitCode {
320    match target {
321        GithubTarget::Annotations => github_annotations::print_annotations(kind, envelope, root),
322        GithubTarget::Summary => github_summary::print_summary(kind, envelope, root),
323    }
324}
325
326/// Render dead-code results as GitHub workflow-command annotations by
327/// building the same JSON envelope `--format json` serializes and feeding it
328/// to the value-driven renderer (which keeps `fallow report --from` output
329/// byte-identical to the direct format run).
330fn print_check_github_annotations(results: &AnalysisResults, ctx: &ReportContext<'_>) -> ExitCode {
331    print_check_github_format(results, ctx, GithubTarget::Annotations)
332}
333
334fn print_check_github_format(
335    results: &AnalysisResults,
336    ctx: &ReportContext<'_>,
337    target: GithubTarget,
338) -> ExitCode {
339    match json::api_check_json_document_with_config_fixable_meta_and_extras(
340        results,
341        ctx.root,
342        ctx.elapsed,
343        ctx.config_fixable,
344        None,
345        fallow_api::CheckJsonExtraOutputs::default(),
346    ) {
347        Ok(envelope) => print_github_format(
348            github_annotations::EnvelopeKind::DeadCode,
349            &envelope,
350            ctx.root,
351            target,
352        ),
353        Err(e) => {
354            eprintln!("Error: failed to serialize results: {e}");
355            ExitCode::from(2)
356        }
357    }
358}
359
360/// Render the CI comment / review / badge fallback arms for dead-code results.
361fn print_results_ci_comment(
362    results: &AnalysisResults,
363    ctx: &ReportContext<'_>,
364    output: OutputFormat,
365) -> ExitCode {
366    // Analysis-root-relative on purpose: the review renderer applies the
367    // presentation prefix after its diff lookups, and rebasing here would
368    // prefix twice and key the filter in the wrong namespace.
369    let issues = codeclimate::api_codeclimate_issues(results, ctx.root, ctx.rules);
370    let value = fallow_output::codeclimate_issues_to_value(&issues);
371    print_ci_comment_format("dead-code", &value, output).unwrap_or_else(|| {
372        eprintln!("Error: badge format is only supported for the health command");
373        ExitCode::from(2)
374    })
375}
376
377/// Render grouped results across all output formats.
378#[must_use]
379fn print_grouped_results(
380    groups: &[grouping::ResultGroup],
381    original: &AnalysisResults,
382    ctx: &ReportContext<'_>,
383    output: OutputFormat,
384    resolver: &OwnershipResolver,
385) -> ExitCode {
386    match output {
387        OutputFormat::Human => {
388            human::print_grouped_human(&human::PrintGroupedHumanInput {
389                groups,
390                root: ctx.root,
391                rules: ctx.rules,
392                elapsed: ctx.elapsed,
393                quiet: ctx.quiet,
394                resolver: Some(resolver),
395                explain: ctx.explain,
396            });
397            ExitCode::SUCCESS
398        }
399        OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
400            groups,
401            original,
402            root: ctx.root,
403            elapsed: ctx.elapsed,
404            explain: ctx.explain,
405            resolver,
406            config_fixable: ctx.config_fixable,
407        }),
408        OutputFormat::Compact => {
409            compact::print_grouped_compact(groups, ctx.root);
410            ExitCode::SUCCESS
411        }
412        OutputFormat::Markdown => {
413            markdown::print_grouped_markdown(groups, ctx.root);
414            ExitCode::SUCCESS
415        }
416        OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
417        OutputFormat::CodeClimate => {
418            codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
419        }
420        // The GitHub formats have no grouping concept; render ungrouped from
421        // the original results (same fallback the PR-comment formats use).
422        OutputFormat::GithubAnnotations => print_check_github_annotations(original, ctx),
423        OutputFormat::GithubSummary => {
424            print_check_github_format(original, ctx, GithubTarget::Summary)
425        }
426        ci_format => print_results_ci_comment(original, ctx, ci_format),
427    }
428}
429
430/// Print duplication analysis results in the configured format.
431#[must_use]
432pub fn print_duplication_report(
433    report: &DuplicationReport,
434    ctx: &ReportContext<'_>,
435    output: OutputFormat,
436) -> ExitCode {
437    if let Some(ref resolver) = ctx.group_by {
438        let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
439        return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
440    }
441
442    match output {
443        OutputFormat::Human => {
444            if ctx.summary {
445                human::dupes::print_duplication_summary(
446                    report,
447                    ctx.elapsed,
448                    ctx.quiet,
449                    ctx.summary_heading,
450                );
451            } else {
452                human::print_duplication_human(
453                    report,
454                    ctx.root,
455                    ctx.elapsed,
456                    ctx.quiet,
457                    ctx.show_explain_tip,
458                    ctx.explain,
459                );
460            }
461            ExitCode::SUCCESS
462        }
463        OutputFormat::Json => {
464            json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain)
465        }
466        OutputFormat::Compact => {
467            compact::print_duplication_compact(report, ctx.root);
468            ExitCode::SUCCESS
469        }
470        OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
471        OutputFormat::Markdown => {
472            markdown::print_duplication_markdown(report, ctx.root);
473            ExitCode::SUCCESS
474        }
475        OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
476        OutputFormat::GithubAnnotations => {
477            print_dupes_github_format(report, ctx, GithubTarget::Annotations)
478        }
479        OutputFormat::GithubSummary => {
480            print_dupes_github_format(report, ctx, GithubTarget::Summary)
481        }
482        ci_format => print_duplication_ci_comment(report, ctx.root, ci_format),
483    }
484}
485
486/// Render duplication results in a GitHub-native format from the same JSON
487/// envelope `--format json` serializes.
488fn print_dupes_github_format(
489    report: &DuplicationReport,
490    ctx: &ReportContext<'_>,
491    target: GithubTarget,
492) -> ExitCode {
493    match json::api_duplication_json_document(report, ctx.root, ctx.elapsed, ctx.explain) {
494        Ok(envelope) => print_github_format(
495            github_annotations::EnvelopeKind::Dupes,
496            &envelope,
497            ctx.root,
498            target,
499        ),
500        Err(e) => {
501            eprintln!("Error: failed to serialize duplication report: {e}");
502            ExitCode::from(2)
503        }
504    }
505}
506
507/// Render the CI comment / review / badge fallback arms for duplication results.
508fn print_duplication_ci_comment(
509    report: &DuplicationReport,
510    root: &Path,
511    output: OutputFormat,
512) -> ExitCode {
513    let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
514    let value = fallow_output::codeclimate_issues_to_value(&issues);
515    print_ci_comment_format("dupes", &value, output).unwrap_or_else(|| {
516        eprintln!("Error: badge format is only supported for the health command");
517        ExitCode::from(2)
518    })
519}
520
521/// Render grouped duplication results across all output formats.
522#[must_use]
523fn print_grouped_duplication_report(
524    report: &DuplicationReport,
525    grouping: &DuplicationGrouping,
526    ctx: &ReportContext<'_>,
527    output: OutputFormat,
528    resolver: &OwnershipResolver,
529) -> ExitCode {
530    match output {
531        OutputFormat::Human => {
532            human::print_grouped_duplication_human(
533                report,
534                grouping,
535                ctx.root,
536                ctx.elapsed,
537                ctx.quiet,
538            );
539            ExitCode::SUCCESS
540        }
541        OutputFormat::Json => json::print_grouped_duplication_json(
542            report,
543            grouping,
544            ctx.root,
545            ctx.elapsed,
546            ctx.explain,
547        ),
548        OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
549        OutputFormat::CodeClimate => {
550            codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
551        }
552        OutputFormat::PrCommentGithub
553        | OutputFormat::PrCommentGitlab
554        | OutputFormat::ReviewGithub
555        | OutputFormat::ReviewGitlab => print_duplication_ci_comment(report, ctx.root, output),
556        // The GitHub formats have no grouping concept; render ungrouped (same
557        // fallback the PR-comment formats use).
558        OutputFormat::GithubAnnotations => {
559            print_dupes_github_format(report, ctx, GithubTarget::Annotations)
560        }
561        OutputFormat::GithubSummary => {
562            print_dupes_github_format(report, ctx, GithubTarget::Summary)
563        }
564        OutputFormat::Compact => {
565            compact::print_duplication_compact(report, ctx.root);
566            warn_dupes_grouping_unsupported(grouping, "compact");
567            ExitCode::SUCCESS
568        }
569        OutputFormat::Markdown => {
570            markdown::print_duplication_markdown(report, ctx.root);
571            warn_dupes_grouping_unsupported(grouping, "markdown");
572            ExitCode::SUCCESS
573        }
574        OutputFormat::Badge => {
575            eprintln!("Error: badge format is only supported for the health command");
576            ExitCode::from(2)
577        }
578    }
579}
580
581/// Dispatch a PR-comment / review CI format from a precomputed CodeClimate value.
582///
583/// Returns `Some(exit_code)` for the four CI comment/review formats and `None`
584/// for every other output format, so callers keep their exhaustive match arms.
585fn print_ci_comment_format(
586    analysis: &str,
587    value: &serde_json::Value,
588    output: OutputFormat,
589) -> Option<ExitCode> {
590    let exit = match output {
591        OutputFormat::PrCommentGithub => {
592            ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Github, value)
593        }
594        OutputFormat::PrCommentGitlab => {
595            ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Gitlab, value)
596        }
597        OutputFormat::ReviewGithub => {
598            ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Github, value)
599        }
600        OutputFormat::ReviewGitlab => {
601            ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Gitlab, value)
602        }
603        _ => return None,
604    };
605    Some(exit)
606}
607
608fn warn_dupes_grouping_unsupported(grouping: &DuplicationGrouping, format: &str) {
609    eprintln!(
610        "note: --group-by {} is not supported for {format} duplication output, falling back to \
611         ungrouped output (use --format json for the full grouped envelope)",
612        grouping.mode
613    );
614}
615
616/// Print health (complexity) analysis results in the configured format.
617///
618/// `grouping` and `group_resolver` carry per-group output produced by
619/// `--group-by`:
620/// - **JSON** renders the grouped envelope (`{ grouped_by, vital_signs,
621///   health_score, groups: [...] }`).
622/// - **Human** prints a per-group summary block (score / files / hot / p90)
623///   after the project-level report.
624/// - **SARIF** and **CodeClimate** tag every per-finding result with the
625///   resolver-derived group key (`properties.group` for SARIF, top-level
626///   `group` for CodeClimate) so CI consumers like GitHub Code Scanning
627///   and GitLab Code Quality can partition findings per team / package
628///   without re-parsing the project structure.
629/// - **Compact**, **Markdown**, and **Badge** fall back to ungrouped output
630///   and emit a one-line stderr note pointing at `--format json` for the
631///   richer grouped envelope.
632#[must_use]
633pub fn print_health_report(
634    report: &fallow_output::HealthReport,
635    grouping: Option<&fallow_output::HealthGrouping>,
636    group_resolver: Option<&grouping::OwnershipResolver>,
637    ctx: &ReportContext<'_>,
638    output: OutputFormat,
639) -> ExitCode {
640    match output {
641        OutputFormat::Human => {
642            print_health_human_report(report, grouping, ctx);
643            ExitCode::SUCCESS
644        }
645        OutputFormat::Compact => {
646            compact::print_health_compact(report, ctx.root);
647            warn_grouping_unsupported(grouping, "compact");
648            ExitCode::SUCCESS
649        }
650        OutputFormat::Markdown => {
651            markdown::print_health_markdown(report, ctx.root);
652            warn_grouping_unsupported(grouping, "markdown");
653            ExitCode::SUCCESS
654        }
655        OutputFormat::Sarif => match group_resolver {
656            Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
657            None => sarif::print_health_sarif(report, ctx.root),
658        },
659        OutputFormat::Json => match grouping {
660            Some(grouping) => json::print_grouped_health_json(
661                report,
662                grouping,
663                ctx.root,
664                ctx.elapsed,
665                ctx.explain,
666            ),
667            None => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
668        },
669        OutputFormat::CodeClimate => match group_resolver {
670            Some(resolver) => {
671                codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
672            }
673            None => codeclimate::print_health_codeclimate(report, ctx.root),
674        },
675        OutputFormat::PrCommentGithub
676        | OutputFormat::PrCommentGitlab
677        | OutputFormat::ReviewGithub
678        | OutputFormat::ReviewGitlab => print_health_ci_comment(report, ctx.root, output),
679        // The GitHub formats have no grouping concept; render ungrouped (same
680        // fallback the PR-comment formats use).
681        OutputFormat::GithubAnnotations => {
682            print_health_github_format(report, ctx, GithubTarget::Annotations)
683        }
684        OutputFormat::GithubSummary => {
685            print_health_github_format(report, ctx, GithubTarget::Summary)
686        }
687        OutputFormat::Badge => {
688            warn_grouping_unsupported(grouping, "badge");
689            badge::print_health_badge(report)
690        }
691    }
692}
693
694/// Render health results in a GitHub-native format from the same JSON
695/// envelope `--format json` serializes.
696fn print_health_github_format(
697    report: &fallow_output::HealthReport,
698    ctx: &ReportContext<'_>,
699    target: GithubTarget,
700) -> ExitCode {
701    match json::api_health_json_document(report, ctx.root, ctx.elapsed, ctx.explain) {
702        Ok(envelope) => print_github_format(
703            github_annotations::EnvelopeKind::Health,
704            &envelope,
705            ctx.root,
706            target,
707        ),
708        Err(e) => {
709            eprintln!("Error: failed to serialize health report: {e}");
710            ExitCode::from(2)
711        }
712    }
713}
714
715/// Render the human-format health report, including the per-group summary block.
716fn print_health_human_report(
717    report: &fallow_output::HealthReport,
718    grouping: Option<&fallow_output::HealthGrouping>,
719    ctx: &ReportContext<'_>,
720) {
721    if ctx.summary {
722        human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
723        return;
724    }
725    human::print_health_human(&human::PrintHealthHumanInput {
726        report,
727        root: ctx.root,
728        elapsed: ctx.elapsed,
729        quiet: ctx.quiet,
730        show_explain_tip: ctx.show_explain_tip,
731        explain: ctx.explain,
732        skip_score_and_trend: ctx.skip_score_and_trend,
733        css_requested: ctx.css_requested,
734    });
735    if let Some(grouping) = grouping {
736        human::print_health_grouping(grouping, ctx.root, ctx.quiet);
737    }
738}
739
740/// Render the CI comment / review fallback arms for health results.
741fn print_health_ci_comment(
742    report: &fallow_output::HealthReport,
743    root: &Path,
744    output: OutputFormat,
745) -> ExitCode {
746    let issues = codeclimate::api_health_codeclimate_issues(report, root);
747    let value = fallow_output::codeclimate_issues_to_value(&issues);
748    print_ci_comment_format("health", &value, output).unwrap_or_else(|| {
749        eprintln!("Error: badge format is only supported for the health command");
750        ExitCode::from(2)
751    })
752}
753
754fn warn_grouping_unsupported(grouping: Option<&fallow_output::HealthGrouping>, format: &str) {
755    if let Some(g) = grouping {
756        eprintln!(
757            "note: --group-by {} is not supported for {format} output, falling back to \
758             ungrouped output (use --format json for the full grouped envelope)",
759            g.mode
760        );
761    }
762}
763
764/// Print cross-reference findings (duplicated code that is also dead code).
765///
766/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
767pub fn print_cross_reference_findings(
768    cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
769    root: &Path,
770    quiet: bool,
771    output: OutputFormat,
772) {
773    human::print_cross_reference_findings(cross_ref, root, quiet, output);
774}
775
776/// Print export trace results.
777pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
778    match format {
779        OutputFormat::Json => json::print_trace_json(trace),
780        _ => human::print_export_trace_human(trace),
781    }
782}
783
784/// Print class-member trace results (the `--trace FILE:MEMBER` fallback).
785pub fn print_class_member_trace(
786    trace: &fallow_engine::trace::ClassMemberTrace,
787    format: OutputFormat,
788) {
789    match format {
790        OutputFormat::Json => json::print_trace_json(trace),
791        _ => human::print_class_member_trace_human(trace),
792    }
793}
794
795/// Print file trace results.
796pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
797    match format {
798        OutputFormat::Json => json::print_trace_json(trace),
799        _ => human::print_file_trace_human(trace),
800    }
801}
802
803/// Print dependency trace results.
804pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
805    match format {
806        OutputFormat::Json => json::print_trace_json(trace),
807        _ => human::print_dependency_trace_human(trace),
808    }
809}
810
811/// Print clone trace results.
812pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
813    match format {
814        OutputFormat::Json => json::print_trace_json(trace),
815        _ => human::print_clone_trace_human(trace, root),
816    }
817}
818
819/// Print impact-closure trace results. JSON only emits the structured
820/// closure; human renders a short summary.
821pub fn print_impact_closure_trace(trace: &ImpactClosureTrace, format: OutputFormat) {
822    match format {
823        OutputFormat::Json => json::print_trace_json(trace),
824        _ => {
825            outln!("Impact closure for {}", trace.seed);
826            outln!(
827                "  affected beyond the diff: {} file{}",
828                trace.affected_not_shown.len(),
829                plural(trace.affected_not_shown.len())
830            );
831            for gap in &trace.coordination_gap {
832                outln!(
833                    "  coordination gap: {} consumes {}",
834                    gap.consumer_file,
835                    gap.consumed_symbols.join(", ")
836                );
837            }
838        }
839    }
840}
841
842/// Print pipeline performance timings.
843/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
844pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
845    match format {
846        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
847            Ok(json) => eprintln!("{json}"),
848            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
849        },
850        _ => human::print_performance_human(timings),
851    }
852}
853
854/// Print health pipeline performance timings.
855/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
856pub fn print_health_performance(timings: &fallow_output::HealthTimings, format: OutputFormat) {
857    match format {
858        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
859            Ok(json) => eprintln!("{json}"),
860            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
861        },
862        _ => human::print_health_performance_human(timings),
863    }
864}
865
866#[allow(
867    unused_imports,
868    reason = "target-dependent: used in lib, unused in bin"
869)]
870pub use fallow_api::build_compact_lines;
871#[allow(
872    unused_imports,
873    reason = "target-dependent: used in lib, unused in bin"
874)]
875pub use fallow_api::build_duplication_markdown;
876#[allow(
877    unused_imports,
878    reason = "target-dependent: used in lib, unused in bin"
879)]
880pub use fallow_api::build_health_markdown;
881#[allow(
882    unused_imports,
883    reason = "target-dependent: used in lib, unused in bin"
884)]
885pub use fallow_api::build_markdown;
886#[allow(
887    clippy::redundant_pub_crate,
888    reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
889)]
890pub(crate) use json::SCHEMA_VERSION;
891#[allow(
892    clippy::redundant_pub_crate,
893    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
894)]
895pub(crate) use json::api_check_json_payload_with_config_fixable;
896#[allow(
897    clippy::redundant_pub_crate,
898    reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
899)]
900pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
901#[allow(
902    unused_imports,
903    reason = "target-dependent: used in lib, unused in bin"
904)]
905#[allow(
906    clippy::redundant_pub_crate,
907    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
908)]
909pub(crate) use sarif::api_health_sarif_document;
910#[allow(
911    unused_imports,
912    reason = "target-dependent: used in lib, unused in bin"
913)]
914#[allow(
915    clippy::redundant_pub_crate,
916    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
917)]
918pub(crate) use sarif::api_sarif_document;
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use std::path::{Path, PathBuf};
924
925    fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
926        ReportContext {
927            root,
928            rules,
929            elapsed: Duration::default(),
930            quiet: true,
931            explain: false,
932            group_by: None,
933            top: None,
934            summary: false,
935            summary_heading: false,
936            show_explain_tip: false,
937            baseline_matched: None,
938            config_fixable: false,
939            skip_score_and_trend: false,
940            css_requested: false,
941        }
942    }
943
944    #[test]
945    fn normalize_uri_forward_slashes_unchanged() {
946        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
947    }
948
949    #[test]
950    fn normalize_uri_backslashes_replaced() {
951        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
952    }
953
954    #[test]
955    fn normalize_uri_mixed_slashes() {
956        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
957    }
958
959    #[test]
960    fn normalize_uri_path_with_spaces() {
961        assert_eq!(
962            normalize_uri("src\\my folder\\file.ts"),
963            "src/my folder/file.ts"
964        );
965    }
966
967    #[test]
968    fn normalize_uri_empty_string() {
969        assert_eq!(normalize_uri(""), "");
970    }
971
972    #[test]
973    fn relative_path_strips_root_prefix() {
974        let root = Path::new("/project");
975        let path = Path::new("/project/src/utils.ts");
976        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
977    }
978
979    #[test]
980    fn relative_path_returns_full_path_when_no_prefix() {
981        let root = Path::new("/other");
982        let path = Path::new("/project/src/utils.ts");
983        assert_eq!(relative_path(path, root), path);
984    }
985
986    #[test]
987    fn relative_path_at_root_returns_empty_or_file() {
988        let root = Path::new("/project");
989        let path = Path::new("/project/file.ts");
990        assert_eq!(relative_path(path, root), Path::new("file.ts"));
991    }
992
993    #[test]
994    fn relative_path_deeply_nested() {
995        let root = Path::new("/project");
996        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
997        assert_eq!(
998            relative_path(path, root),
999            Path::new("packages/ui/src/components/Button.tsx")
1000        );
1001    }
1002
1003    #[test]
1004    fn format_display_path_returns_workspace_relative() {
1005        let root = Path::new("/project");
1006        let path = Path::new("/project/apps/server/src/index.ts");
1007        assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1008    }
1009
1010    #[test]
1011    fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1012        let root = Path::new("/project");
1013        let server = Path::new("/project/apps/server/src/index.ts");
1014        let client = Path::new("/project/apps/client/src/index.ts");
1015        assert_eq!(
1016            format_display_path(server, root),
1017            "apps/server/src/index.ts"
1018        );
1019        assert_eq!(
1020            format_display_path(client, root),
1021            "apps/client/src/index.ts"
1022        );
1023    }
1024
1025    #[test]
1026    fn format_display_path_angular_component_renders_parent_directory() {
1027        let root = Path::new("/project");
1028        let path = Path::new(
1029            "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1030        );
1031        assert_eq!(
1032            format_display_path(path, root),
1033            "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1034        );
1035    }
1036
1037    #[test]
1038    fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1039        let root = Path::new("/other");
1040        let path = Path::new("/project/src/utils.ts");
1041        let rendered = format_display_path(path, root);
1042        assert!(rendered.contains("project"));
1043        assert!(rendered.ends_with("utils.ts"));
1044        assert!(!rendered.contains('\\'));
1045    }
1046
1047    #[test]
1048    fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1049        let root = Path::new("/project");
1050        let path = Path::new("/project/src/sub\\file.ts");
1051        let rendered = format_display_path(path, root);
1052        assert!(
1053            !rendered.contains('\\'),
1054            "backslashes must be normalized: {rendered}"
1055        );
1056    }
1057
1058    #[test]
1059    fn format_display_path_handles_brackets_verbatim() {
1060        let root = Path::new("/project");
1061        let path = Path::new("/project/app/[slug]/page.tsx");
1062        assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1063    }
1064
1065    #[test]
1066    fn format_display_path_path_equals_root_returns_empty() {
1067        let root = Path::new("/project");
1068        let path = Path::new("/project");
1069        assert_eq!(format_display_path(path, root), "");
1070    }
1071
1072    #[test]
1073    fn format_display_path_basename_only_when_path_is_at_root() {
1074        let root = Path::new("/project");
1075        let path = Path::new("/project/Cargo.toml");
1076        assert_eq!(format_display_path(path, root), "Cargo.toml");
1077    }
1078
1079    #[test]
1080    fn relative_uri_produces_forward_slash_path() {
1081        let root = PathBuf::from("/project");
1082        let path = root.join("src").join("utils.ts");
1083        let uri = relative_uri(&path, &root);
1084        assert_eq!(uri, "src/utils.ts");
1085    }
1086
1087    #[test]
1088    fn relative_uri_encodes_brackets() {
1089        let root = PathBuf::from("/project");
1090        let path = root.join("src/app/[...slug]/page.tsx");
1091        let uri = relative_uri(&path, &root);
1092        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1093    }
1094
1095    #[test]
1096    fn relative_uri_encodes_nested_dynamic_routes() {
1097        let root = PathBuf::from("/project");
1098        let path = root.join("src/app/[slug]/[id]/page.tsx");
1099        let uri = relative_uri(&path, &root);
1100        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1101    }
1102
1103    #[test]
1104    fn relative_uri_no_common_prefix_returns_full() {
1105        let root = PathBuf::from("/other");
1106        let path = PathBuf::from("/project/src/utils.ts");
1107        let uri = relative_uri(&path, &root);
1108        assert!(uri.contains("project"));
1109        assert!(uri.contains("utils.ts"));
1110    }
1111
1112    #[test]
1113    fn severity_error_maps_to_level_error() {
1114        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1115    }
1116
1117    #[test]
1118    fn severity_warn_maps_to_level_warn() {
1119        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1120    }
1121
1122    #[test]
1123    fn severity_off_maps_to_level_info() {
1124        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1125    }
1126
1127    #[test]
1128    fn normalize_uri_single_bracket_pair() {
1129        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1130    }
1131
1132    #[test]
1133    fn normalize_uri_catch_all_route() {
1134        assert_eq!(
1135            normalize_uri("app/[...slug]/page.tsx"),
1136            "app/%5B...slug%5D/page.tsx"
1137        );
1138    }
1139
1140    #[test]
1141    fn normalize_uri_optional_catch_all_route() {
1142        assert_eq!(
1143            normalize_uri("app/[[...slug]]/page.tsx"),
1144            "app/%5B%5B...slug%5D%5D/page.tsx"
1145        );
1146    }
1147
1148    #[test]
1149    fn normalize_uri_multiple_dynamic_segments() {
1150        assert_eq!(
1151            normalize_uri("app/[lang]/posts/[id]"),
1152            "app/%5Blang%5D/posts/%5Bid%5D"
1153        );
1154    }
1155
1156    #[test]
1157    fn normalize_uri_no_special_chars() {
1158        let plain = "src/components/Button.tsx";
1159        assert_eq!(normalize_uri(plain), plain);
1160    }
1161
1162    #[test]
1163    fn normalize_uri_only_backslashes() {
1164        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1165    }
1166
1167    #[test]
1168    fn relative_path_identical_paths_returns_empty() {
1169        let root = Path::new("/project");
1170        assert_eq!(relative_path(root, root), Path::new(""));
1171    }
1172
1173    #[test]
1174    fn relative_path_partial_name_match_not_stripped() {
1175        let root = Path::new("/project");
1176        let path = Path::new("/project-two/src/a.ts");
1177        assert_eq!(relative_path(path, root), path);
1178    }
1179
1180    #[test]
1181    fn relative_uri_combines_stripping_and_encoding() {
1182        let root = PathBuf::from("/project");
1183        let path = root.join("src/app/[slug]/page.tsx");
1184        let uri = relative_uri(&path, &root);
1185        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1186        assert!(!uri.starts_with('/'));
1187    }
1188
1189    #[test]
1190    fn relative_uri_at_root_file() {
1191        let root = PathBuf::from("/project");
1192        let path = root.join("index.ts");
1193        assert_eq!(relative_uri(&path, &root), "index.ts");
1194    }
1195
1196    #[test]
1197    fn severity_to_level_is_const_evaluable() {
1198        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1199        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1200        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1201        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1202        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1203        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1204    }
1205
1206    #[test]
1207    fn level_is_copy() {
1208        let level = severity_to_level(Severity::Error);
1209        let copy = level;
1210        assert!(matches!(level, Level::Error));
1211        assert!(matches!(copy, Level::Error));
1212    }
1213
1214    #[test]
1215    fn print_results_rejects_badge_for_dead_code_reports() {
1216        let root = Path::new("/project");
1217        let rules = RulesConfig::default();
1218        let ctx = test_context(root, &rules);
1219
1220        let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1221
1222        assert_eq!(code, ExitCode::from(2));
1223    }
1224
1225    #[test]
1226    fn print_duplication_report_rejects_badge_format() {
1227        let root = Path::new("/project");
1228        let rules = RulesConfig::default();
1229        let ctx = test_context(root, &rules);
1230
1231        let code =
1232            print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1233
1234        assert_eq!(code, ExitCode::from(2));
1235    }
1236
1237    #[test]
1238    fn elide_common_prefix_shared_dir() {
1239        assert_eq!(
1240            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1241            "B.tsx"
1242        );
1243    }
1244
1245    #[test]
1246    fn elide_common_prefix_partial_shared() {
1247        assert_eq!(
1248            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1249            "utils/B.tsx"
1250        );
1251    }
1252
1253    #[test]
1254    fn elide_common_prefix_no_shared() {
1255        assert_eq!(
1256            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1257            "pkg-b/src/B.tsx"
1258        );
1259    }
1260
1261    #[test]
1262    fn elide_common_prefix_identical_files() {
1263        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1264    }
1265
1266    #[test]
1267    fn elide_common_prefix_no_dirs() {
1268        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1269    }
1270
1271    #[test]
1272    fn elide_common_prefix_deep_monorepo() {
1273        assert_eq!(
1274            elide_common_prefix(
1275                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1276                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1277            ),
1278            "SearchSelectItem.tsx"
1279        );
1280    }
1281
1282    #[test]
1283    fn split_dir_filename_with_dir() {
1284        let (dir, file) = split_dir_filename("src/utils/index.ts");
1285        assert_eq!(dir, "src/utils/");
1286        assert_eq!(file, "index.ts");
1287    }
1288
1289    #[test]
1290    fn split_dir_filename_no_dir() {
1291        let (dir, file) = split_dir_filename("file.ts");
1292        assert_eq!(dir, "");
1293        assert_eq!(file, "file.ts");
1294    }
1295
1296    #[test]
1297    fn split_dir_filename_deeply_nested() {
1298        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1299        assert_eq!(dir, "a/b/c/d/");
1300        assert_eq!(file, "e.ts");
1301    }
1302
1303    #[test]
1304    fn split_dir_filename_trailing_slash() {
1305        let (dir, file) = split_dir_filename("src/");
1306        assert_eq!(dir, "src/");
1307        assert_eq!(file, "");
1308    }
1309
1310    #[test]
1311    fn split_dir_filename_empty() {
1312        let (dir, file) = split_dir_filename("");
1313        assert_eq!(dir, "");
1314        assert_eq!(file, "");
1315    }
1316
1317    #[test]
1318    fn plural_zero_is_plural() {
1319        assert_eq!(plural(0), "s");
1320    }
1321
1322    #[test]
1323    fn plural_one_is_singular() {
1324        assert_eq!(plural(1), "");
1325    }
1326
1327    #[test]
1328    fn plural_two_is_plural() {
1329        assert_eq!(plural(2), "s");
1330    }
1331
1332    #[test]
1333    fn plural_large_number() {
1334        assert_eq!(plural(999), "s");
1335    }
1336
1337    #[test]
1338    fn elide_common_prefix_empty_base() {
1339        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1340    }
1341
1342    #[test]
1343    fn elide_common_prefix_empty_target() {
1344        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1345    }
1346
1347    #[test]
1348    fn elide_common_prefix_both_empty() {
1349        assert_eq!(elide_common_prefix("", ""), "");
1350    }
1351
1352    #[test]
1353    fn elide_common_prefix_same_file_different_extension() {
1354        assert_eq!(
1355            elide_common_prefix("src/utils.ts", "src/utils.js"),
1356            "utils.js"
1357        );
1358    }
1359
1360    #[test]
1361    fn elide_common_prefix_partial_filename_match_not_stripped() {
1362        assert_eq!(
1363            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1364            "AppUtils.tsx"
1365        );
1366    }
1367
1368    #[test]
1369    fn elide_common_prefix_identical_paths() {
1370        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1371    }
1372
1373    #[test]
1374    fn split_dir_filename_single_slash() {
1375        let (dir, file) = split_dir_filename("/file.ts");
1376        assert_eq!(dir, "/");
1377        assert_eq!(file, "file.ts");
1378    }
1379
1380    #[test]
1381    fn emit_json_returns_success_for_valid_value() {
1382        let value = serde_json::json!({"key": "value"});
1383        let code = emit_json(&value, "test");
1384        assert_eq!(code, ExitCode::SUCCESS);
1385    }
1386
1387    mod proptests {
1388        use super::*;
1389        use proptest::prelude::*;
1390
1391        proptest! {
1392            /// split_dir_filename always reconstructs the original path.
1393            #[test]
1394            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1395                let (dir, file) = split_dir_filename(&path);
1396                let reconstructed = format!("{dir}{file}");
1397                prop_assert_eq!(
1398                    reconstructed, path,
1399                    "dir+file should reconstruct the original path"
1400                );
1401            }
1402
1403            /// plural returns either "" or "s", nothing else.
1404            #[test]
1405            fn plural_returns_empty_or_s(n: usize) {
1406                let result = plural(n);
1407                prop_assert!(
1408                    result.is_empty() || result == "s",
1409                    "plural should return \"\" or \"s\", got {:?}",
1410                    result
1411                );
1412            }
1413
1414            /// plural(1) is always "" and plural(n != 1) is always "s".
1415            #[test]
1416            fn plural_singular_only_for_one(n: usize) {
1417                let result = plural(n);
1418                if n == 1 {
1419                    prop_assert_eq!(result, "", "plural(1) should be empty");
1420                } else {
1421                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1422                }
1423            }
1424
1425            /// normalize_uri never panics and always replaces backslashes.
1426            #[test]
1427            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1428                let result = normalize_uri(&path);
1429                prop_assert!(
1430                    !result.contains('\\'),
1431                    "Result should not contain backslashes: {result}"
1432                );
1433            }
1434
1435            /// normalize_uri always encodes brackets.
1436            #[test]
1437            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1438                let result = normalize_uri(&path);
1439                prop_assert!(
1440                    !result.contains('[') && !result.contains(']'),
1441                    "Result should not contain raw brackets: {result}"
1442                );
1443            }
1444
1445            /// elide_common_prefix always returns a suffix of or equal to target.
1446            #[test]
1447            fn elide_common_prefix_returns_suffix_of_target(
1448                base in "[a-zA-Z0-9_./]{0,50}",
1449                target in "[a-zA-Z0-9_./]{0,50}",
1450            ) {
1451                let result = elide_common_prefix(&base, &target);
1452                prop_assert!(
1453                    target.ends_with(result),
1454                    "Result {:?} should be a suffix of target {:?}",
1455                    result, target
1456                );
1457            }
1458
1459            /// relative_path never panics.
1460            #[test]
1461            fn relative_path_never_panics(
1462                root in "/[a-zA-Z0-9_/]{0,30}",
1463                suffix in "[a-zA-Z0-9_./]{0,30}",
1464            ) {
1465                let root_path = Path::new(&root);
1466                let full = PathBuf::from(format!("{root}/{suffix}"));
1467                let _ = relative_path(&full, root_path);
1468            }
1469        }
1470    }
1471}