Skip to main content

fallow_cli/report/
mod.rs

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