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 = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1017)]
1018pub(crate) use json::api_check_json_payload_with_config_fixable;
1019#[allow(
1020    clippy::redundant_pub_crate,
1021    reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1022)]
1023pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1024#[allow(
1025    unused_imports,
1026    reason = "target-dependent: used in lib, unused in bin"
1027)]
1028#[allow(
1029    clippy::redundant_pub_crate,
1030    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1031)]
1032pub(crate) use sarif::api_health_sarif_document;
1033#[allow(
1034    unused_imports,
1035    reason = "target-dependent: used in lib, unused in bin"
1036)]
1037#[allow(
1038    clippy::redundant_pub_crate,
1039    reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1040)]
1041pub(crate) use sarif::api_sarif_document;
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use std::path::{Path, PathBuf};
1047
1048    fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1049        ReportContext {
1050            root,
1051            rules,
1052            elapsed: Duration::default(),
1053            quiet: true,
1054            explain: false,
1055            type_aware: None,
1056            type_aware_scope: None,
1057            group_by: None,
1058            top: None,
1059            summary: false,
1060            summary_heading: false,
1061            show_explain_tip: false,
1062            baseline_matched: None,
1063            config_fixable: false,
1064            skip_score_and_trend: false,
1065            css_requested: false,
1066            json_style: crate::json_style::JsonStyle::Compact,
1067        }
1068    }
1069
1070    #[test]
1071    fn normalize_uri_forward_slashes_unchanged() {
1072        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1073    }
1074
1075    #[test]
1076    fn normalize_uri_backslashes_replaced() {
1077        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1078    }
1079
1080    #[test]
1081    fn normalize_uri_mixed_slashes() {
1082        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1083    }
1084
1085    #[test]
1086    fn normalize_uri_path_with_spaces() {
1087        assert_eq!(
1088            normalize_uri("src\\my folder\\file.ts"),
1089            "src/my folder/file.ts"
1090        );
1091    }
1092
1093    #[test]
1094    fn normalize_uri_empty_string() {
1095        assert_eq!(normalize_uri(""), "");
1096    }
1097
1098    #[test]
1099    fn relative_path_strips_root_prefix() {
1100        let root = Path::new("/project");
1101        let path = Path::new("/project/src/utils.ts");
1102        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1103    }
1104
1105    #[test]
1106    fn relative_path_returns_full_path_when_no_prefix() {
1107        let root = Path::new("/other");
1108        let path = Path::new("/project/src/utils.ts");
1109        assert_eq!(relative_path(path, root), path);
1110    }
1111
1112    #[test]
1113    fn relative_path_at_root_returns_empty_or_file() {
1114        let root = Path::new("/project");
1115        let path = Path::new("/project/file.ts");
1116        assert_eq!(relative_path(path, root), Path::new("file.ts"));
1117    }
1118
1119    #[test]
1120    fn relative_path_deeply_nested() {
1121        let root = Path::new("/project");
1122        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1123        assert_eq!(
1124            relative_path(path, root),
1125            Path::new("packages/ui/src/components/Button.tsx")
1126        );
1127    }
1128
1129    #[test]
1130    fn format_display_path_returns_workspace_relative() {
1131        let root = Path::new("/project");
1132        let path = Path::new("/project/apps/server/src/index.ts");
1133        assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1134    }
1135
1136    #[test]
1137    fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1138        let root = Path::new("/project");
1139        let server = Path::new("/project/apps/server/src/index.ts");
1140        let client = Path::new("/project/apps/client/src/index.ts");
1141        assert_eq!(
1142            format_display_path(server, root),
1143            "apps/server/src/index.ts"
1144        );
1145        assert_eq!(
1146            format_display_path(client, root),
1147            "apps/client/src/index.ts"
1148        );
1149    }
1150
1151    #[test]
1152    fn format_display_path_angular_component_renders_parent_directory() {
1153        let root = Path::new("/project");
1154        let path = Path::new(
1155            "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1156        );
1157        assert_eq!(
1158            format_display_path(path, root),
1159            "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1160        );
1161    }
1162
1163    #[test]
1164    fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1165        let root = Path::new("/other");
1166        let path = Path::new("/project/src/utils.ts");
1167        let rendered = format_display_path(path, root);
1168        assert!(rendered.contains("project"));
1169        assert!(rendered.ends_with("utils.ts"));
1170        assert!(!rendered.contains('\\'));
1171    }
1172
1173    #[test]
1174    fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1175        let root = Path::new("/project");
1176        let path = Path::new("/project/src/sub\\file.ts");
1177        let rendered = format_display_path(path, root);
1178        assert!(
1179            !rendered.contains('\\'),
1180            "backslashes must be normalized: {rendered}"
1181        );
1182    }
1183
1184    #[test]
1185    fn format_display_path_handles_brackets_verbatim() {
1186        let root = Path::new("/project");
1187        let path = Path::new("/project/app/[slug]/page.tsx");
1188        assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1189    }
1190
1191    #[test]
1192    fn format_display_path_path_equals_root_returns_empty() {
1193        let root = Path::new("/project");
1194        let path = Path::new("/project");
1195        assert_eq!(format_display_path(path, root), "");
1196    }
1197
1198    #[test]
1199    fn format_display_path_basename_only_when_path_is_at_root() {
1200        let root = Path::new("/project");
1201        let path = Path::new("/project/Cargo.toml");
1202        assert_eq!(format_display_path(path, root), "Cargo.toml");
1203    }
1204
1205    #[test]
1206    fn relative_uri_produces_forward_slash_path() {
1207        let root = PathBuf::from("/project");
1208        let path = root.join("src").join("utils.ts");
1209        let uri = relative_uri(&path, &root);
1210        assert_eq!(uri, "src/utils.ts");
1211    }
1212
1213    #[test]
1214    fn relative_uri_encodes_brackets() {
1215        let root = PathBuf::from("/project");
1216        let path = root.join("src/app/[...slug]/page.tsx");
1217        let uri = relative_uri(&path, &root);
1218        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1219    }
1220
1221    #[test]
1222    fn relative_uri_encodes_nested_dynamic_routes() {
1223        let root = PathBuf::from("/project");
1224        let path = root.join("src/app/[slug]/[id]/page.tsx");
1225        let uri = relative_uri(&path, &root);
1226        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1227    }
1228
1229    #[test]
1230    fn relative_uri_no_common_prefix_returns_full() {
1231        let root = PathBuf::from("/other");
1232        let path = PathBuf::from("/project/src/utils.ts");
1233        let uri = relative_uri(&path, &root);
1234        assert!(uri.contains("project"));
1235        assert!(uri.contains("utils.ts"));
1236    }
1237
1238    #[test]
1239    fn severity_error_maps_to_level_error() {
1240        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1241    }
1242
1243    #[test]
1244    fn severity_warn_maps_to_level_warn() {
1245        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1246    }
1247
1248    #[test]
1249    fn severity_off_maps_to_level_info() {
1250        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1251    }
1252
1253    #[test]
1254    fn normalize_uri_single_bracket_pair() {
1255        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1256    }
1257
1258    #[test]
1259    fn normalize_uri_catch_all_route() {
1260        assert_eq!(
1261            normalize_uri("app/[...slug]/page.tsx"),
1262            "app/%5B...slug%5D/page.tsx"
1263        );
1264    }
1265
1266    #[test]
1267    fn normalize_uri_optional_catch_all_route() {
1268        assert_eq!(
1269            normalize_uri("app/[[...slug]]/page.tsx"),
1270            "app/%5B%5B...slug%5D%5D/page.tsx"
1271        );
1272    }
1273
1274    #[test]
1275    fn normalize_uri_multiple_dynamic_segments() {
1276        assert_eq!(
1277            normalize_uri("app/[lang]/posts/[id]"),
1278            "app/%5Blang%5D/posts/%5Bid%5D"
1279        );
1280    }
1281
1282    #[test]
1283    fn normalize_uri_no_special_chars() {
1284        let plain = "src/components/Button.tsx";
1285        assert_eq!(normalize_uri(plain), plain);
1286    }
1287
1288    #[test]
1289    fn normalize_uri_only_backslashes() {
1290        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1291    }
1292
1293    #[test]
1294    fn relative_path_identical_paths_returns_empty() {
1295        let root = Path::new("/project");
1296        assert_eq!(relative_path(root, root), Path::new(""));
1297    }
1298
1299    #[test]
1300    fn relative_path_partial_name_match_not_stripped() {
1301        let root = Path::new("/project");
1302        let path = Path::new("/project-two/src/a.ts");
1303        assert_eq!(relative_path(path, root), path);
1304    }
1305
1306    #[test]
1307    fn relative_uri_combines_stripping_and_encoding() {
1308        let root = PathBuf::from("/project");
1309        let path = root.join("src/app/[slug]/page.tsx");
1310        let uri = relative_uri(&path, &root);
1311        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1312        assert!(!uri.starts_with('/'));
1313    }
1314
1315    #[test]
1316    fn relative_uri_at_root_file() {
1317        let root = PathBuf::from("/project");
1318        let path = root.join("index.ts");
1319        assert_eq!(relative_uri(&path, &root), "index.ts");
1320    }
1321
1322    #[test]
1323    fn severity_to_level_is_const_evaluable() {
1324        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1325        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1326        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1327        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1328        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1329        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1330    }
1331
1332    #[test]
1333    fn level_is_copy() {
1334        let level = severity_to_level(Severity::Error);
1335        let copy = level;
1336        assert!(matches!(level, Level::Error));
1337        assert!(matches!(copy, Level::Error));
1338    }
1339
1340    #[test]
1341    fn print_results_rejects_badge_for_dead_code_reports() {
1342        let root = Path::new("/project");
1343        let rules = RulesConfig::default();
1344        let ctx = test_context(root, &rules);
1345
1346        let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1347
1348        assert_eq!(code, ExitCode::from(2));
1349    }
1350
1351    #[test]
1352    fn print_duplication_report_rejects_badge_format() {
1353        let root = Path::new("/project");
1354        let rules = RulesConfig::default();
1355        let ctx = test_context(root, &rules);
1356
1357        let code =
1358            print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1359
1360        assert_eq!(code, ExitCode::from(2));
1361    }
1362
1363    #[test]
1364    fn elide_common_prefix_shared_dir() {
1365        assert_eq!(
1366            elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1367            "B.tsx"
1368        );
1369    }
1370
1371    #[test]
1372    fn elide_common_prefix_partial_shared() {
1373        assert_eq!(
1374            elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1375            "utils/B.tsx"
1376        );
1377    }
1378
1379    #[test]
1380    fn elide_common_prefix_no_shared() {
1381        assert_eq!(
1382            elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1383            "pkg-b/src/B.tsx"
1384        );
1385    }
1386
1387    #[test]
1388    fn elide_common_prefix_identical_files() {
1389        assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1390    }
1391
1392    #[test]
1393    fn elide_common_prefix_no_dirs() {
1394        assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1395    }
1396
1397    #[test]
1398    fn elide_common_prefix_deep_monorepo() {
1399        assert_eq!(
1400            elide_common_prefix(
1401                "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1402                "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1403            ),
1404            "SearchSelectItem.tsx"
1405        );
1406    }
1407
1408    #[test]
1409    fn split_dir_filename_with_dir() {
1410        let (dir, file) = split_dir_filename("src/utils/index.ts");
1411        assert_eq!(dir, "src/utils/");
1412        assert_eq!(file, "index.ts");
1413    }
1414
1415    #[test]
1416    fn split_dir_filename_no_dir() {
1417        let (dir, file) = split_dir_filename("file.ts");
1418        assert_eq!(dir, "");
1419        assert_eq!(file, "file.ts");
1420    }
1421
1422    #[test]
1423    fn split_dir_filename_deeply_nested() {
1424        let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1425        assert_eq!(dir, "a/b/c/d/");
1426        assert_eq!(file, "e.ts");
1427    }
1428
1429    #[test]
1430    fn split_dir_filename_trailing_slash() {
1431        let (dir, file) = split_dir_filename("src/");
1432        assert_eq!(dir, "src/");
1433        assert_eq!(file, "");
1434    }
1435
1436    #[test]
1437    fn split_dir_filename_empty() {
1438        let (dir, file) = split_dir_filename("");
1439        assert_eq!(dir, "");
1440        assert_eq!(file, "");
1441    }
1442
1443    #[test]
1444    fn plural_zero_is_plural() {
1445        assert_eq!(plural(0), "s");
1446    }
1447
1448    #[test]
1449    fn plural_one_is_singular() {
1450        assert_eq!(plural(1), "");
1451    }
1452
1453    #[test]
1454    fn plural_two_is_plural() {
1455        assert_eq!(plural(2), "s");
1456    }
1457
1458    #[test]
1459    fn plural_large_number() {
1460        assert_eq!(plural(999), "s");
1461    }
1462
1463    #[test]
1464    fn elide_common_prefix_empty_base() {
1465        assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1466    }
1467
1468    #[test]
1469    fn elide_common_prefix_empty_target() {
1470        assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1471    }
1472
1473    #[test]
1474    fn elide_common_prefix_both_empty() {
1475        assert_eq!(elide_common_prefix("", ""), "");
1476    }
1477
1478    #[test]
1479    fn elide_common_prefix_same_file_different_extension() {
1480        assert_eq!(
1481            elide_common_prefix("src/utils.ts", "src/utils.js"),
1482            "utils.js"
1483        );
1484    }
1485
1486    #[test]
1487    fn elide_common_prefix_partial_filename_match_not_stripped() {
1488        assert_eq!(
1489            elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1490            "AppUtils.tsx"
1491        );
1492    }
1493
1494    #[test]
1495    fn elide_common_prefix_identical_paths() {
1496        assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1497    }
1498
1499    #[test]
1500    fn split_dir_filename_single_slash() {
1501        let (dir, file) = split_dir_filename("/file.ts");
1502        assert_eq!(dir, "/");
1503        assert_eq!(file, "file.ts");
1504    }
1505
1506    #[test]
1507    fn emit_json_returns_success_for_valid_value() {
1508        let value = serde_json::json!({"key": "value"});
1509        let code = emit_json(&value, "test");
1510        assert_eq!(code, ExitCode::SUCCESS);
1511    }
1512
1513    mod proptests {
1514        use super::*;
1515        use proptest::prelude::*;
1516
1517        proptest! {
1518            /// split_dir_filename always reconstructs the original path.
1519            #[test]
1520            fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1521                let (dir, file) = split_dir_filename(&path);
1522                let reconstructed = format!("{dir}{file}");
1523                prop_assert_eq!(
1524                    reconstructed, path,
1525                    "dir+file should reconstruct the original path"
1526                );
1527            }
1528
1529            /// plural returns either "" or "s", nothing else.
1530            #[test]
1531            fn plural_returns_empty_or_s(n: usize) {
1532                let result = plural(n);
1533                prop_assert!(
1534                    result.is_empty() || result == "s",
1535                    "plural should return \"\" or \"s\", got {:?}",
1536                    result
1537                );
1538            }
1539
1540            /// plural(1) is always "" and plural(n != 1) is always "s".
1541            #[test]
1542            fn plural_singular_only_for_one(n: usize) {
1543                let result = plural(n);
1544                if n == 1 {
1545                    prop_assert_eq!(result, "", "plural(1) should be empty");
1546                } else {
1547                    prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1548                }
1549            }
1550
1551            /// normalize_uri never panics and always replaces backslashes.
1552            #[test]
1553            fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1554                let result = normalize_uri(&path);
1555                prop_assert!(
1556                    !result.contains('\\'),
1557                    "Result should not contain backslashes: {result}"
1558                );
1559            }
1560
1561            /// normalize_uri always encodes brackets.
1562            #[test]
1563            fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1564                let result = normalize_uri(&path);
1565                prop_assert!(
1566                    !result.contains('[') && !result.contains(']'),
1567                    "Result should not contain raw brackets: {result}"
1568                );
1569            }
1570
1571            /// elide_common_prefix always returns a suffix of or equal to target.
1572            #[test]
1573            fn elide_common_prefix_returns_suffix_of_target(
1574                base in "[a-zA-Z0-9_./]{0,50}",
1575                target in "[a-zA-Z0-9_./]{0,50}",
1576            ) {
1577                let result = elide_common_prefix(&base, &target);
1578                prop_assert!(
1579                    target.ends_with(result),
1580                    "Result {:?} should be a suffix of target {:?}",
1581                    result, target
1582                );
1583            }
1584
1585            /// relative_path never panics.
1586            #[test]
1587            fn relative_path_never_panics(
1588                root in "/[a-zA-Z0-9_/]{0,30}",
1589                suffix in "[a-zA-Z0-9_./]{0,30}",
1590            ) {
1591                let root_path = Path::new(&root);
1592                let full = PathBuf::from(format!("{root}/{suffix}"));
1593                let _ = relative_path(&full, root_path);
1594            }
1595        }
1596    }
1597}