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