Skip to main content

crap_core/cli/
mod.rs

1//! CLI entry point — thin shell over the library crate.
2//!
3//! Parses args with clap, validates inputs, delegates to `core::analyze()`.
4//! No business logic lives here.
5//!
6//! `cli::run<P>` is generic over the coverage adapter's parse-diagnostic
7//! type so the same dispatch shell drives every adapter binary. The
8//! per-binary main.rs supplies the complexity + coverage ports as `&dyn`
9//! trait objects (ADR D9) plus an `AdapterMeta` carrying the binary's
10//! name, version, help copy, extensions, and config-file name.
11
12use std::path::{Path, PathBuf};
13use std::process::ExitCode;
14use std::time::SystemTime;
15
16use anyhow::{Result, bail};
17use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, ValueHint};
18use clap_complete::Shell as ClapShell;
19
20use crate::adapters::baseline::{self, BaselineSnapshot};
21use crate::adapters::config::{self, FileConfig};
22use crate::adapters::reporters;
23use crate::adapters::reporters::json::DeltaContext;
24use crate::core::{AnalysisOutput, AnalyzeOptions};
25use crate::domain::delta::{self, AnalysisDelta, DeltaView};
26use crate::domain::threshold::{ThresholdConfig, ThresholdPreset, is_valid_threshold};
27use crate::domain::types::{AnalysisDiagnostics, ComplexityMetric};
28use crate::domain::view::{self, GroupKey, SortKey};
29use crate::ports::{ComplexityPort, CoveragePort, ParseDiagnostic};
30
31mod delta_args;
32mod init;
33mod view_args;
34
35// ── ValueEnum wrappers (keep domain types clap-free) ────────────────
36
37/// Complexity metric for CRAP score computation.
38#[derive(Debug, Clone, Copy, ValueEnum)]
39pub enum MetricArg {
40    /// Nesting depth + structural complexity
41    Cognitive,
42    /// Decision-point count, classic CRAP metric
43    Cyclomatic,
44}
45
46impl From<MetricArg> for ComplexityMetric {
47    fn from(arg: MetricArg) -> Self {
48        match arg {
49            MetricArg::Cognitive => ComplexityMetric::Cognitive,
50            MetricArg::Cyclomatic => ComplexityMetric::Cyclomatic,
51        }
52    }
53}
54
55/// Output format for the CRAP report.
56#[derive(Debug, Clone, Copy, ValueEnum)]
57pub enum FormatArg {
58    /// Human-readable table with ANSI colors
59    Table,
60    /// Nested JSON envelope (pipe to jq for filtering)
61    Json,
62    /// GitHub-flavored Markdown — paste into PR comments or issues
63    Markdown,
64    /// RFC 4180 CSV — one row per function, no summary
65    Csv,
66    /// SARIF v2.1.0 — for GitHub Code Scanning (upload-sarif@v3)
67    Sarif,
68    /// Agent-oriented JSON with Diagnostic remediation hints (experimental)
69    Advice,
70    /// Single `Row::CrapDelta` JSON object — for scorecard
71    /// aggregator consumption (locked schema fragment at
72    /// `crates/crap4rs/tests/fixtures/scorecard/schema.json`,
73    /// schema_version=1; see `docs/scorecard-row-contract.md`).
74    ScorecardRow,
75    /// Self-contained HTML dashboard with summary stats, risk
76    /// distribution, and per-file collapsible function tables. Inline
77    /// CSS, no external assets, mobile-responsive.
78    Html,
79    /// GitHub Actions inline annotations — emits `::warning` workflow
80    /// commands so threshold-exceeding findings render inline on the
81    /// PR "Files Changed" tab. Universal, free, no GHAS dependency.
82    GithubAnnotations,
83}
84
85/// One requested output: a format and an optional file destination.
86///
87/// Parsed from `--format X` (stdout) or `--format X:FILE` (write to file).
88/// `--format` accepts a comma-separated list of these specs so a single
89/// analysis pass can fan out to multiple shapes.
90#[derive(Debug, Clone)]
91pub struct FormatSpec {
92    pub format: FormatArg,
93    pub output: Option<PathBuf>,
94}
95
96impl std::str::FromStr for FormatSpec {
97    type Err = String;
98
99    fn from_str(spec: &str) -> Result<Self, Self::Err> {
100        let (fmt_str, output) = match spec.split_once(':') {
101            Some((f, path)) if !path.is_empty() => (f, Some(PathBuf::from(path))),
102            Some((_, _)) => return Err(format!("empty file path in `--format {spec}`")),
103            None => (spec, None),
104        };
105        let format = FormatArg::from_str(fmt_str, true)
106            .map_err(|e| format!("invalid format `{fmt_str}`: {e}"))?;
107        Ok(FormatSpec { format, output })
108    }
109}
110
111/// Clap value parser for `FormatSpec` — delegates to the `FromStr` impl.
112fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
113    s.parse()
114}
115
116/// Sort key for the displayed view.
117///
118/// CLI-side wrapper that keeps `clap::ValueEnum` out of the domain.
119/// `From<SortKeyArg> for SortKey` is the boundary; `build_view_spec`
120/// translates at the edge so `domain::view::SortKey` stays clap-free.
121#[derive(Debug, Clone, Copy, ValueEnum)]
122pub enum SortKeyArg {
123    /// CRAP score descending (default — investigator's first cut)
124    Crap,
125    /// Coverage percent ascending (lowest coverage first)
126    Coverage,
127    /// Complexity descending (most complex first)
128    Complexity,
129    /// Alphabetical by file_path, then CRAP descending within file
130    Path,
131}
132
133impl From<SortKeyArg> for SortKey {
134    fn from(arg: SortKeyArg) -> Self {
135        match arg {
136            SortKeyArg::Crap => SortKey::Crap,
137            SortKeyArg::Coverage => SortKey::Coverage,
138            SortKeyArg::Complexity => SortKey::Complexity,
139            SortKeyArg::Path => SortKey::Path,
140        }
141    }
142}
143
144/// Reverse mapping for saved view presets — preset stores
145/// domain `SortKey`, but `FilterArgs.sort_by` is the clap-side wrapper.
146///
147/// `SortKey` is `#[non_exhaustive]` for cross-crate consumers, but
148/// the cli module lives in the same crate as the domain `SortKey`
149/// definition, so the compiler treats the match as exhaustive
150/// without a wildcard arm. New domain variants must still land with a
151/// paired CLI variant in the same PR — clippy's missing-pattern error
152/// is now the loud failure point (the formerly-required wildcard arm
153/// triggered `unreachable_patterns` post-relocation).
154impl From<SortKey> for SortKeyArg {
155    fn from(key: SortKey) -> Self {
156        match key {
157            SortKey::Crap => SortKeyArg::Crap,
158            SortKey::Coverage => SortKeyArg::Coverage,
159            SortKey::Complexity => SortKeyArg::Complexity,
160            SortKey::Path => SortKeyArg::Path,
161        }
162    }
163}
164
165/// Group key for the displayed view.
166///
167/// Today only `file` is supported. The wrapper keeps `clap::ValueEnum`
168/// out of the domain; `From<GroupByArg> for GroupKey` is the boundary.
169#[derive(Debug, Clone, Copy, ValueEnum)]
170pub enum GroupByArg {
171    /// Aggregate by source file path
172    File,
173}
174
175impl From<GroupByArg> for GroupKey {
176    fn from(arg: GroupByArg) -> Self {
177        match arg {
178            GroupByArg::File => GroupKey::File,
179        }
180    }
181}
182
183/// Reverse mapping for saved view presets. See `From<SortKey>`
184/// above for the wildcard-arm rationale.
185impl From<GroupKey> for GroupByArg {
186    fn from(key: GroupKey) -> Self {
187        match key {
188            GroupKey::File => GroupByArg::File,
189        }
190    }
191}
192
193/// Sort key for the delta block.
194#[derive(Debug, Clone, Copy, ValueEnum)]
195pub enum DeltaSortKeyArg {
196    /// Magnitude of change descending — regressions first (default)
197    ScoreDelta,
198    /// Current CRAP score descending; `Removed` rows last
199    CurrentCrap,
200    /// Baseline CRAP score descending; `Added` rows last
201    BaselineCrap,
202    /// Alphabetical by file_path then qualified_name
203    Path,
204}
205
206impl From<DeltaSortKeyArg> for crate::domain::delta::DeltaSortKey {
207    fn from(arg: DeltaSortKeyArg) -> Self {
208        use crate::domain::delta::DeltaSortKey;
209        match arg {
210            DeltaSortKeyArg::ScoreDelta => DeltaSortKey::ScoreDelta,
211            DeltaSortKeyArg::CurrentCrap => DeltaSortKey::CurrentCrap,
212            DeltaSortKeyArg::BaselineCrap => DeltaSortKey::BaselineCrap,
213            DeltaSortKeyArg::Path => DeltaSortKey::Path,
214        }
215    }
216}
217
218/// Change-kind subset for `--delta-only`.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
220pub enum DeltaKindArg {
221    Added,
222    Removed,
223    Modified,
224}
225
226impl From<DeltaKindArg> for crate::domain::delta::ChangeKind {
227    fn from(arg: DeltaKindArg) -> Self {
228        use crate::domain::delta::ChangeKind;
229        match arg {
230            DeltaKindArg::Added => ChangeKind::Added,
231            DeltaKindArg::Removed => ChangeKind::Removed,
232            DeltaKindArg::Modified => ChangeKind::Modified,
233        }
234    }
235}
236
237/// When to colorize output.
238#[derive(Debug, Clone, Copy, Default, ValueEnum)]
239pub enum ColorArg {
240    /// Colorize when writing to a terminal
241    #[default]
242    Auto,
243    /// Always colorize output
244    Always,
245    /// Never colorize output
246    Never,
247}
248
249// ── Arg groups ──────────────────────────────────────────────────────
250
251/// Shell name for completion script generation.
252#[derive(Debug, Clone, Copy, ValueEnum)]
253pub enum ShellArg {
254    Bash,
255    Zsh,
256    Fish,
257    Powershell,
258    Elvish,
259    Nushell,
260}
261
262/// Top-level subcommands. Optional — when absent, the analyzer runs
263/// the default analysis path that requires `--coverage`.
264#[derive(Debug, Subcommand)]
265pub enum Command {
266    /// Generate a shell completion script to stdout.
267    Completions {
268        #[arg(value_enum)]
269        shell: ShellArg,
270    },
271    /// Generate a starter config TOML in the current directory.
272    ///
273    /// Interactive by default (asks for a threshold preset);
274    /// `--non-interactive` uses defaults for CI/scripts. Refuses to
275    /// overwrite an existing config unless `--force` is passed.
276    Init {
277        /// Overwrite an existing config file in this directory.
278        #[arg(long)]
279        force: bool,
280        /// Skip the interactive prompt and use defaults (preset =
281        /// "default"). CI-friendly.
282        #[arg(long)]
283        non_interactive: bool,
284    },
285}
286
287#[derive(Debug, Args)]
288#[command(next_help_heading = "Input")]
289pub struct InputArgs {
290    /// Path to the coverage file (adapter-specific format).
291    /// Required for analysis; not required for the `completions` subcommand.
292    #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
293    pub coverage: Option<PathBuf>,
294
295    /// Root directory of source files to analyze [default: src]
296    #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
297    pub src: Option<PathBuf>,
298
299    /// Complexity metric to use
300    #[arg(long, value_enum)]
301    pub metric: Option<MetricArg>,
302
303    /// Path to config file (default: auto-discover the adapter's config TOML)
304    #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
305    pub config: Option<PathBuf>,
306
307    /// Resolve and apply a saved view preset from the adapter's config TOML.
308    ///
309    /// The preset's fields (`top`, `min_coverage`, `max_coverage`, `sort`,
310    /// `only_failing`, `no_fail`, `group_by`, `minimal_view`) are folded
311    /// into the parsed CLI before the report is shaped. CLI flags
312    /// override the preset's `Option<T>` fields. Bare-bool flags
313    /// OR-merge with the preset (an explicit `--no-fail` adds to a
314    /// preset's value but cannot turn off `no_fail = true`).
315    #[arg(long, value_name = "NAME")]
316    pub view: Option<String>,
317
318    /// Path to a previously-emitted JSON envelope, used as the baseline
319    /// for delta analysis.
320    ///
321    /// The analyzer runs the current analysis as usual, then compares
322    /// against the baseline's `result` block to produce a `delta` block
323    /// in the output (see `--format json`, `--format markdown` for
324    /// rendering). Generate the baseline file by piping a previous run:
325    /// `<binary> --coverage <file> --format json > baseline.json`.
326    ///
327    /// **Delta is informational by default.** Pass `--delta-gate` to
328    /// make the delta contribute to the exit code (fails on new
329    /// threshold violations introduced by this PR).
330    #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
331    pub baseline: Option<PathBuf>,
332}
333
334#[derive(Debug, Args)]
335#[command(next_help_heading = "Output")]
336pub struct OutputArgs {
337    /// Output format(s).
338    ///
339    /// Accepts a single format (`--format json`) for stdout, or a comma-
340    /// separated list to fan out a single analysis pass to multiple
341    /// destinations (`--format json:envelope.json,markdown:report.md`).
342    /// Each entry is `FORMAT` (stdout) or `FORMAT:FILE` (write to file).
343    /// Multi-format invocations may include at most one stdout entry
344    /// (the rest must specify a file) — two stdout sinks would
345    /// interleave indistinguishably, but a single stdout sink
346    /// alongside file sinks is the shape composite CI workflows need
347    /// (e.g. `markdown:scorecard.md,github-annotations` where the
348    /// workflow commands are intercepted from stdout by the runner).
349    #[arg(
350        short,
351        long,
352        value_delimiter = ',',
353        default_value = "table",
354        value_parser = parse_format_spec
355    )]
356    pub format: Vec<FormatSpec>,
357
358    /// CRAP score threshold — functions above this fail the check.
359    /// Defaults to the calibrated cutoff for the active complexity
360    /// metric (cyclomatic and cognitive scores differ in magnitude, so
361    /// the default differs per metric — see the generated config).
362    // allow_hyphen_values: lets clap parse `--threshold -5` as a value
363    // (not a flag), so our validate_inputs can give an actionable error.
364    #[arg(long, allow_hyphen_values = true, group = "threshold_select")]
365    pub threshold: Option<f64>,
366
367    /// Use the strict preset (tighter cutoff calibrated for the active
368    /// metric) — for high-quality or safety-critical code
369    #[arg(long, group = "threshold_select")]
370    pub strict: bool,
371
372    /// Use the lenient preset (looser cutoff calibrated for the active
373    /// metric) — for legacy or transitional code
374    #[arg(long, group = "threshold_select")]
375    pub lenient: bool,
376
377    /// Always exit 0, even when threshold violations exist.
378    ///
379    /// Overrides only the exit-code translation; the underlying analysis
380    /// is untouched and `result.passed` in JSON output still reflects
381    /// the truthful pass/fail state, so consumers can detect "would
382    /// have failed" even when the process exits 0. Composes with
383    /// `--quiet` for silent success in CI. With `--delta-gate`, also
384    /// overrides the delta-gate exit-code translation (truth still in
385    /// `delta.summary.passed`).
386    #[arg(long)]
387    pub no_fail: bool,
388
389    /// Fail the build (exit 1) when the baseline comparison introduces
390    /// new threshold violations.
391    ///
392    /// Off by default — delta is informational unless this flag is set.
393    /// Drives off `delta.summary.passed`, which is true iff
394    /// `new_violations == 0`. Pre-existing violations (functions that
395    /// already exceeded threshold in the baseline) do NOT contribute,
396    /// so re-running with no code changes never trips the gate. Only
397    /// meaningful with `--baseline`. Composes with `--no-fail` (which
398    /// overrides BOTH gates).
399    #[arg(long, requires = "baseline")]
400    pub delta_gate: bool,
401
402    /// Omit the denormalized `view.shown` row array from JSON output.
403    ///
404    /// Payload-size escape hatch for very large codebases. The
405    /// envelope's `result` block (the gate) is unaffected; `view.spec`,
406    /// `view.eligible_count`, `view.truncated`, and `view.shown_summary`
407    /// remain so consumers retain full scope context. Only meaningful
408    /// with `--format json`.
409    #[arg(long)]
410    pub minimal_view: bool,
411
412    /// Emit a single-line analysis verdict instead of the full report.
413    ///
414    /// Format: `<STATUS>: <N> functions | <M> above threshold (<T>) | worst: <W> | avg: <A>`
415    /// (e.g., `PASS: 1082 functions | 0 above threshold (25) | worst: 13.0 | avg: 1.6`).
416    /// Short-circuits `--format`: when set, the format dispatch is
417    /// skipped and only the summary line is printed to stdout.
418    /// Composes with `--no-fail` (exit 0 always when set, summary still
419    /// emitted) and `--quiet` (quiet wins — no output, exit code only).
420    /// Matches crap4ts's `--summary` shape byte-for-byte for the shared
421    /// subset so a CI line-template can match either tool.
422    #[arg(long)]
423    pub summary: bool,
424
425    /// Cap on the number of `::warning` annotations the
426    /// `github-annotations` reporter emits per invocation.
427    ///
428    /// GitHub Actions silently drops annotations past a per-step UI
429    /// cap (10 warning / 10 error / 10 notice per step, 50 per job).
430    /// When the eligible (`exceeds == true`) set exceeds this cap, the
431    /// reporter takes the top-N by CRAP and appends a single trailing
432    /// `::notice::N more functions exceed threshold; see scorecard for
433    /// the full list` line so reviewers know findings were dropped.
434    ///
435    /// Range `1..=100` (clap-enforced — `0` is rejected at the CLI
436    /// boundary because a zero cap is meaningless and almost always
437    /// indicates the user meant to omit the flag). Default `10` —
438    /// matches the GH Actions per-step display cap; raising the limit
439    /// past 10 produces annotations that the runner will drop. Only
440    /// meaningful with `--format github-annotations`; ignored by every
441    /// other reporter. Configurable per project via
442    /// `[output] annotation_limit` in the adapter's TOML.
443    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))]
444    pub annotation_limit: Option<u32>,
445}
446
447#[derive(Debug, Args)]
448#[command(next_help_heading = "Filtering")]
449pub struct FilterArgs {
450    /// Glob patterns to exclude from analysis (repeatable)
451    ///
452    /// Build artifacts (target/) are excluded automatically via .gitignore.
453    /// Test files are NOT excluded by default — use `--exclude "tests/**"`
454    /// if you want to skip them.
455    #[arg(long, action = clap::ArgAction::Append)]
456    pub exclude: Vec<String>,
457
458    /// Do not respect .gitignore files
459    ///
460    /// By default, paths in .gitignore are skipped (e.g., target/).
461    /// Pass this flag to analyze all files regardless of .gitignore.
462    #[arg(long)]
463    pub no_gitignore: bool,
464
465    /// Git ref to diff against — only analyze functions in changed files/hunks
466    ///
467    /// Scopes analysis to functions in files that changed since the given ref.
468    /// Useful for CI PR gating: `<binary> --coverage <file> --diff main`
469    #[arg(long, value_name = "REF")]
470    pub diff: Option<String>,
471
472    /// Only show functions that exceed the threshold
473    ///
474    /// Display-only filter: the underlying analysis (the gate) and its
475    /// summary remain over the full unfiltered set, so the exit code and
476    /// every aggregate (`average_crap`, `median_crap`, `distribution`,
477    /// etc.) reflect the whole codebase. Only the row list and
478    /// `view.shown_summary` are reduced.
479    #[arg(long)]
480    pub only_failing: bool,
481
482    /// Lower bound (inclusive) on coverage_percent for the displayed view.
483    ///
484    /// `allow_hyphen_values`: lets clap parse `--min-coverage -5` as a
485    /// value (not an unknown flag) so `validate_view_args` can report
486    /// the right error.
487    #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
488    pub min_coverage: Option<f64>,
489
490    /// Upper bound (inclusive) on coverage_percent for the displayed view.
491    #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
492    pub max_coverage: Option<f64>,
493
494    /// Sort key for the displayed view (default: crap descending).
495    ///
496    /// `crap` (default) — CRAP score descending; `coverage` — coverage
497    /// percent ascending (lowest first); `complexity` — complexity
498    /// descending; `path` — alphabetical by file, then CRAP descending
499    /// within file. Sorting reorders without reducing rows, so the gate
500    /// (exit code) is unaffected. Unknown values are rejected by clap
501    /// at parse time with an `invalid value` error attributed to
502    /// `--sort-by`, so no custom validation is needed here.
503    #[arg(long, value_enum, value_name = "KEY")]
504    pub sort_by: Option<SortKeyArg>,
505
506    /// Truncate the displayed view to the top N highest-CRAP rows.
507    ///
508    /// `--top 0` means "no limit" — equivalent to omitting the flag.
509    /// The full unfiltered analysis still drives the gate (exit code),
510    /// so truncating violations out of the view does not change the outcome.
511    ///
512    /// `allow_hyphen_values`: lets clap parse `--top -3` as a value (not an
513    /// unknown flag) so the resulting error message is attributed to `--top`.
514    #[arg(long, allow_hyphen_values = true, value_name = "N")]
515    pub top: Option<u32>,
516
517    /// Aggregate the displayed view by a key. Today: `file` only.
518    ///
519    /// When set, the report shifts to per-file rows. `--top N` truncates
520    /// to the top N **files** (not functions); `--sort-by` keys at the
521    /// file level (`crap` → average CRAP descending; `coverage` →
522    /// average coverage ascending; `complexity` → max complexity
523    /// descending; `path` → alphabetical). The full per-function row
524    /// list still appears in JSON `view.shown` for drill-down. The
525    /// gate (exit code) is unaffected.
526    #[arg(long, value_enum, value_name = "KEY")]
527    pub group_by: Option<GroupByArg>,
528
529    /// Truncate the delta block to the top N rows by `--delta-sort`.
530    /// `--delta-top 0` means "no limit". Independent of `--top`, which
531    /// truncates the analysis view (`view.shown`).
532    ///
533    /// `allow_hyphen_values`: parses `--delta-top -3` as a value (not
534    /// an unknown flag) so the error attribution to `--delta-top` is
535    /// readable.
536    #[arg(long, allow_hyphen_values = true, value_name = "N")]
537    pub delta_top: Option<u32>,
538
539    /// Sort key for the delta block.
540    ///
541    /// `score-delta` (default) — magnitude of change descending
542    /// (regressions first). `current-crap` — current CRAP descending,
543    /// `Removed` rows last. `baseline-crap` — baseline CRAP descending,
544    /// `Added` rows last. `path` — alphabetical by file then qualified
545    /// name.
546    #[arg(long, value_enum, value_name = "KEY")]
547    pub delta_sort: Option<DeltaSortKeyArg>,
548
549    /// Comma-separated list of change kinds to include in the delta
550    /// block: `added`, `removed`, `modified`. Default: all three.
551    #[arg(long, value_delimiter = ',', value_name = "KINDS")]
552    pub delta_only: Vec<DeltaKindArg>,
553}
554
555#[derive(Debug, Args)]
556#[command(next_help_heading = "Display")]
557pub struct DisplayArgs {
558    /// When to use terminal colors
559    #[arg(long, value_enum, default_value_t = ColorArg::Auto)]
560    pub color: ColorArg,
561
562    /// Show parse diagnostics and matching statistics
563    #[arg(short, long)]
564    pub verbose: bool,
565
566    /// Suppress report output, only set exit code
567    #[arg(short, long)]
568    pub quiet: bool,
569
570    /// Show complexity contributors for functions exceeding threshold.
571    ///
572    /// JSON output always includes contributors regardless of this flag.
573    #[arg(long)]
574    pub breakdown: bool,
575
576    /// Explain nested breakdown increments in table output.
577    ///
578    /// Only affects table output, and only when `--breakdown` is enabled.
579    #[arg(long)]
580    pub explain: bool,
581
582    /// Render the full per-function table in markdown output.
583    ///
584    /// By default `--format markdown` produces a compact summary plus a
585    /// top-N table (failures if any exist, otherwise the worst by CRAP).
586    /// This flag appends the legacy row-per-function table — useful when
587    /// piping into a longer document instead of a PR comment. Has no
588    /// effect on other output formats.
589    #[arg(long)]
590    pub md_full_table: bool,
591
592    /// Number of rows in the markdown top-N table (default 10).
593    ///
594    /// Bounds the failures list (or worst-by-CRAP list when nothing
595    /// exceeds threshold). The summary block is unaffected — its stats
596    /// always reflect the full unshapeable analysis.
597    #[arg(long, value_name = "N", default_value_t = 10)]
598    pub md_top: usize,
599}
600
601// ── Top-level CLI ───────────────────────────────────────────────────
602
603// `long_version` is overridden at runtime in `cli::run` so each binary's
604// build script can splice the git hash + build date into its `--version`
605// output without forcing crap-core to read an env var that's only set
606// during the binary's compile. The derive's `version` here resolves to
607// the **adapter** crate's `CARGO_PKG_VERSION` because clap captures the
608// env at the macro expansion site — that's the binary crate's version
609// when compiling the binary, but the lib crate's version when compiling
610// the lib. Production callers always reach `cli::run` through the binary,
611// so `--version` displays the adapter's version. Tests that go through
612// the lib see crap-core's version, which is fine for tests.
613//
614// Consumer-visible version strings flow as parameters via `AdapterMeta`
615// from the binary where `env!` resolves against the bin's package, not
616// against this module's home crate.
617
618// `about` / `long_about` / `after_help` are intentionally generic
619// here — adapter-flavored copy (language name, AST library, coverage
620// toolchain, runnable examples) is injected at runtime by
621// `build_command` from `AdapterMeta`. Library tests that
622// `try_parse_from` `Cli` directly see this generic default; the
623// binary always overrides.
624#[derive(Debug, Parser)]
625#[command(
626    version,
627    author,
628    about = "CRAP score analyzer",
629    long_about = "CRAP (Change Risk Anti-Patterns) score analyzer. \
630                  Combines complexity analysis with line-coverage data to \
631                  identify functions that are both complex and under-tested. \
632                  Adapter-specific binaries (crap4rs for Rust, crap4ts for \
633                  TypeScript) wire language-specific complexity walkers and \
634                  coverage parsers behind the same orchestrator."
635)]
636pub struct Cli {
637    #[command(flatten)]
638    pub input: InputArgs,
639
640    #[command(flatten)]
641    pub output: OutputArgs,
642
643    #[command(flatten)]
644    pub filter: FilterArgs,
645
646    #[command(flatten)]
647    pub display: DisplayArgs,
648
649    #[command(subcommand)]
650    pub command: Option<Command>,
651}
652
653// ── Entry point ─────────────────────────────────────────────────────
654
655/// Adapter-supplied runtime metadata that crap-core threads through
656/// `parse_args`, `run`, and the reporter call sites.
657///
658/// All fields are `&'static` because every production caller supplies
659/// `env!(...)` / `build.rs`-stamped literals or `const &[&str]` slices.
660/// Tests construct from `&'static str` literals too. The lifetime
661/// parameter was dropped in #161 — no caller ever needed non-static
662/// metadata, and the `<'a>` ripple polluted 15 function signatures for
663/// no payoff. The struct stays `Copy` so threading is trivial.
664///
665/// Reporters keep a flat `(tool_name, tool_version)` call boundary —
666/// the struct only travels through orchestration code.
667#[derive(Debug, Clone, Copy)]
668pub struct AdapterMeta {
669    /// Adapter binary name (e.g., `"crap4rs"`, `"crap4ts"`). Drives
670    /// clap's `--version` output, the `name` field in SARIF, and the
671    /// header line in table/markdown/html reporters.
672    pub tool_name: &'static str,
673    /// Human-readable adapter label (e.g., `"Rust"`, `"TypeScript"`).
674    /// Surfaced in the HTML reporter's per-adapter footer row so end
675    /// users read "Rust · cognitive complexity" instead of the binary
676    /// name. Production binaries supply the language name; tests use
677    /// a synthetic label. Distinct from `tool_name` because the
678    /// binary's package name is the wrong granularity for the
679    /// adapter-provenance footer.
680    pub display_name: &'static str,
681    /// Short version string (e.g., `"0.5.0"`). Threaded to every
682    /// reporter alongside `tool_name`.
683    pub tool_version: &'static str,
684    /// Long version string for `--version --long` (e.g.,
685    /// `"0.5.0 (abc1234 2026-05-09)"`).
686    pub long_version: &'static str,
687    /// Short adapter-flavored help text (one-line, shown by `--help`).
688    pub about: &'static str,
689    /// Long adapter-flavored help text (multi-paragraph, shown by
690    /// `--help` in full mode).
691    pub long_about: &'static str,
692    /// `after_help` block with adapter-specific examples
693    /// (`crap4rs --coverage lcov.info ...` etc.). May be empty.
694    pub after_help: &'static str,
695    /// Coverage-tool hint shown when `--coverage` points at a file
696    /// with no `SF:` / `DA:` records. Adapter-specific because the
697    /// remediation depends on the coverage toolchain (Rust: `cargo
698    /// llvm-cov --lcov`; TS: `c8 --reporter=lcov`).
699    pub coverage_hint: &'static str,
700    /// File extensions the walker should pick up (e.g.,
701    /// `&["rs"]` for crap4rs; `&["ts","tsx","js","jsx","mjs","cjs"]`
702    /// for crap4ts). Adapter binaries supply a `const &[&str]`; copied
703    /// into `AnalyzeOptions.extensions` at the orchestration boundary.
704    pub extensions: &'static [&'static str],
705    /// Adapter repo URL spliced into SARIF's
706    /// `runs[0].tool.driver.informationUri`. Adapter-specific so
707    /// crap4ts SARIF output links to crap4ts's repo, not crap4rs's.
708    pub tool_info_uri: &'static str,
709    /// Adapter rule-help URL spliced into SARIF's
710    /// `runs[0].tool.driver.rules[0].helpUri`. Adapter-specific for
711    /// the same reason as `tool_info_uri`.
712    pub rule_help_uri: &'static str,
713    /// Conventional config file name the adapter binary auto-discovers
714    /// in the working directory (e.g., `"crap4rs.toml"` for the Rust
715    /// adapter; `"crap4ts.toml"` for the TS adapter). Threaded through
716    /// to `discover_config` and surfaced in `--view <preset>`
717    /// error hints so users see the right file name to create.
718    pub config_file_name: &'static str,
719    /// Commented-out exclude patterns emitted by `init` into the
720    /// generated config (e.g., `&["tests/**", "benches/**", "examples/**"]`
721    /// for Rust; `&["node_modules/**", "dist/**", "coverage/**"]` for
722    /// TS). Adapter-specific because the convention for "where tests
723    /// and ignorable artifacts live" differs per ecosystem. May be
724    /// empty — init then emits the `# exclude = [ … ]` block without
725    /// per-language entries.
726    ///
727    /// This field is **init-template only** — it does NOT affect the
728    /// analyzer's effective exclude list at runtime. The runtime
729    /// exclude list is built from `cli.filter.exclude` plus the user's
730    /// `crap4ts.toml`/`crap4rs.toml` `exclude` entries, with adapter-
731    /// mandated patterns prepended via `forced_excludes` (below).
732    pub default_excludes: &'static [&'static str],
733    /// Adapter-mandated exclude patterns prepended to every analysis
734    /// run, regardless of CLI flags or user config. Use for files that
735    /// are **structurally never source** for the adapter's language —
736    /// e.g. crap4ts sets `&["**/*.d.ts"]` because TypeScript
737    /// declaration files contain only ambient types, never executable
738    /// code. crap4rs has no such suffix today and sets `&[]`.
739    ///
740    /// Distinct from `default_excludes` (above): forced excludes are
741    /// load-bearing at analysis time and cannot be turned off by the
742    /// operator, whereas `default_excludes` is init-template
743    /// scaffolding. If an operator genuinely needs `.d.ts` files in
744    /// their report, the path is forking the adapter or filing a
745    /// follow-up to add an opt-out — not a config knob (crap-rs#253).
746    pub forced_excludes: &'static [&'static str],
747    /// The default complexity metric the adapter binary uses when
748    /// neither CLI nor config file specifies one. crap4rs sets
749    /// `Cognitive`; crap4ts sets `Cyclomatic` (the only metric crap4ts
750    /// currently supports per `CrapError::MetricNotSupported`).
751    /// Threaded through `merge_effective_inputs` so the binary's
752    /// default flips per adapter without re-litigating the shared CLI
753    /// fallthrough. See ADR (d) `adr-adapter-meta-default-metric.md`
754    /// for the design rationale (per-adapter defaults surface through
755    /// `AdapterMeta`, not crap-core configuration).
756    pub default_metric: ComplexityMetric,
757}
758
759impl AdapterMeta {
760    /// Allocate an owned `Vec<String>` from `extensions` for inclusion
761    /// in `AnalyzeOptions` (which owns its config rather than borrowing
762    /// from the meta, decoupling analysis lifetime from CLI lifetime).
763    pub fn extensions_owned(&self) -> Vec<String> {
764        self.extensions.iter().map(|e| (*e).to_string()).collect()
765    }
766
767    /// Trip on construction with empty required strings. `extensions`
768    /// is allowed to be empty — `core::ensure_source_files_found`
769    /// surfaces a parser-neutral diagnostic when no files match. Other
770    /// fields are mandatory for help/SARIF/`--version` rendering, and a
771    /// silent empty string here would produce malformed output that's
772    /// hard to trace back to the meta. Debug-only so release builds
773    /// stay zero-cost; production binaries should never hit these
774    /// (their meta is `env!()` / `const`).
775    pub(crate) fn debug_assert_required_fields(&self) {
776        debug_assert!(
777            !self.tool_name.is_empty(),
778            "AdapterMeta.tool_name must not be empty"
779        );
780        debug_assert!(
781            !self.display_name.is_empty(),
782            "AdapterMeta.display_name must not be empty"
783        );
784        debug_assert!(
785            !self.tool_version.is_empty(),
786            "AdapterMeta.tool_version must not be empty"
787        );
788        debug_assert!(
789            !self.long_version.is_empty(),
790            "AdapterMeta.long_version must not be empty"
791        );
792        debug_assert!(
793            !self.about.is_empty(),
794            "AdapterMeta.about must not be empty"
795        );
796        debug_assert!(
797            !self.long_about.is_empty(),
798            "AdapterMeta.long_about must not be empty"
799        );
800        debug_assert!(
801            !self.coverage_hint.is_empty(),
802            "AdapterMeta.coverage_hint must not be empty"
803        );
804        debug_assert!(
805            !self.tool_info_uri.is_empty(),
806            "AdapterMeta.tool_info_uri must not be empty"
807        );
808        debug_assert!(
809            !self.rule_help_uri.is_empty(),
810            "AdapterMeta.rule_help_uri must not be empty"
811        );
812        debug_assert!(
813            !self.config_file_name.is_empty(),
814            "AdapterMeta.config_file_name must not be empty"
815        );
816    }
817}
818
819/// Parse process args into `Cli`, splicing the adapter's runtime
820/// metadata into clap's help / `--version` output.
821///
822/// Split out from `run` purely to keep the parse step monomorphic and
823/// off the binary's hot path on `--help` / `--version` (clap intercepts
824/// those before `parse_args` returns). The adapter binary supplies its
825/// coverage adapter to `run` as a factory closure that's invoked once
826/// after CLI/config-file merging resolves the effective source root —
827/// pre-construction lets the coverage parser strip the wrong prefix
828/// from per-file records when `cli.input.src` is `None` because `src`
829/// came from the adapter's config TOML rather than the CLI.
830///
831/// `AdapterMeta::{tool_version, long_version, about, long_about,
832/// after_help}` flow into clap's help / `--version` output at runtime
833/// so the binary's build-script metadata reaches the help text — the
834/// derive macro's `version` reads `CARGO_PKG_VERSION` at lib-crate
835/// compile time (crap-core's `0.1.0`); the adapter binary's own
836/// `CARGO_PKG_VERSION` and `<ADAPTER>_LONG_VERSION` only resolve in
837/// the binary's compile and reach us by parameter.
838pub fn parse_args(meta: &AdapterMeta) -> Cli {
839    meta.debug_assert_required_fields();
840    let cmd = build_command(meta);
841    let matches = cmd.get_matches();
842    Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
843}
844
845/// Read the adapter binary's name from `argv[0]`, falling back to
846/// `meta.tool_name` when argv[0] is unavailable (extreme edge cases
847/// like execve with empty argv). The clap-derive `Cli::command()`
848/// defaults to `CARGO_PKG_NAME` of the lib crate (crap-core), which
849/// would print `--version` lines with the wrong identifier and shape
850/// generated completion scripts for the wrong binary; runtime
851/// detection ensures the displayed name matches whichever adapter
852/// binary actually ran.
853fn current_bin_name(meta_fallback: &str) -> String {
854    std::env::args()
855        .next()
856        .and_then(|first| {
857            // `file_stem()` (not `file_name()`) so Windows builds drop
858            // the `.exe` suffix — without it `--version` would print
859            // `<binary>.exe <version>` and break scripts (and the
860            // version-stamp integration tests) that match `^<binary> `.
861            // No-op on Linux/macOS.
862            std::path::PathBuf::from(first)
863                .file_stem()
864                .map(|os| os.to_string_lossy().into_owned())
865        })
866        .unwrap_or_else(|| meta_fallback.to_string())
867}
868
869/// Build the clap `Command` with the binary's runtime metadata
870/// spliced in. Used by `parse_args`; `emit_completions` reads the
871/// bin name through `current_bin_name` directly because
872/// `clap_complete::generate` takes the bin name as a separate arg.
873///
874/// `name` / `bin_name` need clap's `string` feature
875/// (`impl From<String> for clap::builder::Str`) because
876/// `current_bin_name` constructs the bin name at runtime from
877/// `argv[0]` and returns `String` — without the feature, the only way
878/// to satisfy `From<&'static str>` from a runtime `String` is
879/// `Box::leak` (the pre-#161 workaround). The remaining fields are
880/// `&'static str` on `AdapterMeta`, so they pass through clap's
881/// default `Into<Str>` impl with zero heap allocations.
882fn build_command(meta: &AdapterMeta) -> clap::Command {
883    let bin_name = current_bin_name(meta.tool_name);
884    let mut cmd = Cli::command()
885        .name(bin_name.clone())
886        .bin_name(bin_name)
887        .version(meta.tool_version)
888        .long_version(meta.long_version)
889        .about(meta.about)
890        .long_about(meta.long_about);
891    if !meta.after_help.is_empty() {
892        cmd = cmd.after_help(meta.after_help);
893    }
894    // The `--metric` help advertises the adapter's default metric. The
895    // doc-comment-derived help is static and shared between adapters,
896    // but the effective default differs per adapter (crap4rs resolves
897    // to cognitive, crap4ts to cyclomatic), so the displayed default is
898    // injected at runtime from `AdapterMeta::default_metric` rather
899    // than hardcoded in the comment.
900    cmd = cmd.mut_arg("metric", |arg| {
901        arg.help(format!(
902            "Complexity metric to use [default: {}]",
903            meta.default_metric
904        ))
905    });
906    cmd
907}
908
909/// Run the CRAP CLI pipeline end-to-end.
910///
911/// Takes a `coverage_factory` closure rather than a constructed
912/// coverage adapter so the parser receives the effective source root
913/// *after* CLI / config-file / preset merging — pre-construction
914/// canonicalized against the bare CLI value (or the default `src`) and
915/// the LCOV parser silently stripped the wrong prefix from `SF:`
916/// records. The factory is invoked once inside `run` after
917/// `merge_effective_inputs` resolves the final `src`, receives the
918/// **canonicalized** effective source root (so adapter factories stay
919/// dumb — orchestration owns the canonicalize concern), and is
920/// short-circuited entirely on the `completions` subcommand (clap's
921/// `--help` / `--version` exit even earlier, inside `parse_args`).
922///
923/// Generic over `P: ParseDiagnostic` so the same orchestrator drives
924/// every adapter crate's binary (per ADR D9, mixed-dispatch). The
925/// `'static` bound on `P` is the standard trait-object well-formedness
926/// requirement when the closure returns `Box<dyn …>`; concrete adapter
927/// diagnostic types (`LcovParseDiagnostic`, `IstanbulParseDiagnostic`)
928/// satisfy it trivially.
929///
930/// `meta` carries the adapter binary's runtime identity (name,
931/// version, help copy, extensions, config-file name, SARIF URIs).
932/// The binary's own `tool_version` (e.g. crap4rs's `CARGO_PKG_VERSION`
933/// resolves to `0.5.0`, not crap-core's `0.1.0`) feeds the JSON
934/// envelope's `tool_version` field, the SARIF run metadata, the
935/// markdown / HTML headers, and clap's long-version splice. See
936/// `AdapterMeta` for the per-field rationale.
937pub fn run<P, F>(
938    cli: Cli,
939    complexity: &dyn ComplexityPort,
940    coverage_factory: F,
941    meta: &AdapterMeta,
942) -> ExitCode
943where
944    P: ParseDiagnostic + std::fmt::Display + 'static,
945    F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
946{
947    match run_inner(cli, complexity, coverage_factory, meta) {
948        Ok(true) => ExitCode::from(0),
949        Ok(false) => ExitCode::from(1),
950        Err(e) => {
951            render_error(&e, meta);
952            ExitCode::from(2)
953        }
954    }
955}
956
957/// Render an end-of-pipeline error to stderr, special-casing the
958/// `MetricNotSupported` variant so the user sees adapter-specific
959/// phrasing without the generic `error:` prefix (per breadboard W-5
960/// + `metric_unsupported.feature` scenario 1 exact-string contract).
961///
962/// `anyhow::Error::downcast_ref` walks the source chain looking for a
963/// concrete `CrapError`; we use it (not `is::<CrapError>()`) so an
964/// error that was `.context(...)`-wrapped along the way is still
965/// detected. Every other error type falls through to the default
966/// `error: {e:#}` rendering — `{:#}` uses anyhow's alternate Display
967/// which prints the full cause chain.
968fn render_error(err: &anyhow::Error, meta: &AdapterMeta) {
969    if let Some(crap_err) = err.downcast_ref::<crate::domain::types::CrapError>()
970        && let crate::domain::types::CrapError::MetricNotSupported { metric } = crap_err
971    {
972        // Adapter-specific message: `tool_name` + `default_metric`
973        // hint + `tool_info_uri`. The domain layer's variant message
974        // stays adapter-agnostic; adapter-named phrasing lives at
975        // this rendering boundary only. `metric` (input) +
976        // `meta.default_metric` (hint) both use `ComplexityMetric`'s
977        // `Display` impl which yields lowercase wire tokens —
978        // matching CLI input (`cognitive`, not Debug `Cognitive`).
979        eprintln!(
980            "{}: complexity metric `{}` is not yet supported. Use `--metric {}` (the default for {}) or track support at {}.",
981            meta.tool_name, metric, meta.default_metric, meta.tool_name, meta.tool_info_uri,
982        );
983        return;
984    }
985    eprintln!("error: {err:#}");
986}
987
988fn run_inner<P, F>(
989    mut cli: Cli,
990    complexity: &dyn ComplexityPort,
991    coverage_factory: F,
992    meta: &AdapterMeta,
993) -> Result<bool>
994where
995    P: ParseDiagnostic + std::fmt::Display + 'static,
996    F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
997{
998    match cli.command {
999        Some(Command::Completions { shell }) => {
1000            emit_completions(shell, &current_bin_name(meta.tool_name));
1001            return Ok(true);
1002        }
1003        Some(Command::Init {
1004            force,
1005            non_interactive,
1006        }) => {
1007            init::handle_init(force, non_interactive, meta)?;
1008            return Ok(true);
1009        }
1010        None => {}
1011    }
1012
1013    let prep = prepare_pipeline(&mut cli, complexity, coverage_factory, meta)?;
1014
1015    // Build the spec, then shape the result through the View pipeline.
1016    // V1b: `--only-failing` flows through `Filters::only_failing` here.
1017    // W2 fills in `--top`, `--min/max-coverage`, `--sort-by`. The
1018    // underlying `result` is never mutated — the gate is unshapeable.
1019    let spec = view_args::build_view_spec(&cli);
1020    let view = view::apply(&prep.analysis.result, spec);
1021
1022    // Shape the delta. Spec is built from --delta-top / --delta-sort /
1023    // --delta-only (VS4); defaults match the dominant scorecard use
1024    // case (regressions first, all kinds, no truncation). `Option::map`
1025    // is `FnOnce`, so the closure moves the spec rather than cloning —
1026    // `DeltaView` owns its `spec` field, no further uses upstream.
1027    let delta_spec = delta_args::build_delta_view_spec(&cli);
1028    let delta_view: Option<DeltaView<'_>> = prep
1029        .delta_state
1030        .as_ref()
1031        .map(move |s| delta::apply(&s.delta, delta_spec));
1032
1033    if !cli.display.quiet {
1034        print_formatted_output(
1035            &cli,
1036            &view,
1037            delta_view.as_ref(),
1038            prep.delta_state.as_ref(),
1039            &prep.analysis,
1040            &prep.inputs,
1041            meta,
1042        )?;
1043    }
1044
1045    // Exit code derives from `view.full.passed` — i.e., the underlying
1046    // analysis. The View shapes the display, never the gate.
1047    //
1048    // Delta is informational by default.
1049    // `--delta-gate` opts in: a passing analysis with delta regressions
1050    // that introduce new violations will exit 1 when `--delta-gate` is
1051    // set. `--no-fail` overrides BOTH gates — truth lives in JSON
1052    // (`result.passed` and `delta.summary.passed`) so consumers can
1053    // still detect "would have failed."
1054    Ok(compute_exit_code(
1055        &cli,
1056        prep.analysis.result.passed,
1057        prep.delta_state.as_ref(),
1058    ))
1059}
1060
1061// ── Run-inner orchestration helpers ────────────────────────────────
1062
1063/// Effective inputs after CLI / config-file / preset / default merging.
1064/// Carries everything downstream (`core::analyze` + reporter dispatch)
1065/// needs except the coverage path (which is validated separately and
1066/// may be borrowed from `cli`).
1067struct EffectiveInputs {
1068    src: PathBuf,
1069    metric: ComplexityMetric,
1070    threshold_config: ThresholdConfig,
1071    threshold: f64,
1072    exclude: Vec<String>,
1073    /// Merged cap for the `github-annotations` reporter. Defaults to
1074    /// `10` when neither the CLI flag nor the TOML `[output]
1075    /// annotation_limit` is set; CLI flag wins over config. Honored
1076    /// only by `format_github_annotations` — every other reporter
1077    /// ignores it.
1078    annotation_limit: usize,
1079}
1080
1081/// In-flight pipeline state assembled by `prepare_pipeline`. Owns the
1082/// analysis output and the optional delta state so the dispatch layer
1083/// borrows through references. Generic over `P: ParseDiagnostic` so
1084/// `AnalysisOutput<P>` and `DeltaState<P>` carry the adapter's diagnostic
1085/// shape (LCOV, future Istanbul, …) end-to-end.
1086struct PipelinePrep<P: ParseDiagnostic> {
1087    inputs: EffectiveInputs,
1088    analysis: AnalysisOutput<P>,
1089    delta_state: Option<DeltaState<P>>,
1090}
1091
1092/// Merge CLI flags, optional file config, and adapter defaults into a
1093/// concrete `EffectiveInputs`. `meta.default_metric` is the
1094/// load-bearing fallthrough — each adapter binary picks its own
1095/// sensible default (crap4rs: `Cognitive`; crap4ts: `Cyclomatic`) so
1096/// the shared CLI stays adapter-agnostic. See ADR (d).
1097fn merge_effective_inputs(
1098    cli: &Cli,
1099    file_config: &Option<FileConfig>,
1100    meta: &AdapterMeta,
1101) -> EffectiveInputs {
1102    let src = cli
1103        .input
1104        .src
1105        .clone()
1106        .or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
1107        .unwrap_or_else(|| PathBuf::from("src"));
1108    let metric: ComplexityMetric = cli
1109        .input
1110        .metric
1111        .map(Into::into)
1112        .or_else(|| file_config.as_ref().and_then(|c| c.metric))
1113        .unwrap_or(meta.default_metric);
1114    let (threshold_config, threshold) = merge_threshold(cli, file_config, metric);
1115    let exclude = merge_exclude(cli, file_config, meta);
1116    let annotation_limit = cli
1117        .output
1118        .annotation_limit
1119        .or_else(|| file_config.as_ref().and_then(|c| c.output.annotation_limit))
1120        .unwrap_or(10) as usize;
1121    EffectiveInputs {
1122        src,
1123        metric,
1124        threshold_config,
1125        threshold,
1126        exclude,
1127        annotation_limit,
1128    }
1129}
1130
1131fn validate_runtime_inputs<'a>(
1132    cli: &'a Cli,
1133    inputs: &EffectiveInputs,
1134    meta: &AdapterMeta,
1135) -> Result<&'a Path> {
1136    // `--coverage` is required on the analysis path; subcommands like
1137    // `completions` skip this branch. Clap can't express "required
1138    // unless subcommand X" in derive, so we enforce it here.
1139    let Some(coverage_path) = cli.input.coverage.as_deref() else {
1140        bail!(
1141            "--coverage <FILE> is required (run `{name} --help` for usage, or `{name} completions <SHELL>` for shell completion scripts)",
1142            name = meta.tool_name,
1143        );
1144    };
1145
1146    validate_inputs(
1147        coverage_path,
1148        &inputs.src,
1149        inputs.threshold,
1150        meta.coverage_hint,
1151    )?;
1152
1153    if let Some(diff_ref) = cli.filter.diff.as_deref() {
1154        validate_diff_ref(diff_ref)?;
1155        preflight_git_worktree(&inputs.src)?;
1156    }
1157
1158    Ok(coverage_path)
1159}
1160
1161fn build_analyze_options(
1162    cli: &Cli,
1163    inputs: &EffectiveInputs,
1164    coverage: &Path,
1165    meta: &AdapterMeta,
1166) -> AnalyzeOptions {
1167    AnalyzeOptions {
1168        src: inputs.src.clone(),
1169        coverage: coverage.to_path_buf(),
1170        threshold_config: inputs.threshold_config.clone(),
1171        metric: inputs.metric,
1172        exclude: inputs.exclude.clone(),
1173        respect_gitignore: !cli.filter.no_gitignore,
1174        diff_ref: cli.filter.diff.clone(),
1175        extensions: meta.extensions_owned(),
1176        compute_diagnostics: cli
1177            .output
1178            .format
1179            .iter()
1180            .any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
1181        ..AnalyzeOptions::default()
1182    }
1183}
1184
1185fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
1186    cli: &Cli,
1187    diagnostics: &AnalysisDiagnostics<P>,
1188) {
1189    // Always warn about non-fatal issues (details require --verbose)
1190    warn_if_issues(diagnostics);
1191    if cli.display.verbose {
1192        print_diagnostics(diagnostics);
1193    }
1194}
1195
1196/// Validates inputs, merges effective config, runs the analyzer, and
1197/// resolves the optional baseline delta. The bulk of `run_inner`'s
1198/// pre-render work lives here so `run_inner` itself stays a flat dispatch.
1199///
1200/// Constructs the coverage adapter via `coverage_factory` *after*
1201/// `merge_effective_inputs` resolves the final source root, so the
1202/// coverage parser strips the correct prefix from per-file records
1203/// even when `src` came from the adapter's config TOML rather than
1204/// the CLI.
1205fn prepare_pipeline<P, F>(
1206    cli: &mut Cli,
1207    complexity: &dyn ComplexityPort,
1208    coverage_factory: F,
1209    meta: &AdapterMeta,
1210) -> Result<PipelinePrep<P>>
1211where
1212    P: ParseDiagnostic + std::fmt::Display + 'static,
1213    F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
1214{
1215    validate_display_flags(cli)?;
1216    apply_color(cli.display.color);
1217
1218    // Load config file (explicit path or auto-discovered). Path is
1219    // kept alongside the loaded config so downstream diagnostics
1220    // (e.g., unknown `--view` preset) can point the user at the
1221    // exact file to edit.
1222    let (file_config, config_path) = load_file_config(cli, meta.config_file_name)?.unzip();
1223
1224    // Resolve `--view <NAME>` before validate_view_args runs
1225    // so preset fields participate in the same validation pass as CLI
1226    // flags. `apply_preset_to_cli` mutates `cli` in place: CLI explicit
1227    // values win on `Option<T>` fields, bools OR-merge.
1228    view_args::resolve_view_preset(
1229        cli,
1230        file_config.as_ref(),
1231        config_path.as_deref(),
1232        meta.config_file_name,
1233    )?;
1234    view_args::validate_view_args(cli)?;
1235
1236    let inputs = merge_effective_inputs(cli, &file_config, meta);
1237    let coverage_path = validate_runtime_inputs(cli, &inputs, meta)?;
1238
1239    // Canonicalize the effective `src` (post-config-merge) and hand
1240    // it to the adapter's factory closure. `validate_runtime_inputs`
1241    // already gated on existence; `canonicalize_src`'s fallback path
1242    // is purely defensive against TOCTOU between the two `metadata`
1243    // calls and emits a warning on the error arm so the regression is
1244    // observable instead of silent.
1245    let src_canonical = crate::core::canonicalize_src(&inputs.src);
1246    let coverage = coverage_factory(&src_canonical);
1247
1248    // Adapter-aware pre-flight runs after construction so
1249    // `CoveragePort::validate` can apply its own structural check
1250    // (LCOV: SF/DA records; future Istanbul: non-empty statementMap)
1251    // before the full parse pass. See ADR D-coverage-validate.
1252    preflight_checks(coverage_path, &*coverage, meta)?;
1253
1254    let options = build_analyze_options(cli, &inputs, coverage_path, meta);
1255
1256    let analysis = crate::core::analyze(&options, complexity, &*coverage)?;
1257    apply_diagnostics(cli, &analysis.diagnostics);
1258
1259    // Resolve --baseline: load a previously-emitted JSON
1260    // envelope and compute the AnalysisDelta. None when --baseline is
1261    // absent — the JSON envelope omits the `delta` block entirely so
1262    // existing consumers see byte-identical output.
1263    let delta_state = load_delta_state(cli, &analysis.result)?;
1264
1265    Ok(PipelinePrep {
1266        inputs,
1267        analysis,
1268        delta_state,
1269    })
1270}
1271
1272// ── Format dispatch ────────────────────────────────────────────────
1273
1274fn format_as_json<P: ParseDiagnostic>(
1275    cli: &Cli,
1276    view: &view::AnalysisView<'_>,
1277    delta_view: Option<&DeltaView<'_>>,
1278    delta_state: Option<&DeltaState<P>>,
1279    analysis: &AnalysisOutput<P>,
1280    inputs: &EffectiveInputs,
1281    meta: &AdapterMeta,
1282) -> Result<String> {
1283    let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
1284        view: dv,
1285        baseline_tool_version: &s.snapshot.tool_version,
1286        baseline_timestamp: &s.snapshot.timestamp,
1287        baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
1288    });
1289    let config = reporters::json::JsonConfig {
1290        tool_version: meta.tool_version.to_string(),
1291        metric: inputs.metric,
1292        threshold: inputs.threshold,
1293        timestamp: now_unix_epoch(),
1294        diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
1295        diff_ref: cli.filter.diff.as_deref(),
1296        minimal_view: cli.output.minimal_view,
1297        delta: delta_ctx,
1298    };
1299    reporters::json::format_json(view, &config).map_err(Into::into)
1300}
1301
1302/// ScorecardRow projects the unshaped analysis + delta into a
1303/// `Row::CrapDelta` JSON object. View shaping does NOT
1304/// alter scorecard-row — the aggregator consumes truth, not a filtered
1305/// subset.
1306fn format_as_scorecard_row<P: ParseDiagnostic>(
1307    delta_state: Option<&DeltaState<P>>,
1308    result: &crate::domain::types::AnalysisResult,
1309    threshold: f64,
1310) -> String {
1311    let baseline_result = delta_state.map(|s| &s.snapshot.result);
1312    let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
1313    let row_data = crate::domain::summary::project_crap_delta_row(
1314        result,
1315        baseline_result,
1316        delta_inputs,
1317        threshold.round() as u32,
1318    );
1319    reporters::format_scorecard_row(&row_data)
1320}
1321
1322// 8-arg dispatch is the cost of threading `<P>` + `meta` through the
1323// format match without restructuring the per-reporter call sites
1324// (which carry heterogeneous, irreducible signatures per `adapters.md`
1325// rule 1). Bundling them into a context struct would shadow the per-arm
1326// argument list that's the whole point of this match. Tracked under v1.0
1327// follow-up for the broader cli refactor.
1328#[allow(clippy::too_many_arguments)]
1329fn render_format<P: ParseDiagnostic>(
1330    cli: &Cli,
1331    spec: &FormatSpec,
1332    view: &view::AnalysisView<'_>,
1333    delta_view: Option<&DeltaView<'_>>,
1334    delta_state: Option<&DeltaState<P>>,
1335    analysis: &AnalysisOutput<P>,
1336    inputs: &EffectiveInputs,
1337    meta: &AdapterMeta,
1338) -> Result<String> {
1339    Ok(match spec.format {
1340        FormatArg::Table => reporters::format_table_with_explain(
1341            view,
1342            delta_view,
1343            inputs.threshold,
1344            cli.display.breakdown,
1345            cli.display.explain,
1346            meta.tool_name,
1347            meta.tool_version,
1348        ),
1349        FormatArg::Json | FormatArg::Advice => {
1350            format_as_json(cli, view, delta_view, delta_state, analysis, inputs, meta)?
1351        }
1352        FormatArg::Markdown => reporters::format_markdown(
1353            view,
1354            delta_view,
1355            inputs.threshold,
1356            cli.display.breakdown,
1357            cli.display.explain,
1358            cli.display.md_full_table,
1359            cli.display.md_top,
1360            meta,
1361            inputs.metric,
1362        ),
1363        FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
1364        // SARIF is a gate translation, not a display: it iterates
1365        // `view.full.functions` internally regardless of how the View
1366        // was shaped. `--top`, `--sort-by`, `--only-failing`, and
1367        // `--baseline` do NOT alter SARIF output — PR annotations
1368        // must reflect truth.
1369        FormatArg::Sarif => reporters::format_sarif(
1370            view,
1371            meta.tool_name,
1372            meta.tool_version,
1373            meta.tool_info_uri,
1374            meta.rule_help_uri,
1375        ),
1376        FormatArg::ScorecardRow => {
1377            format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
1378        }
1379        FormatArg::Html => reporters::format_html(view, inputs.threshold, meta, inputs.metric),
1380        // GitHub Actions annotations is a gate translation like SARIF —
1381        // iterates `view.full.functions` regardless of View shaping so
1382        // PR annotations reflect the gate, not a presentation choice.
1383        // `annotation_limit` is the per-step UI cap; the reporter
1384        // appends a `::notice` summary when truncation kicks in.
1385        FormatArg::GithubAnnotations => reporters::format_github_annotations(
1386            view,
1387            meta.tool_name,
1388            meta.tool_version,
1389            inputs.annotation_limit,
1390        ),
1391    })
1392}
1393
1394fn print_formatted_output<P: ParseDiagnostic>(
1395    cli: &Cli,
1396    view: &view::AnalysisView<'_>,
1397    delta_view: Option<&DeltaView<'_>>,
1398    delta_state: Option<&DeltaState<P>>,
1399    analysis: &AnalysisOutput<P>,
1400    inputs: &EffectiveInputs,
1401    meta: &AdapterMeta,
1402) -> Result<()> {
1403    // `--summary` short-circuits `--format` dispatch entirely. `--quiet`
1404    // already gates this entire function at the caller (run_inner), so
1405    // the precedence is `--quiet > --summary > --format`. Mirrors
1406    // crap4ts's implicit precedence (its formatSummaryLine bypasses the
1407    // reporter switch when set).
1408    if cli.output.summary {
1409        let line = reporters::format_summary_line(view.full, inputs.threshold);
1410        println!("{line}");
1411        return Ok(());
1412    }
1413
1414    for spec in &cli.output.format {
1415        let output = render_format(
1416            cli,
1417            spec,
1418            view,
1419            delta_view,
1420            delta_state,
1421            analysis,
1422            inputs,
1423            meta,
1424        )?;
1425        match &spec.output {
1426            Some(path) => std::fs::write(path, &output)
1427                .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
1428            None => print!("{output}"),
1429        }
1430    }
1431
1432    // Advice's stderr summary fires once even if Advice appears multiple
1433    // times in `--format`. SARIF stays silent — its primary deliverable
1434    // is the `.sarif` file uploaded to Code Scanning; stderr would noise
1435    // up CI logs.
1436    if cli
1437        .output
1438        .format
1439        .iter()
1440        .any(|s| matches!(s.format, FormatArg::Advice))
1441    {
1442        let mut stderr = std::io::stderr();
1443        let _ = reporters::render_advice_summary(view, &mut stderr);
1444    }
1445
1446    Ok(())
1447}
1448
1449fn compute_exit_code<P: ParseDiagnostic>(
1450    cli: &Cli,
1451    passed: bool,
1452    delta_state: Option<&DeltaState<P>>,
1453) -> bool {
1454    let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
1455    let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
1456    combined_passed || cli.output.no_fail
1457}
1458
1459// ── Delta orchestration ─────────────────────────────────────────────
1460
1461/// In-flight delta state — owned baseline metadata + computed delta.
1462/// `cli/mod.rs` keeps this for the lifetime of `run_inner` so reporters
1463/// can borrow through it. Constructed once per invocation when
1464/// `--baseline` is set; absent otherwise. Generic over `P:
1465/// ParseDiagnostic` so the snapshot's `BaselineSnapshot<P>` matches the
1466/// adapter's diagnostic shape.
1467struct DeltaState<P: ParseDiagnostic> {
1468    snapshot: BaselineSnapshot<P>,
1469    delta: AnalysisDelta,
1470}
1471
1472fn load_delta_state<P: ParseDiagnostic>(
1473    cli: &Cli,
1474    current: &crate::domain::types::AnalysisResult,
1475) -> Result<Option<DeltaState<P>>> {
1476    let Some(path) = cli.input.baseline.as_ref() else {
1477        return Ok(None);
1478    };
1479    let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
1480    // delta::compute consumes both — we own snapshot.result, clone the
1481    // current analysis so the surrounding pipeline keeps its handle.
1482    let delta = delta::compute(snapshot.result.clone(), current.clone());
1483    Ok(Some(DeltaState { snapshot, delta }))
1484}
1485
1486fn validate_display_flags(cli: &Cli) -> Result<()> {
1487    let any_table = cli
1488        .output
1489        .format
1490        .iter()
1491        .any(|s| matches!(s.format, FormatArg::Table));
1492    if cli.display.explain && any_table && !cli.display.breakdown {
1493        bail!("--explain requires --breakdown for table output");
1494    }
1495    validate_format_destinations(&cli.output.format)?;
1496    Ok(())
1497}
1498
1499/// Multi-format invocations may contain at most one stdout-targeted
1500/// spec (the rest must specify a file). Two stdout sinks would
1501/// interleave indistinguishably, but a single stdout sink alongside
1502/// any number of file sinks is unambiguous and is the shape composite
1503/// CI workflows need (e.g. `markdown:scorecard.md,github-annotations`
1504/// where the markdown becomes the scorecard artefact and
1505/// `github-annotations` workflow commands are intercepted from
1506/// stdout by the GitHub Actions runner).
1507fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
1508    if specs.len() > 1 {
1509        let stdout_specs: Vec<_> = specs
1510            .iter()
1511            .filter(|s| s.output.is_none())
1512            .map(|s| format_arg_kebab(s.format).to_string())
1513            .collect();
1514        if stdout_specs.len() > 1 {
1515            bail!(
1516                "multi-format `--format` allows at most one stdout entry (the rest must specify a file, e.g. `json:envelope.json`); stdout entries: {}",
1517                stdout_specs.join(", ")
1518            );
1519        }
1520    }
1521    Ok(())
1522}
1523
1524/// User-facing kebab-case name for a `FormatArg` (matches the clap CLI
1525/// surface `--format X`). Defaults to `Debug` lowercased if clap's
1526/// `ValueEnum` registry can't resolve a name.
1527fn format_arg_kebab(arg: FormatArg) -> String {
1528    use clap::ValueEnum;
1529    arg.to_possible_value()
1530        .map(|v| v.get_name().to_string())
1531        .unwrap_or_else(|| format!("{arg:?}").to_lowercase())
1532}
1533
1534// ── Config loading & merging ───────────────────────────────────────
1535
1536/// Load the on-disk config file (explicit `--config` path or
1537/// auto-discovered by adapter convention) and return it paired with
1538/// the path it came from. The path is threaded into downstream error
1539/// hints (e.g., the `--view` unknown-preset diagnostic) so the user
1540/// sees the exact file to edit — not just the conventional name.
1541fn load_file_config(
1542    cli: &Cli,
1543    config_file_name: &str,
1544) -> Result<Option<(FileConfig, std::path::PathBuf)>> {
1545    if let Some(path) = &cli.input.config {
1546        let cfg = config::load_config(path)?;
1547        Ok(Some((cfg, path.clone())))
1548    } else {
1549        match config::discover_config(config_file_name)? {
1550            Some(path) => {
1551                let cfg = config::load_config(&path)?;
1552                Ok(Some((cfg, path)))
1553            }
1554            None => Ok(None),
1555        }
1556    }
1557}
1558
1559/// Merge CLI threshold with config file. Returns (ThresholdConfig, effective_display_threshold).
1560///
1561/// Resolution order (first match wins). Every tier-derived value is
1562/// keyed on the resolved `metric` via [`ThresholdPreset::threshold`],
1563/// so a cutoff calibrated for one metric is never applied to the
1564/// other metric's (different-magnitude) scores. `metric` is the
1565/// already-resolved effective metric (CLI > config > adapter default).
1566/// A literal cutoff (an explicit number) always beats a named preset
1567/// at the same level of specificity, because a literal is the most
1568/// specific expression of intent: CLI `--threshold N` beats CLI
1569/// `--strict`/`--lenient`, and config `threshold = N` beats config
1570/// `preset = "..."`. This keeps CLI and config-file semantics
1571/// consistent — a user who writes an explicit number gets that number.
1572///
1573/// 1. `--threshold N`   — explicit CLI value (a literal cutoff; metric-independent)
1574/// 2. `--strict`        → `ThresholdPreset::Strict.threshold(metric)`
1575/// 3. `--lenient`       → `ThresholdPreset::Lenient.threshold(metric)`
1576/// 4. config `threshold` — explicit literal cutoff (metric-independent)
1577/// 5. config `preset`   → `preset.threshold(metric)`
1578/// 6. no-flag default   → `ThresholdPreset::Default.threshold(metric)`
1579///    (cyclomatic-metric runs → 16; cognitive-metric runs → 25)
1580fn merge_threshold(
1581    cli: &Cli,
1582    file_config: &Option<FileConfig>,
1583    metric: ComplexityMetric,
1584) -> (ThresholdConfig, f64) {
1585    let global = cli
1586        .output
1587        .threshold
1588        .or_else(|| {
1589            cli.output
1590                .strict
1591                .then(|| ThresholdPreset::Strict.threshold(metric))
1592        })
1593        .or_else(|| {
1594            cli.output
1595                .lenient
1596                .then(|| ThresholdPreset::Lenient.threshold(metric))
1597        })
1598        .or_else(|| file_config.as_ref().and_then(|c| c.threshold))
1599        .or_else(|| {
1600            file_config
1601                .as_ref()
1602                .and_then(|c| c.preset)
1603                .map(|p| p.threshold(metric))
1604        })
1605        .unwrap_or(ThresholdPreset::Default.threshold(metric));
1606
1607    let overrides = file_config
1608        .as_ref()
1609        .map(|fc| fc.overrides.clone())
1610        .unwrap_or_default();
1611
1612    let config = ThresholdConfig { global, overrides };
1613    (config, global)
1614}
1615
1616/// Build the effective exclude list applied to the source walker:
1617/// `meta.forced_excludes` first (adapter-mandated, structural skips
1618/// like `**/*.d.ts` for crap4ts — see `AdapterMeta::forced_excludes`),
1619/// then CLI `--exclude` flags, then patterns from the user's config
1620/// file. Duplicates are dropped so the override builder doesn't see
1621/// the same pattern twice. Order matters because `ignore::overrides`
1622/// is order-sensitive on shadowed patterns; forced excludes lead so a
1623/// user can't accidentally re-include a structurally-skipped file.
1624fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>, meta: &AdapterMeta) -> Vec<String> {
1625    let mut exclude: Vec<String> = meta
1626        .forced_excludes
1627        .iter()
1628        .map(|s| (*s).to_string())
1629        .collect();
1630    let mut seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
1631    for pattern in &cli.filter.exclude {
1632        if seen.insert(pattern.clone()) {
1633            exclude.push(pattern.clone());
1634        }
1635    }
1636    if let Some(fc) = file_config
1637        && let Some(fc_exclude) = &fc.exclude
1638    {
1639        for pattern in fc_exclude {
1640            if seen.insert(pattern.clone()) {
1641                exclude.push(pattern.clone());
1642            }
1643        }
1644    }
1645    exclude
1646}
1647
1648// ── Validation ──────────────────────────────────────────────────────
1649
1650fn validate_inputs(
1651    coverage: &std::path::Path,
1652    src: &std::path::Path,
1653    threshold: f64,
1654    coverage_hint: &str,
1655) -> Result<()> {
1656    match std::fs::metadata(coverage) {
1657        Ok(m) if m.is_file() => {}
1658        Ok(_) => bail!(
1659            "coverage path is not a file: {}\n  \
1660             hint: pass --coverage pointing to a coverage file, not a directory",
1661            coverage.display()
1662        ),
1663        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1664            "coverage file not found: {}\n  hint: {coverage_hint}",
1665            coverage.display()
1666        ),
1667        Err(e) => bail!(
1668            "cannot access coverage file: {}: {e}\n  \
1669             hint: check file permissions",
1670            coverage.display()
1671        ),
1672    }
1673    match std::fs::metadata(src) {
1674        Ok(m) if m.is_dir() => {}
1675        Ok(_) => bail!(
1676            "source path is not a directory: {}\n  \
1677             hint: pass --src <DIR> pointing to your source root",
1678            src.display()
1679        ),
1680        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1681            "source directory not found: {}\n  \
1682             hint: pass --src <DIR> pointing to your source root",
1683            src.display()
1684        ),
1685        Err(e) => bail!(
1686            "cannot access source directory: {}: {e}\n  \
1687             hint: check directory permissions",
1688            src.display()
1689        ),
1690    }
1691    if !is_valid_threshold(threshold) {
1692        bail!(
1693            "threshold must be a finite positive number, got: {}",
1694            threshold
1695        );
1696    }
1697    Ok(())
1698}
1699
1700// ── Diff validation ────────────────────────────────────────────────
1701
1702fn validate_diff_ref(diff_ref: &str) -> Result<()> {
1703    if diff_ref.is_empty() {
1704        bail!("invalid diff ref: ref must not be empty");
1705    }
1706    if diff_ref.starts_with('-') {
1707        bail!(
1708            "invalid diff ref: {diff_ref}\n  \
1709             hint: ref must not start with a dash"
1710        );
1711    }
1712    Ok(())
1713}
1714
1715fn preflight_git_worktree(src: &Path) -> Result<()> {
1716    let output = std::process::Command::new("git")
1717        .current_dir(src)
1718        .args(["rev-parse", "--is-inside-work-tree"])
1719        .output();
1720
1721    match output {
1722        Ok(o) if o.status.success() => Ok(()),
1723        Ok(o) => {
1724            let stderr = String::from_utf8_lossy(&o.stderr);
1725            bail!(
1726                "not inside a git work tree\n  \
1727                 hint: --diff requires a git repository\n  \
1728                 git: {stderr}",
1729            );
1730        }
1731        Err(e) => bail!(
1732            "not inside a git work tree\n  \
1733             hint: --diff requires git to be installed\n  \
1734             error: {e}",
1735        ),
1736    }
1737}
1738
1739// ── Pre-flight checks ──────────────────────────────────────────────
1740
1741/// Adapter-aware coverage pre-flight: read the coverage file once and
1742/// delegate the structural check to `CoveragePort::validate`. The
1743/// source-directory check is handled by `core::ensure_source_files_found`
1744/// during the analyze pipeline — see ADR D-preflight-walker-reconcile.
1745fn preflight_checks<P>(
1746    coverage: &std::path::Path,
1747    coverage_port: &dyn CoveragePort<Diagnostic = P>,
1748    meta: &AdapterMeta,
1749) -> Result<()>
1750where
1751    P: ParseDiagnostic,
1752{
1753    check_coverage_has_data(coverage, coverage_port, meta.coverage_hint)
1754}
1755
1756fn check_coverage_has_data<P>(
1757    path: &std::path::Path,
1758    coverage_port: &dyn CoveragePort<Diagnostic = P>,
1759    coverage_hint: &str,
1760) -> Result<()>
1761where
1762    P: ParseDiagnostic,
1763{
1764    // The adapter's `validate` streams the file itself (LCOV) or
1765    // slurps (Istanbul, post-implementation) — whichever is cheaper
1766    // for that format. We do NOT pre-read here: `core::analyze` will
1767    // read the file again for the full parse pass, and slurping twice
1768    // for large workspaces (100 MB+ LCOV) is a memory regression.
1769    //
1770    // The validation reason (e.g. `"no SF/DA records"`) is surfaced
1771    // alongside the path so the user knows whether the file was
1772    // syntactically empty, malformed, or just missing data points.
1773    if let Err(reason) = coverage_port.validate(path) {
1774        bail!(
1775            "no coverage data found in {} ({reason})\n  hint: {}",
1776            path.display(),
1777            coverage_hint,
1778        );
1779    }
1780    Ok(())
1781}
1782
1783// ── Timestamp ──────────────────────────────────────────────────────
1784
1785fn now_unix_epoch() -> String {
1786    let secs = SystemTime::now()
1787        .duration_since(SystemTime::UNIX_EPOCH)
1788        .unwrap_or_default()
1789        .as_secs();
1790    format!("{secs}")
1791}
1792
1793// ── Verbose diagnostics ────────────────────────────────────────────
1794
1795fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
1796    files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
1797}
1798
1799fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
1800    if !diag.parse_diagnostics.is_empty() {
1801        eprintln!(
1802            "warning: {} coverage parse issue(s) encountered (use --verbose for details)",
1803            diag.parse_diagnostics.len()
1804        );
1805    }
1806    if diag.files_unparseable > 0 {
1807        eprintln!(
1808            "warning: {} source file(s) could not be parsed (use --verbose for details)",
1809            diag.files_unparseable
1810        );
1811    }
1812    if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
1813        eprintln!(
1814            "warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
1815            diag.files_zero_coverage, diag.files_analyzed
1816        );
1817        eprintln!(
1818            "  hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
1819        );
1820        eprintln!(
1821            "  hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
1822        );
1823    }
1824}
1825
1826fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
1827    eprintln!(
1828        "verbose: file discovery: {} files found, {} unparseable",
1829        diag.files_found, diag.files_unparseable
1830    );
1831    eprintln!(
1832        "verbose: complexity: {} functions extracted",
1833        diag.functions_extracted
1834    );
1835    eprintln!(
1836        "verbose: matching: {} matched with coverage, {} without coverage data",
1837        diag.functions_matched, diag.functions_no_coverage
1838    );
1839    eprintln!(
1840        "verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
1841        diag.files_analyzed, diag.files_zero_coverage
1842    );
1843    if !diag.parse_diagnostics.is_empty() {
1844        eprintln!(
1845            "verbose: coverage parse diagnostics ({}):",
1846            diag.parse_diagnostics.len()
1847        );
1848        for d in &diag.parse_diagnostics {
1849            eprintln!("  {d}");
1850        }
1851    }
1852}
1853
1854// ── Shell completions ───────────────────────────────────────────────
1855
1856/// Print a shell completion script to stdout for the given shell.
1857/// `clap_complete::generate` covers POSIX shells + PowerShell + Elvish;
1858/// nushell uses the separate `clap_complete_nushell` crate.
1859///
1860/// `bin_name` is the adapter binary's name (`crap4rs`, future
1861/// `crap4ts`, …) inferred at runtime from `argv[0]` — generated
1862/// completion scripts should reference the binary the user invoked,
1863/// not crap-core's library name.
1864fn emit_completions(shell: ShellArg, bin_name: &str) {
1865    let mut cmd = Cli::command();
1866    let stdout = &mut std::io::stdout();
1867    match shell {
1868        ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
1869        ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
1870        ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
1871        ShellArg::Powershell => {
1872            clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
1873        }
1874        ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
1875        ShellArg::Nushell => {
1876            clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
1877        }
1878    }
1879}
1880
1881// ── Color wiring ────────────────────────────────────────────────────
1882
1883fn apply_color(choice: ColorArg) {
1884    match choice {
1885        ColorArg::Auto => colored::control::unset_override(),
1886        ColorArg::Always => colored::control::set_override(true),
1887        ColorArg::Never => colored::control::set_override(false),
1888    }
1889}
1890
1891// ── Tests ───────────────────────────────────────────────────────────
1892
1893#[cfg(test)]
1894mod tests {
1895    use super::*;
1896    // `DEFAULT_THRESHOLD` is no longer referenced by `merge_threshold`
1897    // (it reads `meta.default_threshold` post-#218); only the tests
1898    // assert against the value crap4rs's `AdapterMeta` carries.
1899    use crate::domain::threshold::DEFAULT_THRESHOLD;
1900    use std::path::Path;
1901
1902    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1903        // argv[0] is a clap placeholder — kept adapter-agnostic
1904        // (`"test-adapter"`, not any real adapter binary's name) so
1905        // crap-core source has zero hardcoded references to its
1906        // consumers.
1907        let mut full = vec!["test-adapter"];
1908        full.extend_from_slice(args);
1909        Cli::try_parse_from(full)
1910    }
1911
1912    #[test]
1913    fn no_args_parses_with_coverage_none() {
1914        // `--coverage` is enforced at runtime via run_inner (so that
1915        // the `completions` subcommand can skip it), not at clap parse
1916        // time. Bare `crap4rs` therefore parses successfully here but
1917        // would `bail!` once dispatched.
1918        let cli = parse(&[]).unwrap();
1919        assert!(cli.input.coverage.is_none());
1920        assert!(cli.command.is_none());
1921    }
1922
1923    #[test]
1924    fn minimal_valid_args() {
1925        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1926        assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
1927        assert_eq!(cli.input.src, None);
1928    }
1929
1930    #[test]
1931    fn completions_subcommand_does_not_require_coverage() {
1932        let cli = parse(&["completions", "bash"]).unwrap();
1933        assert!(matches!(
1934            cli.command,
1935            Some(Command::Completions {
1936                shell: ShellArg::Bash
1937            })
1938        ));
1939        assert!(cli.input.coverage.is_none());
1940    }
1941
1942    #[test]
1943    fn default_metric_is_none() {
1944        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1945        assert!(cli.input.metric.is_none());
1946    }
1947
1948    #[test]
1949    fn default_format_is_table() {
1950        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1951        assert_eq!(cli.output.format.len(), 1);
1952        assert!(matches!(cli.output.format[0].format, FormatArg::Table));
1953        assert!(cli.output.format[0].output.is_none());
1954    }
1955
1956    #[test]
1957    fn default_threshold_is_none() {
1958        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1959        assert!(cli.output.threshold.is_none());
1960    }
1961
1962    #[test]
1963    fn default_color_is_auto() {
1964        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1965        assert!(matches!(cli.display.color, ColorArg::Auto));
1966    }
1967
1968    #[test]
1969    fn metric_cyclomatic() {
1970        let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
1971        assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
1972    }
1973
1974    #[test]
1975    fn format_json() {
1976        let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
1977        assert_eq!(cli.output.format.len(), 1);
1978        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1979        assert!(cli.output.format[0].output.is_none());
1980    }
1981
1982    #[test]
1983    fn format_sarif() {
1984        let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
1985        assert_eq!(cli.output.format.len(), 1);
1986        assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
1987    }
1988
1989    #[test]
1990    fn format_with_file_destination() {
1991        let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
1992        assert_eq!(cli.output.format.len(), 1);
1993        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1994        assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1995    }
1996
1997    #[test]
1998    fn format_multi_with_files() {
1999        let cli = parse(&[
2000            "--coverage",
2001            "lcov.info",
2002            "--format",
2003            "json:env.json,markdown:report.md",
2004        ])
2005        .unwrap();
2006        assert_eq!(cli.output.format.len(), 2);
2007        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
2008        assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
2009        assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
2010        assert_eq!(
2011            cli.output.format[1].output,
2012            Some(PathBuf::from("report.md"))
2013        );
2014    }
2015
2016    #[test]
2017    fn format_multi_with_two_stdout_specs_rejected() {
2018        let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
2019        let err = validate_display_flags(&cli).unwrap_err();
2020        let msg = err.to_string();
2021        assert!(msg.contains("multi-format"), "got: {msg}");
2022        assert!(msg.contains("stdout"), "got: {msg}");
2023        assert!(
2024            msg.contains("json"),
2025            "msg should name the stdout specs: {msg}"
2026        );
2027        assert!(
2028            msg.contains("markdown"),
2029            "msg should name the stdout specs: {msg}"
2030        );
2031    }
2032
2033    #[test]
2034    fn format_multi_with_single_stdout_plus_file_accepted() {
2035        // Locked shape D1 for github-annotations: composite workflows
2036        // emit `markdown:scorecard.md,github-annotations` in one
2037        // invocation. The validator permits exactly one stdout sink
2038        // alongside any number of file sinks.
2039        let cli = parse(&[
2040            "--coverage",
2041            "lcov.info",
2042            "--format",
2043            "markdown:scorecard.md,github-annotations",
2044        ])
2045        .unwrap();
2046        assert!(validate_display_flags(&cli).is_ok());
2047    }
2048
2049    #[test]
2050    fn format_multi_with_three_stdout_specs_rejected() {
2051        let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown,csv"]).unwrap();
2052        let err = validate_display_flags(&cli).unwrap_err();
2053        let msg = err.to_string();
2054        assert!(
2055            msg.contains("at most one stdout"),
2056            "rejection must name the rule, got: {msg}"
2057        );
2058    }
2059
2060    #[test]
2061    fn format_empty_path_rejected() {
2062        let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
2063        let msg = format!("{err}");
2064        assert!(msg.contains("empty file path"));
2065    }
2066
2067    #[test]
2068    fn custom_threshold() {
2069        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
2070        assert_eq!(cli.output.threshold, Some(15.5));
2071    }
2072
2073    #[test]
2074    fn custom_src() {
2075        let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2076        assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
2077    }
2078
2079    #[test]
2080    fn exclude_repeatable() {
2081        let cli = parse(&[
2082            "--coverage",
2083            "lcov.info",
2084            "--exclude",
2085            "tests/**",
2086            "--exclude",
2087            "benches/**",
2088        ])
2089        .unwrap();
2090        assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
2091    }
2092
2093    #[test]
2094    fn no_gitignore_flag() {
2095        let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
2096        assert!(cli.filter.no_gitignore);
2097    }
2098
2099    #[test]
2100    fn only_failing_flag() {
2101        let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
2102        assert!(cli.filter.only_failing);
2103    }
2104
2105    #[test]
2106    fn group_by_file_parses() {
2107        let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
2108        assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
2109    }
2110
2111    #[test]
2112    fn group_by_absence_is_none() {
2113        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2114        assert!(cli.filter.group_by.is_none());
2115    }
2116
2117    #[test]
2118    fn group_by_invalid_value_rejected() {
2119        let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
2120        let msg = err.to_string();
2121        assert!(msg.contains("invalid value"), "expected clap error: {msg}");
2122        assert!(
2123            msg.contains("--group-by") || msg.contains("module"),
2124            "error should attribute to --group-by: {msg}"
2125        );
2126    }
2127
2128    #[test]
2129    fn group_by_arg_to_domain_file() {
2130        let domain: GroupKey = GroupByArg::File.into();
2131        assert_eq!(domain, GroupKey::File);
2132    }
2133
2134    #[test]
2135    fn verbose_flag() {
2136        let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
2137        assert!(cli.display.verbose);
2138    }
2139
2140    #[test]
2141    fn quiet_flag() {
2142        let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
2143        assert!(cli.display.quiet);
2144    }
2145
2146    #[test]
2147    fn color_always() {
2148        let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
2149        assert!(matches!(cli.display.color, ColorArg::Always));
2150    }
2151
2152    #[test]
2153    fn color_never() {
2154        let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
2155        assert!(matches!(cli.display.color, ColorArg::Never));
2156    }
2157
2158    #[test]
2159    fn invalid_metric_rejected() {
2160        let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
2161        assert!(err.to_string().contains("invalid value"));
2162    }
2163
2164    #[test]
2165    fn invalid_format_rejected() {
2166        let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
2167        assert!(err.to_string().contains("invalid value"));
2168    }
2169
2170    #[test]
2171    fn metric_arg_to_domain_cognitive() {
2172        let domain: ComplexityMetric = MetricArg::Cognitive.into();
2173        assert_eq!(domain, ComplexityMetric::Cognitive);
2174    }
2175
2176    #[test]
2177    fn metric_arg_to_domain_cyclomatic() {
2178        let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
2179        assert_eq!(domain, ComplexityMetric::Cyclomatic);
2180    }
2181
2182    #[test]
2183    fn validate_missing_coverage_file_uses_adapter_hint() {
2184        let err = validate_inputs(
2185            Path::new("nonexistent.info"),
2186            Path::new("src"),
2187            DEFAULT_THRESHOLD,
2188            "run `cargo llvm-cov --lcov --output-path lcov.info` first",
2189        )
2190        .unwrap_err();
2191        let msg = format!("{err:#}");
2192        assert!(msg.contains("coverage file not found"));
2193        // Adapter-supplied hint flows through; crap-core itself stays neutral.
2194        assert!(msg.contains("cargo llvm-cov"));
2195    }
2196
2197    #[test]
2198    fn validate_missing_src_dir() {
2199        let err = validate_inputs(
2200            Path::new("Cargo.toml"),
2201            Path::new("nonexistent_dir"),
2202            DEFAULT_THRESHOLD,
2203            "test-hint",
2204        )
2205        .unwrap_err();
2206        let msg = format!("{err:#}");
2207        assert!(msg.contains("source directory not found"));
2208    }
2209
2210    #[test]
2211    fn validate_negative_threshold() {
2212        let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0, "test-hint")
2213            .unwrap_err();
2214        let msg = format!("{err:#}");
2215        assert!(msg.contains("threshold must be a finite positive number"));
2216    }
2217
2218    #[test]
2219    fn validate_zero_threshold() {
2220        let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0, "test-hint")
2221            .unwrap_err();
2222        let msg = format!("{err:#}");
2223        assert!(msg.contains("threshold must be a finite positive number"));
2224    }
2225
2226    #[test]
2227    fn validate_infinity_threshold() {
2228        let err = validate_inputs(
2229            Path::new("Cargo.toml"),
2230            Path::new("src"),
2231            f64::INFINITY,
2232            "test-hint",
2233        )
2234        .unwrap_err();
2235        let msg = format!("{err:#}");
2236        assert!(msg.contains("threshold must be a finite positive number"));
2237    }
2238
2239    #[test]
2240    fn validate_src_is_file_not_dir() {
2241        let err = validate_inputs(
2242            Path::new("Cargo.toml"),
2243            Path::new("Cargo.toml"),
2244            DEFAULT_THRESHOLD,
2245            "test-hint",
2246        )
2247        .unwrap_err();
2248        let msg = format!("{err:#}");
2249        assert!(msg.contains("source path is not a directory"));
2250    }
2251
2252    #[test]
2253    fn validate_coverage_is_dir_not_file() {
2254        let err = validate_inputs(
2255            Path::new("src"),
2256            Path::new("src"),
2257            DEFAULT_THRESHOLD,
2258            "test-hint",
2259        )
2260        .unwrap_err();
2261        let msg = format!("{err:#}");
2262        assert!(msg.contains("coverage path is not a file"));
2263    }
2264
2265    #[test]
2266    fn format_short_flag() {
2267        let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
2268        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
2269    }
2270
2271    #[test]
2272    fn config_flag_accepts_path() {
2273        let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
2274        assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
2275    }
2276
2277    #[test]
2278    fn config_flag_defaults_to_none() {
2279        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2280        assert_eq!(cli.input.config, None);
2281    }
2282
2283    #[test]
2284    fn view_flag_accepts_name() {
2285        let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
2286        assert_eq!(cli.input.view, Some("ci".to_string()));
2287    }
2288
2289    #[test]
2290    fn view_flag_defaults_to_none() {
2291        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2292        assert_eq!(cli.input.view, None);
2293    }
2294
2295    #[test]
2296    fn merge_threshold_cli_overrides_config() {
2297        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2298        let file_config = Some(FileConfig {
2299            threshold: Some(10.0),
2300            ..FileConfig::default()
2301        });
2302        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2303        assert_eq!(config.global, 15.0);
2304        assert_eq!(display, 15.0);
2305    }
2306
2307    #[test]
2308    fn merge_threshold_uses_config_when_cli_default() {
2309        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2310        let file_config = Some(FileConfig {
2311            threshold: Some(12.0),
2312            ..FileConfig::default()
2313        });
2314        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2315        assert_eq!(config.global, 12.0);
2316        assert_eq!(display, 12.0);
2317    }
2318
2319    #[test]
2320    fn merge_threshold_preserves_overrides() {
2321        use crate::domain::threshold::ThresholdOverride;
2322        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2323        let file_config = Some(FileConfig {
2324            threshold: Some(10.0),
2325            overrides: vec![ThresholdOverride {
2326                pattern: "domain/**".to_string(),
2327                threshold: 5.0,
2328            }],
2329            ..FileConfig::default()
2330        });
2331        let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2332        assert_eq!(config.overrides.len(), 1);
2333        assert_eq!(config.overrides[0].pattern, "domain/**");
2334    }
2335
2336    #[test]
2337    fn merge_threshold_no_config() {
2338        let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
2339        let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2340        assert_eq!(config.global, 20.0);
2341        assert!(config.overrides.is_empty());
2342        assert_eq!(display, 20.0);
2343    }
2344
2345    #[test]
2346    fn merge_threshold_explicit_default_overrides_config() {
2347        // User explicitly passes --threshold 15.0 (same as DEFAULT_THRESHOLD).
2348        // This MUST override the config file's threshold of 12.0.
2349        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2350        let file_config = Some(FileConfig {
2351            threshold: Some(12.0),
2352            ..FileConfig::default()
2353        });
2354        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2355        assert_eq!(
2356            config.global, 15.0,
2357            "explicit CLI default must override config"
2358        );
2359        assert_eq!(display, 15.0);
2360    }
2361
2362    #[test]
2363    fn merge_threshold_no_flag_default_is_metric_keyed() {
2364        // No-flag/no-config fallthrough is the `Default` tier resolved
2365        // against the effective metric. Both columns currently hold 15
2366        // (post-#272 alignment); the metric-keyed routing path is
2367        // exercised so a future per-metric divergence surfaces here.
2368        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2369        let (cog, cog_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2370        assert_eq!(cog.global, 15.0);
2371        assert_eq!(cog_disp, 15.0);
2372        let (cyc, cyc_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cyclomatic);
2373        assert_eq!(cyc.global, 15.0);
2374        assert_eq!(cyc_disp, 15.0);
2375    }
2376
2377    #[test]
2378    fn merge_threshold_strict_lenient_are_metric_keyed() {
2379        // `--strict` / `--lenient` resolve per metric. Columns are
2380        // flat-equal post-#272; the metric-keyed routing path is still
2381        // exercised so a future per-metric divergence surfaces here.
2382        let strict = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2383        assert_eq!(
2384            merge_threshold(&strict, &None, ComplexityMetric::Cognitive).1,
2385            8.0
2386        );
2387        assert_eq!(
2388            merge_threshold(&strict, &None, ComplexityMetric::Cyclomatic).1,
2389            8.0
2390        );
2391        let lenient = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2392        assert_eq!(
2393            merge_threshold(&lenient, &None, ComplexityMetric::Cognitive).1,
2394            25.0
2395        );
2396        assert_eq!(
2397            merge_threshold(&lenient, &None, ComplexityMetric::Cyclomatic).1,
2398            25.0
2399        );
2400    }
2401
2402    #[test]
2403    fn merge_exclude_combines_cli_and_config() {
2404        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2405        let file_config = Some(FileConfig {
2406            exclude: Some(vec!["benches/**".to_string()]),
2407            ..FileConfig::default()
2408        });
2409        let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2410        assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2411    }
2412
2413    #[test]
2414    fn merge_exclude_deduplicates() {
2415        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2416        let file_config = Some(FileConfig {
2417            exclude: Some(vec!["tests/**".to_string()]),
2418            ..FileConfig::default()
2419        });
2420        let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2421        assert_eq!(exclude, vec!["tests/**"]);
2422    }
2423
2424    /// `AdapterMeta::forced_excludes` patterns are prepended to the
2425    /// effective exclude list at analysis time so an adapter can
2426    /// structurally skip files that are never source code in its
2427    /// language (`**/*.d.ts` for crap4ts — crap-rs#253). CLI flags and
2428    /// config-file entries layer on top, with duplicates dropped.
2429    #[test]
2430    fn merge_exclude_prepends_forced_excludes_from_adapter_meta() {
2431        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2432        let file_config = Some(FileConfig {
2433            exclude: Some(vec!["benches/**".to_string()]),
2434            ..FileConfig::default()
2435        });
2436        let meta = AdapterMeta {
2437            forced_excludes: &["**/*.d.ts"],
2438            ..fake_meta()
2439        };
2440        let exclude = merge_exclude(&cli, &file_config, &meta);
2441        assert_eq!(exclude, vec!["**/*.d.ts", "tests/**", "benches/**"]);
2442    }
2443
2444    /// Empty `forced_excludes` (crap4rs's setting today) is a no-op —
2445    /// the merge is identical to the pre-#253 behavior of CLI then
2446    /// file-config patterns.
2447    #[test]
2448    fn merge_exclude_with_empty_forced_excludes_matches_legacy_behavior() {
2449        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2450        let file_config = Some(FileConfig {
2451            exclude: Some(vec!["benches/**".to_string()]),
2452            ..FileConfig::default()
2453        });
2454        let meta = AdapterMeta {
2455            forced_excludes: &[],
2456            ..fake_meta()
2457        };
2458        let exclude = merge_exclude(&cli, &file_config, &meta);
2459        assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2460    }
2461
2462    /// A `forced_excludes` pattern already present in CLI flags is
2463    /// deduplicated — appears once, in its forced-prefix position.
2464    #[test]
2465    fn merge_exclude_forced_excludes_deduplicates_against_cli_and_config() {
2466        let cli = parse(&["--coverage", "lcov.info", "--exclude", "**/*.d.ts"]).unwrap();
2467        let file_config = Some(FileConfig {
2468            exclude: Some(vec!["**/*.d.ts".to_string(), "benches/**".to_string()]),
2469            ..FileConfig::default()
2470        });
2471        let meta = AdapterMeta {
2472            forced_excludes: &["**/*.d.ts"],
2473            ..fake_meta()
2474        };
2475        let exclude = merge_exclude(&cli, &file_config, &meta);
2476        assert_eq!(exclude, vec!["**/*.d.ts", "benches/**"]);
2477    }
2478
2479    // ── --diff flag tests ───────────────────────────────────────────
2480
2481    #[test]
2482    fn diff_flag_accepts_ref() {
2483        let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
2484        assert_eq!(cli.filter.diff, Some("main".to_string()));
2485    }
2486
2487    #[test]
2488    fn diff_flag_defaults_to_none() {
2489        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2490        assert_eq!(cli.filter.diff, None);
2491    }
2492
2493    #[test]
2494    fn diff_flag_accepts_commit_sha() {
2495        let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
2496        assert_eq!(cli.filter.diff, Some("abc123".to_string()));
2497    }
2498
2499    #[test]
2500    fn diff_flag_accepts_head_tilde() {
2501        let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
2502        assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
2503    }
2504
2505    #[test]
2506    fn validate_diff_ref_rejects_empty_string() {
2507        let err = validate_diff_ref("").unwrap_err();
2508        let msg = format!("{err:#}");
2509        assert!(msg.contains("must not be empty"));
2510    }
2511
2512    #[test]
2513    fn validate_diff_ref_rejects_dash_prefix() {
2514        let err = validate_diff_ref("--malicious").unwrap_err();
2515        let msg = format!("{err:#}");
2516        assert!(msg.contains("invalid diff ref"));
2517        assert!(msg.contains("must not start with a dash"));
2518    }
2519
2520    #[test]
2521    fn validate_diff_ref_accepts_normal_ref() {
2522        assert!(validate_diff_ref("main").is_ok());
2523        assert!(validate_diff_ref("HEAD~1").is_ok());
2524        assert!(validate_diff_ref("abc123").is_ok());
2525    }
2526
2527    #[test]
2528    fn preflight_git_worktree_passes_in_git_repo() {
2529        // Initialize a fresh git repo in a temp dir so the test is self-contained
2530        // and works under tools (e.g. cargo-mutants) that copy the source tree
2531        // without `.git`.
2532        let tmp = tempfile::tempdir().unwrap();
2533        let status = std::process::Command::new("git")
2534            .arg("init")
2535            .arg("--quiet")
2536            .current_dir(tmp.path())
2537            .status()
2538            .expect("git init");
2539        assert!(status.success(), "git init failed");
2540        assert!(preflight_git_worktree(tmp.path()).is_ok());
2541    }
2542
2543    #[test]
2544    fn breakdown_flag_parsed() {
2545        let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
2546        assert!(cli.display.breakdown);
2547    }
2548
2549    #[test]
2550    fn breakdown_flag_default_false() {
2551        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2552        assert!(!cli.display.breakdown);
2553    }
2554
2555    #[test]
2556    fn explain_flag_parsed() {
2557        let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2558        assert!(cli.display.explain);
2559    }
2560
2561    #[test]
2562    fn explain_flag_default_false() {
2563        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2564        assert!(!cli.display.explain);
2565    }
2566
2567    #[test]
2568    fn explain_requires_breakdown_for_table_output() {
2569        let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2570        let err = validate_display_flags(&cli).unwrap_err();
2571        let msg = err.to_string();
2572        assert!(msg.contains("--breakdown"));
2573        assert!(msg.contains("--explain"));
2574    }
2575
2576    #[test]
2577    fn explain_allowed_for_json_output() {
2578        let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
2579        assert!(validate_display_flags(&cli).is_ok());
2580    }
2581
2582    #[test]
2583    fn color_overrides_set_global_state() {
2584        // Combined into one test to avoid nondeterministic interleaving —
2585        // colored::control uses a process-global flag that parallel tests
2586        // can race on.
2587        apply_color(ColorArg::Never);
2588        assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
2589
2590        apply_color(ColorArg::Always);
2591        assert!(colored::control::SHOULD_COLORIZE.should_colorize());
2592
2593        apply_color(ColorArg::Auto);
2594    }
2595
2596    // ── Pre-flight check tests ─────────────────────────────────────────
2597
2598    // Synthetic adapter values for tests — match the placeholder used
2599    // throughout the in-crate test suite. Real adapters supply real
2600    // values via `AdapterMeta`.
2601    const TEST_COVERAGE_HINT: &str =
2602        "ensure tests ran with coverage enabled (test-tool's `--coverage` flag)";
2603
2604    /// Stub `CoveragePort` whose `validate` returns whatever the caller
2605    /// configured. `parse` panics — these tests exercise the CLI-layer
2606    /// preflight wrapper, not the adapter's parsing path.
2607    struct StubCoveragePort {
2608        validate_result: Result<(), String>,
2609    }
2610
2611    impl CoveragePort for StubCoveragePort {
2612        type Diagnostic = crate::test_strategies::DummyParseDiagnostic;
2613
2614        fn parse(
2615            &self,
2616            _path: &std::path::Path,
2617        ) -> Result<crate::ports::ParseOutput<Self::Diagnostic>, crate::domain::types::CrapError>
2618        {
2619            unreachable!("preflight tests never invoke parse")
2620        }
2621
2622        fn validate(&self, _path: &std::path::Path) -> Result<(), String> {
2623            self.validate_result.clone()
2624        }
2625    }
2626
2627    fn stub_ok() -> StubCoveragePort {
2628        StubCoveragePort {
2629            validate_result: Ok(()),
2630        }
2631    }
2632
2633    fn stub_err(reason: &str) -> StubCoveragePort {
2634        StubCoveragePort {
2635            validate_result: Err(reason.to_string()),
2636        }
2637    }
2638
2639    #[test]
2640    fn preflight_surfaces_hint_when_adapter_reports_no_data() {
2641        let dir = tempfile::tempdir().unwrap();
2642        let cov = dir.path().join("empty.info");
2643        std::fs::write(&cov, "").unwrap();
2644
2645        let err =
2646            check_coverage_has_data(&cov, &stub_err("no records"), TEST_COVERAGE_HINT).unwrap_err();
2647        let msg = format!("{err:#}");
2648        assert!(msg.contains("no coverage data found"));
2649        // The adapter's structural reason is surfaced alongside the
2650        // path so the user knows whether the file was empty,
2651        // malformed, or just missing data points.
2652        assert!(msg.contains("no records"), "expected reason in msg: {msg}");
2653        assert!(msg.contains(TEST_COVERAGE_HINT));
2654    }
2655
2656    #[test]
2657    fn preflight_passes_when_adapter_accepts_data() {
2658        let dir = tempfile::tempdir().unwrap();
2659        let cov = dir.path().join("ok.info");
2660        std::fs::write(&cov, "any contents — adapter decides").unwrap();
2661
2662        assert!(check_coverage_has_data(&cov, &stub_ok(), TEST_COVERAGE_HINT).is_ok());
2663    }
2664
2665    // ── --strict / --lenient flag tests ───────────────────────────────
2666
2667    #[test]
2668    fn strict_flag_parses() {
2669        let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2670        assert!(cli.output.strict);
2671    }
2672
2673    #[test]
2674    fn lenient_flag_parses() {
2675        let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2676        assert!(cli.output.lenient);
2677    }
2678
2679    #[test]
2680    fn strict_and_threshold_mutually_exclusive() {
2681        parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
2682    }
2683
2684    #[test]
2685    fn strict_and_lenient_mutually_exclusive() {
2686        parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
2687    }
2688
2689    #[test]
2690    fn merge_threshold_strict_flag() {
2691        use crate::domain::threshold::STRICT_THRESHOLD;
2692        let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2693        let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2694        assert_eq!(config.global, STRICT_THRESHOLD);
2695        assert_eq!(display, STRICT_THRESHOLD);
2696    }
2697
2698    #[test]
2699    fn merge_threshold_lenient_flag() {
2700        use crate::domain::threshold::LENIENT_THRESHOLD;
2701        let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2702        let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2703        assert_eq!(config.global, LENIENT_THRESHOLD);
2704        assert_eq!(display, LENIENT_THRESHOLD);
2705    }
2706
2707    #[test]
2708    fn merge_threshold_toml_preset_used_when_no_cli_flag() {
2709        use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
2710        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2711        let file_config = Some(FileConfig {
2712            preset: Some(ThresholdPreset::Strict),
2713            ..FileConfig::default()
2714        });
2715        let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2716        assert_eq!(config.global, STRICT_THRESHOLD);
2717    }
2718
2719    #[test]
2720    fn merge_threshold_config_literal_overrides_config_preset() {
2721        // A literal `threshold = N` in the config is the most specific
2722        // expression of intent and must beat a named `preset` in the
2723        // same file — mirrors CLI semantics where `--threshold N` beats
2724        // `--strict`/`--lenient`. Without this, a user who sets both
2725        // would silently get the preset and never the number they typed.
2726        use crate::domain::threshold::ThresholdPreset;
2727        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2728        let file_config = Some(FileConfig {
2729            preset: Some(ThresholdPreset::Strict),
2730            threshold: Some(99.0),
2731            ..FileConfig::default()
2732        });
2733        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2734        assert_eq!(config.global, 99.0);
2735        assert_eq!(display, 99.0);
2736    }
2737
2738    #[test]
2739    fn merge_threshold_cli_threshold_overrides_toml_preset() {
2740        use crate::domain::threshold::ThresholdPreset;
2741        let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
2742        let file_config = Some(FileConfig {
2743            preset: Some(ThresholdPreset::Strict),
2744            ..FileConfig::default()
2745        });
2746        let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2747        assert_eq!(config.global, 50.0);
2748    }
2749
2750    // ── majority_zero_coverage predicate tests ─────────────────────────
2751
2752    #[test]
2753    fn zero_coverage_warn_triggers_above_50_percent() {
2754        assert!(majority_zero_coverage(10, 6));
2755        assert!(majority_zero_coverage(1, 1));
2756        assert!(majority_zero_coverage(3, 2));
2757    }
2758
2759    #[test]
2760    fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
2761        assert!(!majority_zero_coverage(10, 5));
2762        assert!(!majority_zero_coverage(2, 1));
2763    }
2764
2765    #[test]
2766    fn zero_coverage_warn_does_not_trigger_when_no_files() {
2767        assert!(!majority_zero_coverage(0, 0));
2768    }
2769
2770    // ── merge_effective_inputs tests ───────────────────────────────────
2771
2772    #[test]
2773    fn merge_effective_inputs_default_src() {
2774        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2775        let inputs = merge_effective_inputs(&cli, &None, &fake_meta());
2776        assert_eq!(inputs.src, PathBuf::from("src"));
2777    }
2778
2779    #[test]
2780    fn merge_effective_inputs_cli_src_wins_over_config() {
2781        let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2782        let file_config = Some(FileConfig {
2783            src: Some(PathBuf::from("from-config/")),
2784            ..FileConfig::default()
2785        });
2786        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2787        assert_eq!(inputs.src, PathBuf::from("crates/"));
2788    }
2789
2790    #[test]
2791    fn merge_effective_inputs_config_src_when_cli_absent() {
2792        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2793        let file_config = Some(FileConfig {
2794            src: Some(PathBuf::from("from-config/")),
2795            ..FileConfig::default()
2796        });
2797        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2798        assert_eq!(inputs.src, PathBuf::from("from-config/"));
2799    }
2800
2801    #[test]
2802    fn merge_effective_inputs_uses_adapter_default_metric_cognitive() {
2803        // Replaces the pre-W2.5 `merge_effective_inputs_default_metric_is_cognitive`
2804        // test. Now that the fallthrough comes from `meta.default_metric`
2805        // (not `ComplexityMetric::default()`), the assertion is "the
2806        // adapter's default flows through when neither CLI nor config
2807        // override it." crap4rs sets this to `Cognitive`.
2808        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2809        let meta = AdapterMeta {
2810            default_metric: ComplexityMetric::Cognitive,
2811            ..fake_meta()
2812        };
2813        let inputs = merge_effective_inputs(&cli, &None, &meta);
2814        assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2815    }
2816
2817    #[test]
2818    fn merge_effective_inputs_uses_adapter_default_metric_cyclomatic() {
2819        // Replaces the pre-W2.5 `merge_effective_inputs_default_metric_is_cognitive`
2820        // test. Mirror of the Cognitive case for the crap4ts adapter
2821        // (locked decision #2: crap4ts default = cyclomatic).
2822        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2823        let meta = AdapterMeta {
2824            default_metric: ComplexityMetric::Cyclomatic,
2825            ..fake_meta()
2826        };
2827        let inputs = merge_effective_inputs(&cli, &None, &meta);
2828        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2829    }
2830
2831    #[test]
2832    fn merge_effective_inputs_cli_metric_overrides_config() {
2833        let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
2834        let file_config = Some(FileConfig {
2835            metric: Some(ComplexityMetric::Cognitive),
2836            ..FileConfig::default()
2837        });
2838        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2839        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2840    }
2841
2842    #[test]
2843    fn merge_effective_inputs_default_threshold_follows_adapter_metric_cognitive() {
2844        // End-to-end wiring: an adapter whose default metric is
2845        // cognitive, with no `--threshold`/`--metric`/config, resolves
2846        // the no-flag gate to the cognitive `Default` cutoff (15).
2847        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2848        let meta = AdapterMeta {
2849            default_metric: ComplexityMetric::Cognitive,
2850            ..fake_meta()
2851        };
2852        let inputs = merge_effective_inputs(&cli, &None, &meta);
2853        assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2854        assert_eq!(inputs.threshold, 15.0);
2855    }
2856
2857    #[test]
2858    fn merge_effective_inputs_default_threshold_follows_adapter_metric_cyclomatic() {
2859        // Mirror for a cyclomatic-default adapter (crap4ts): the no-flag
2860        // gate routes through the cyclomatic column. Columns are flat-
2861        // equal post-#272 (both 15); the metric-keyed routing is what
2862        // this test locks, not the numeric value.
2863        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2864        let meta = AdapterMeta {
2865            default_metric: ComplexityMetric::Cyclomatic,
2866            ..fake_meta()
2867        };
2868        let inputs = merge_effective_inputs(&cli, &None, &meta);
2869        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2870        assert_eq!(inputs.threshold, 15.0);
2871    }
2872
2873    #[test]
2874    fn merge_effective_inputs_exclude_combines_cli_and_config() {
2875        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2876        let file_config = Some(FileConfig {
2877            exclude: Some(vec!["benches/**".to_string()]),
2878            ..FileConfig::default()
2879        });
2880        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2881        assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
2882    }
2883
2884    #[test]
2885    fn merge_effective_inputs_config_metric_wins_over_adapter_default() {
2886        // Config-file metric should still beat the adapter default —
2887        // adapter default is the FINAL fallthrough, below CLI and
2888        // config-file precedence.
2889        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2890        let file_config = Some(FileConfig {
2891            metric: Some(ComplexityMetric::Cyclomatic),
2892            ..FileConfig::default()
2893        });
2894        let meta = AdapterMeta {
2895            default_metric: ComplexityMetric::Cognitive,
2896            ..fake_meta()
2897        };
2898        let inputs = merge_effective_inputs(&cli, &file_config, &meta);
2899        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2900    }
2901
2902    // ── compute_exit_code tests ────────────────────────────────────────
2903    //
2904    // delta_state=None covers the analysis-only paths; the delta-gate +
2905    // delta_state=Some interactions are exercised end-to-end in
2906    // delta_gate_integration.rs (where AnalysisDelta is built through
2907    // the real `delta::compute` path rather than mocked).
2908
2909    #[test]
2910    fn compute_exit_code_passing_no_delta() {
2911        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2912        assert!(compute_exit_code::<
2913            crate::test_strategies::DummyParseDiagnostic,
2914        >(&cli, true, None));
2915    }
2916
2917    #[test]
2918    fn compute_exit_code_failing_no_delta() {
2919        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2920        assert!(!compute_exit_code::<
2921            crate::test_strategies::DummyParseDiagnostic,
2922        >(&cli, false, None));
2923    }
2924
2925    #[test]
2926    fn compute_exit_code_no_fail_overrides_failure() {
2927        let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
2928        assert!(compute_exit_code::<
2929            crate::test_strategies::DummyParseDiagnostic,
2930        >(&cli, false, None));
2931    }
2932
2933    #[test]
2934    fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
2935        // delta_state=None → delta_passed defaults to true even with
2936        // --delta-gate; this matches the runtime behavior when the
2937        // baseline file is missing or unreadable. Clap requires
2938        // --baseline to accompany --delta-gate at parse time, so we
2939        // pass a sentinel path to satisfy the parser without exercising
2940        // the file load (compute_exit_code only inspects the resolved
2941        // delta state, not cli.input.baseline).
2942        let cli = parse(&[
2943            "--coverage",
2944            "lcov.info",
2945            "--delta-gate",
2946            "--baseline",
2947            "/dev/null",
2948        ])
2949        .unwrap();
2950        assert!(compute_exit_code::<
2951            crate::test_strategies::DummyParseDiagnostic,
2952        >(&cli, true, None));
2953    }
2954
2955    #[test]
2956    fn compute_exit_code_no_fail_with_delta_gate() {
2957        // --no-fail is the master override even when --delta-gate
2958        // is set.
2959        let cli = parse(&[
2960            "--coverage",
2961            "lcov.info",
2962            "--delta-gate",
2963            "--baseline",
2964            "/dev/null",
2965            "--no-fail",
2966        ])
2967        .unwrap();
2968        assert!(compute_exit_code::<
2969            crate::test_strategies::DummyParseDiagnostic,
2970        >(&cli, false, None));
2971    }
2972
2973    // ── AdapterMeta unit tests (#161) ──────────────────────────────
2974
2975    fn fake_meta() -> AdapterMeta {
2976        AdapterMeta {
2977            tool_name: "fake-adapter",
2978            display_name: "Fake",
2979            tool_version: "9.9.9",
2980            long_version: "9.9.9 (test 2099-01-01)",
2981            about: "Fake adapter for tests",
2982            long_about: "Fake adapter for tests — verifies AdapterMeta plumbing without binding crap-core to any real adapter.",
2983            after_help: "",
2984            coverage_hint: "no coverage tool — fake adapter",
2985            extensions: &["fake"],
2986            tool_info_uri: "https://example.invalid/fake-adapter",
2987            rule_help_uri: "https://example.invalid/fake-adapter#rules",
2988            config_file_name: "fake-adapter.toml",
2989            default_excludes: &["fixtures/**"],
2990            // Empty in the shared fake so tests that don't care don't
2991            // see an unexpected prefix; the two `merge_exclude` tests
2992            // that DO care construct their own AdapterMeta with an
2993            // explicit `forced_excludes`.
2994            forced_excludes: &[],
2995            // `Cognitive` preserves the pre-W2.5 fallthrough semantics
2996            // for tests that don't care which default they get (the two
2997            // tests that DO care construct their own AdapterMeta with
2998            // an explicit `default_metric`).
2999            default_metric: ComplexityMetric::Cognitive,
3000        }
3001    }
3002
3003    #[test]
3004    fn adapter_meta_extensions_owned_roundtrips_to_owned_strings() {
3005        let meta = AdapterMeta {
3006            extensions: &["ts", "tsx", "js"],
3007            ..fake_meta()
3008        };
3009        let owned = meta.extensions_owned();
3010        assert_eq!(
3011            owned,
3012            vec!["ts".to_string(), "tsx".to_string(), "js".to_string()]
3013        );
3014        // Round-trip via Vec<&str> back to a slice-equivalent shape.
3015        let back: Vec<&str> = owned.iter().map(String::as_str).collect();
3016        assert_eq!(back, &["ts", "tsx", "js"]);
3017    }
3018
3019    #[test]
3020    fn adapter_meta_extensions_owned_handles_empty_slice() {
3021        // `extensions` is allowed to be empty; the diagnostic surfaces
3022        // downstream in `core::ensure_source_files_found`.
3023        let meta = AdapterMeta {
3024            extensions: &[],
3025            ..fake_meta()
3026        };
3027        assert!(meta.extensions_owned().is_empty());
3028    }
3029
3030    #[test]
3031    #[should_panic(expected = "tool_name must not be empty")]
3032    fn adapter_meta_debug_assert_trips_on_empty_tool_name() {
3033        let meta = AdapterMeta {
3034            tool_name: "",
3035            ..fake_meta()
3036        };
3037        meta.debug_assert_required_fields();
3038    }
3039
3040    #[test]
3041    #[should_panic(expected = "config_file_name must not be empty")]
3042    fn adapter_meta_debug_assert_trips_on_empty_config_file_name() {
3043        let meta = AdapterMeta {
3044            config_file_name: "",
3045            ..fake_meta()
3046        };
3047        meta.debug_assert_required_fields();
3048    }
3049
3050    #[test]
3051    fn adapter_meta_debug_assert_passes_on_all_fields_set() {
3052        // Smoke test: a meta with every required field populated should
3053        // pass the debug_assert sweep without panicking.
3054        fake_meta().debug_assert_required_fields();
3055    }
3056}