Skip to main content

fallow_cli/report/
mod.rs

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