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