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    /// Short version string (e.g., `"0.5.0"`). Threaded to every
674    /// reporter alongside `tool_name`.
675    pub tool_version: &'static str,
676    /// Long version string for `--version --long` (e.g.,
677    /// `"0.5.0 (abc1234 2026-05-09)"`).
678    pub long_version: &'static str,
679    /// Short adapter-flavored help text (one-line, shown by `--help`).
680    pub about: &'static str,
681    /// Long adapter-flavored help text (multi-paragraph, shown by
682    /// `--help` in full mode).
683    pub long_about: &'static str,
684    /// `after_help` block with adapter-specific examples
685    /// (`crap4rs --coverage lcov.info ...` etc.). May be empty.
686    pub after_help: &'static str,
687    /// Coverage-tool hint shown when `--coverage` points at a file
688    /// with no `SF:` / `DA:` records. Adapter-specific because the
689    /// remediation depends on the coverage toolchain (Rust: `cargo
690    /// llvm-cov --lcov`; TS: `c8 --reporter=lcov`).
691    pub coverage_hint: &'static str,
692    /// File extensions the walker should pick up (e.g.,
693    /// `&["rs"]` for crap4rs; `&["ts","tsx","js","jsx","mjs","cjs"]`
694    /// for crap4ts). Adapter binaries supply a `const &[&str]`; copied
695    /// into `AnalyzeOptions.extensions` at the orchestration boundary.
696    pub extensions: &'static [&'static str],
697    /// Adapter repo URL spliced into SARIF's
698    /// `runs[0].tool.driver.informationUri`. Adapter-specific so
699    /// crap4ts SARIF output links to crap4ts's repo, not crap4rs's.
700    pub tool_info_uri: &'static str,
701    /// Adapter rule-help URL spliced into SARIF's
702    /// `runs[0].tool.driver.rules[0].helpUri`. Adapter-specific for
703    /// the same reason as `tool_info_uri`.
704    pub rule_help_uri: &'static str,
705    /// Conventional config file name the adapter binary auto-discovers
706    /// in the working directory (e.g., `"crap4rs.toml"` for the Rust
707    /// adapter; `"crap4ts.toml"` for the TS adapter). Threaded through
708    /// to `discover_config` and surfaced in `--view <preset>`
709    /// error hints so users see the right file name to create.
710    pub config_file_name: &'static str,
711    /// Commented-out exclude patterns emitted by `init` into the
712    /// generated config (e.g., `&["tests/**", "benches/**", "examples/**"]`
713    /// for Rust; `&["node_modules/**", "dist/**", "coverage/**"]` for
714    /// TS). Adapter-specific because the convention for "where tests
715    /// and ignorable artifacts live" differs per ecosystem. May be
716    /// empty — init then emits the `# exclude = [ … ]` block without
717    /// per-language entries.
718    ///
719    /// This field is **init-template only** — it does NOT affect the
720    /// analyzer's effective exclude list at runtime. The runtime
721    /// exclude list is built from `cli.filter.exclude` plus the user's
722    /// `crap4ts.toml`/`crap4rs.toml` `exclude` entries, with adapter-
723    /// mandated patterns prepended via `forced_excludes` (below).
724    pub default_excludes: &'static [&'static str],
725    /// Adapter-mandated exclude patterns prepended to every analysis
726    /// run, regardless of CLI flags or user config. Use for files that
727    /// are **structurally never source** for the adapter's language —
728    /// e.g. crap4ts sets `&["**/*.d.ts"]` because TypeScript
729    /// declaration files contain only ambient types, never executable
730    /// code. crap4rs has no such suffix today and sets `&[]`.
731    ///
732    /// Distinct from `default_excludes` (above): forced excludes are
733    /// load-bearing at analysis time and cannot be turned off by the
734    /// operator, whereas `default_excludes` is init-template
735    /// scaffolding. If an operator genuinely needs `.d.ts` files in
736    /// their report, the path is forking the adapter or filing a
737    /// follow-up to add an opt-out — not a config knob (crap-rs#253).
738    pub forced_excludes: &'static [&'static str],
739    /// The default complexity metric the adapter binary uses when
740    /// neither CLI nor config file specifies one. crap4rs sets
741    /// `Cognitive`; crap4ts sets `Cyclomatic` (the only metric crap4ts
742    /// currently supports per `CrapError::MetricNotSupported`).
743    /// Threaded through `merge_effective_inputs` so the binary's
744    /// default flips per adapter without re-litigating the shared CLI
745    /// fallthrough. See ADR (d) `adr-adapter-meta-default-metric.md`
746    /// for the design rationale (per-adapter defaults surface through
747    /// `AdapterMeta`, not crap-core configuration).
748    pub default_metric: ComplexityMetric,
749}
750
751impl AdapterMeta {
752    /// Allocate an owned `Vec<String>` from `extensions` for inclusion
753    /// in `AnalyzeOptions` (which owns its config rather than borrowing
754    /// from the meta, decoupling analysis lifetime from CLI lifetime).
755    pub fn extensions_owned(&self) -> Vec<String> {
756        self.extensions.iter().map(|e| (*e).to_string()).collect()
757    }
758
759    /// Trip on construction with empty required strings. `extensions`
760    /// is allowed to be empty — `core::ensure_source_files_found`
761    /// surfaces a parser-neutral diagnostic when no files match. Other
762    /// fields are mandatory for help/SARIF/`--version` rendering, and a
763    /// silent empty string here would produce malformed output that's
764    /// hard to trace back to the meta. Debug-only so release builds
765    /// stay zero-cost; production binaries should never hit these
766    /// (their meta is `env!()` / `const`).
767    pub(crate) fn debug_assert_required_fields(&self) {
768        debug_assert!(
769            !self.tool_name.is_empty(),
770            "AdapterMeta.tool_name must not be empty"
771        );
772        debug_assert!(
773            !self.tool_version.is_empty(),
774            "AdapterMeta.tool_version must not be empty"
775        );
776        debug_assert!(
777            !self.long_version.is_empty(),
778            "AdapterMeta.long_version must not be empty"
779        );
780        debug_assert!(
781            !self.about.is_empty(),
782            "AdapterMeta.about must not be empty"
783        );
784        debug_assert!(
785            !self.long_about.is_empty(),
786            "AdapterMeta.long_about must not be empty"
787        );
788        debug_assert!(
789            !self.coverage_hint.is_empty(),
790            "AdapterMeta.coverage_hint must not be empty"
791        );
792        debug_assert!(
793            !self.tool_info_uri.is_empty(),
794            "AdapterMeta.tool_info_uri must not be empty"
795        );
796        debug_assert!(
797            !self.rule_help_uri.is_empty(),
798            "AdapterMeta.rule_help_uri must not be empty"
799        );
800        debug_assert!(
801            !self.config_file_name.is_empty(),
802            "AdapterMeta.config_file_name must not be empty"
803        );
804    }
805}
806
807/// Parse process args into `Cli`, splicing the adapter's runtime
808/// metadata into clap's help / `--version` output.
809///
810/// Split out from `run` purely to keep the parse step monomorphic and
811/// off the binary's hot path on `--help` / `--version` (clap intercepts
812/// those before `parse_args` returns). The adapter binary supplies its
813/// coverage adapter to `run` as a factory closure that's invoked once
814/// after CLI/config-file merging resolves the effective source root —
815/// pre-construction lets the coverage parser strip the wrong prefix
816/// from per-file records when `cli.input.src` is `None` because `src`
817/// came from the adapter's config TOML rather than the CLI.
818///
819/// `AdapterMeta::{tool_version, long_version, about, long_about,
820/// after_help}` flow into clap's help / `--version` output at runtime
821/// so the binary's build-script metadata reaches the help text — the
822/// derive macro's `version` reads `CARGO_PKG_VERSION` at lib-crate
823/// compile time (crap-core's `0.1.0`); the adapter binary's own
824/// `CARGO_PKG_VERSION` and `<ADAPTER>_LONG_VERSION` only resolve in
825/// the binary's compile and reach us by parameter.
826pub fn parse_args(meta: &AdapterMeta) -> Cli {
827    meta.debug_assert_required_fields();
828    let cmd = build_command(meta);
829    let matches = cmd.get_matches();
830    Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
831}
832
833/// Read the adapter binary's name from `argv[0]`, falling back to
834/// `meta.tool_name` when argv[0] is unavailable (extreme edge cases
835/// like execve with empty argv). The clap-derive `Cli::command()`
836/// defaults to `CARGO_PKG_NAME` of the lib crate (crap-core), which
837/// would print `--version` lines with the wrong identifier and shape
838/// generated completion scripts for the wrong binary; runtime
839/// detection ensures the displayed name matches whichever adapter
840/// binary actually ran.
841fn current_bin_name(meta_fallback: &str) -> String {
842    std::env::args()
843        .next()
844        .and_then(|first| {
845            // `file_stem()` (not `file_name()`) so Windows builds drop
846            // the `.exe` suffix — without it `--version` would print
847            // `<binary>.exe <version>` and break scripts (and the
848            // version-stamp integration tests) that match `^<binary> `.
849            // No-op on Linux/macOS.
850            std::path::PathBuf::from(first)
851                .file_stem()
852                .map(|os| os.to_string_lossy().into_owned())
853        })
854        .unwrap_or_else(|| meta_fallback.to_string())
855}
856
857/// Build the clap `Command` with the binary's runtime metadata
858/// spliced in. Used by `parse_args`; `emit_completions` reads the
859/// bin name through `current_bin_name` directly because
860/// `clap_complete::generate` takes the bin name as a separate arg.
861///
862/// `name` / `bin_name` need clap's `string` feature
863/// (`impl From<String> for clap::builder::Str`) because
864/// `current_bin_name` constructs the bin name at runtime from
865/// `argv[0]` and returns `String` — without the feature, the only way
866/// to satisfy `From<&'static str>` from a runtime `String` is
867/// `Box::leak` (the pre-#161 workaround). The remaining fields are
868/// `&'static str` on `AdapterMeta`, so they pass through clap's
869/// default `Into<Str>` impl with zero heap allocations.
870fn build_command(meta: &AdapterMeta) -> clap::Command {
871    let bin_name = current_bin_name(meta.tool_name);
872    let mut cmd = Cli::command()
873        .name(bin_name.clone())
874        .bin_name(bin_name)
875        .version(meta.tool_version)
876        .long_version(meta.long_version)
877        .about(meta.about)
878        .long_about(meta.long_about);
879    if !meta.after_help.is_empty() {
880        cmd = cmd.after_help(meta.after_help);
881    }
882    // The `--metric` help advertises the adapter's default metric. The
883    // doc-comment-derived help is static and shared between adapters,
884    // but the effective default differs per adapter (crap4rs resolves
885    // to cognitive, crap4ts to cyclomatic), so the displayed default is
886    // injected at runtime from `AdapterMeta::default_metric` rather
887    // than hardcoded in the comment.
888    cmd = cmd.mut_arg("metric", |arg| {
889        arg.help(format!(
890            "Complexity metric to use [default: {}]",
891            meta.default_metric
892        ))
893    });
894    cmd
895}
896
897/// Run the CRAP CLI pipeline end-to-end.
898///
899/// Takes a `coverage_factory` closure rather than a constructed
900/// coverage adapter so the parser receives the effective source root
901/// *after* CLI / config-file / preset merging — pre-construction
902/// canonicalized against the bare CLI value (or the default `src`) and
903/// the LCOV parser silently stripped the wrong prefix from `SF:`
904/// records. The factory is invoked once inside `run` after
905/// `merge_effective_inputs` resolves the final `src`, receives the
906/// **canonicalized** effective source root (so adapter factories stay
907/// dumb — orchestration owns the canonicalize concern), and is
908/// short-circuited entirely on the `completions` subcommand (clap's
909/// `--help` / `--version` exit even earlier, inside `parse_args`).
910///
911/// Generic over `P: ParseDiagnostic` so the same orchestrator drives
912/// every adapter crate's binary (per ADR D9, mixed-dispatch). The
913/// `'static` bound on `P` is the standard trait-object well-formedness
914/// requirement when the closure returns `Box<dyn …>`; concrete adapter
915/// diagnostic types (`LcovParseDiagnostic`, `IstanbulParseDiagnostic`)
916/// satisfy it trivially.
917///
918/// `meta` carries the adapter binary's runtime identity (name,
919/// version, help copy, extensions, config-file name, SARIF URIs).
920/// The binary's own `tool_version` (e.g. crap4rs's `CARGO_PKG_VERSION`
921/// resolves to `0.5.0`, not crap-core's `0.1.0`) feeds the JSON
922/// envelope's `tool_version` field, the SARIF run metadata, the
923/// markdown / HTML headers, and clap's long-version splice. See
924/// `AdapterMeta` for the per-field rationale.
925pub fn run<P, F>(
926    cli: Cli,
927    complexity: &dyn ComplexityPort,
928    coverage_factory: F,
929    meta: &AdapterMeta,
930) -> ExitCode
931where
932    P: ParseDiagnostic + std::fmt::Display + 'static,
933    F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
934{
935    match run_inner(cli, complexity, coverage_factory, meta) {
936        Ok(true) => ExitCode::from(0),
937        Ok(false) => ExitCode::from(1),
938        Err(e) => {
939            render_error(&e, meta);
940            ExitCode::from(2)
941        }
942    }
943}
944
945/// Render an end-of-pipeline error to stderr, special-casing the
946/// `MetricNotSupported` variant so the user sees adapter-specific
947/// phrasing without the generic `error:` prefix (per breadboard W-5
948/// + `metric_unsupported.feature` scenario 1 exact-string contract).
949///
950/// `anyhow::Error::downcast_ref` walks the source chain looking for a
951/// concrete `CrapError`; we use it (not `is::<CrapError>()`) so an
952/// error that was `.context(...)`-wrapped along the way is still
953/// detected. Every other error type falls through to the default
954/// `error: {e:#}` rendering — `{:#}` uses anyhow's alternate Display
955/// which prints the full cause chain.
956fn render_error(err: &anyhow::Error, meta: &AdapterMeta) {
957    if let Some(crap_err) = err.downcast_ref::<crate::domain::types::CrapError>()
958        && let crate::domain::types::CrapError::MetricNotSupported { metric } = crap_err
959    {
960        // Adapter-specific message: `tool_name` + `default_metric`
961        // hint + `tool_info_uri`. The domain layer's variant message
962        // stays adapter-agnostic; adapter-named phrasing lives at
963        // this rendering boundary only. `metric` (input) +
964        // `meta.default_metric` (hint) both use `ComplexityMetric`'s
965        // `Display` impl which yields lowercase wire tokens —
966        // matching CLI input (`cognitive`, not Debug `Cognitive`).
967        eprintln!(
968            "{}: complexity metric `{}` is not yet supported. Use `--metric {}` (the default for {}) or track support at {}.",
969            meta.tool_name, metric, meta.default_metric, meta.tool_name, meta.tool_info_uri,
970        );
971        return;
972    }
973    eprintln!("error: {err:#}");
974}
975
976fn run_inner<P, F>(
977    mut cli: Cli,
978    complexity: &dyn ComplexityPort,
979    coverage_factory: F,
980    meta: &AdapterMeta,
981) -> Result<bool>
982where
983    P: ParseDiagnostic + std::fmt::Display + 'static,
984    F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
985{
986    match cli.command {
987        Some(Command::Completions { shell }) => {
988            emit_completions(shell, &current_bin_name(meta.tool_name));
989            return Ok(true);
990        }
991        Some(Command::Init {
992            force,
993            non_interactive,
994        }) => {
995            init::handle_init(force, non_interactive, meta)?;
996            return Ok(true);
997        }
998        None => {}
999    }
1000
1001    let prep = prepare_pipeline(&mut cli, complexity, coverage_factory, meta)?;
1002
1003    // Build the spec, then shape the result through the View pipeline.
1004    // V1b: `--only-failing` flows through `Filters::only_failing` here.
1005    // W2 fills in `--top`, `--min/max-coverage`, `--sort-by`. The
1006    // underlying `result` is never mutated — the gate is unshapeable.
1007    let spec = view_args::build_view_spec(&cli);
1008    let view = view::apply(&prep.analysis.result, spec);
1009
1010    // Shape the delta. Spec is built from --delta-top / --delta-sort /
1011    // --delta-only (VS4); defaults match the dominant scorecard use
1012    // case (regressions first, all kinds, no truncation). `Option::map`
1013    // is `FnOnce`, so the closure moves the spec rather than cloning —
1014    // `DeltaView` owns its `spec` field, no further uses upstream.
1015    let delta_spec = delta_args::build_delta_view_spec(&cli);
1016    let delta_view: Option<DeltaView<'_>> = prep
1017        .delta_state
1018        .as_ref()
1019        .map(move |s| delta::apply(&s.delta, delta_spec));
1020
1021    if !cli.display.quiet {
1022        print_formatted_output(
1023            &cli,
1024            &view,
1025            delta_view.as_ref(),
1026            prep.delta_state.as_ref(),
1027            &prep.analysis,
1028            &prep.inputs,
1029            meta,
1030        )?;
1031    }
1032
1033    // Exit code derives from `view.full.passed` — i.e., the underlying
1034    // analysis. The View shapes the display, never the gate.
1035    //
1036    // Delta is informational by default.
1037    // `--delta-gate` opts in: a passing analysis with delta regressions
1038    // that introduce new violations will exit 1 when `--delta-gate` is
1039    // set. `--no-fail` overrides BOTH gates — truth lives in JSON
1040    // (`result.passed` and `delta.summary.passed`) so consumers can
1041    // still detect "would have failed."
1042    Ok(compute_exit_code(
1043        &cli,
1044        prep.analysis.result.passed,
1045        prep.delta_state.as_ref(),
1046    ))
1047}
1048
1049// ── Run-inner orchestration helpers ────────────────────────────────
1050
1051/// Effective inputs after CLI / config-file / preset / default merging.
1052/// Carries everything downstream (`core::analyze` + reporter dispatch)
1053/// needs except the coverage path (which is validated separately and
1054/// may be borrowed from `cli`).
1055struct EffectiveInputs {
1056    src: PathBuf,
1057    metric: ComplexityMetric,
1058    threshold_config: ThresholdConfig,
1059    threshold: f64,
1060    exclude: Vec<String>,
1061    /// Merged cap for the `github-annotations` reporter. Defaults to
1062    /// `10` when neither the CLI flag nor the TOML `[output]
1063    /// annotation_limit` is set; CLI flag wins over config. Honored
1064    /// only by `format_github_annotations` — every other reporter
1065    /// ignores it.
1066    annotation_limit: usize,
1067}
1068
1069/// In-flight pipeline state assembled by `prepare_pipeline`. Owns the
1070/// analysis output and the optional delta state so the dispatch layer
1071/// borrows through references. Generic over `P: ParseDiagnostic` so
1072/// `AnalysisOutput<P>` and `DeltaState<P>` carry the adapter's diagnostic
1073/// shape (LCOV, future Istanbul, …) end-to-end.
1074struct PipelinePrep<P: ParseDiagnostic> {
1075    inputs: EffectiveInputs,
1076    analysis: AnalysisOutput<P>,
1077    delta_state: Option<DeltaState<P>>,
1078}
1079
1080/// Merge CLI flags, optional file config, and adapter defaults into a
1081/// concrete `EffectiveInputs`. `meta.default_metric` is the
1082/// load-bearing fallthrough — each adapter binary picks its own
1083/// sensible default (crap4rs: `Cognitive`; crap4ts: `Cyclomatic`) so
1084/// the shared CLI stays adapter-agnostic. See ADR (d).
1085fn merge_effective_inputs(
1086    cli: &Cli,
1087    file_config: &Option<FileConfig>,
1088    meta: &AdapterMeta,
1089) -> EffectiveInputs {
1090    let src = cli
1091        .input
1092        .src
1093        .clone()
1094        .or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
1095        .unwrap_or_else(|| PathBuf::from("src"));
1096    let metric: ComplexityMetric = cli
1097        .input
1098        .metric
1099        .map(Into::into)
1100        .or_else(|| file_config.as_ref().and_then(|c| c.metric))
1101        .unwrap_or(meta.default_metric);
1102    let (threshold_config, threshold) = merge_threshold(cli, file_config, metric);
1103    let exclude = merge_exclude(cli, file_config, meta);
1104    let annotation_limit = cli
1105        .output
1106        .annotation_limit
1107        .or_else(|| file_config.as_ref().and_then(|c| c.output.annotation_limit))
1108        .unwrap_or(10) as usize;
1109    EffectiveInputs {
1110        src,
1111        metric,
1112        threshold_config,
1113        threshold,
1114        exclude,
1115        annotation_limit,
1116    }
1117}
1118
1119fn validate_runtime_inputs<'a>(
1120    cli: &'a Cli,
1121    inputs: &EffectiveInputs,
1122    meta: &AdapterMeta,
1123) -> Result<&'a Path> {
1124    // `--coverage` is required on the analysis path; subcommands like
1125    // `completions` skip this branch. Clap can't express "required
1126    // unless subcommand X" in derive, so we enforce it here.
1127    let Some(coverage_path) = cli.input.coverage.as_deref() else {
1128        bail!(
1129            "--coverage <FILE> is required (run `{name} --help` for usage, or `{name} completions <SHELL>` for shell completion scripts)",
1130            name = meta.tool_name,
1131        );
1132    };
1133
1134    validate_inputs(
1135        coverage_path,
1136        &inputs.src,
1137        inputs.threshold,
1138        meta.coverage_hint,
1139    )?;
1140
1141    if let Some(diff_ref) = cli.filter.diff.as_deref() {
1142        validate_diff_ref(diff_ref)?;
1143        preflight_git_worktree(&inputs.src)?;
1144    }
1145
1146    Ok(coverage_path)
1147}
1148
1149fn build_analyze_options(
1150    cli: &Cli,
1151    inputs: &EffectiveInputs,
1152    coverage: &Path,
1153    meta: &AdapterMeta,
1154) -> AnalyzeOptions {
1155    AnalyzeOptions {
1156        src: inputs.src.clone(),
1157        coverage: coverage.to_path_buf(),
1158        threshold_config: inputs.threshold_config.clone(),
1159        metric: inputs.metric,
1160        exclude: inputs.exclude.clone(),
1161        respect_gitignore: !cli.filter.no_gitignore,
1162        diff_ref: cli.filter.diff.clone(),
1163        extensions: meta.extensions_owned(),
1164        compute_diagnostics: cli
1165            .output
1166            .format
1167            .iter()
1168            .any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
1169        ..AnalyzeOptions::default()
1170    }
1171}
1172
1173fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
1174    cli: &Cli,
1175    diagnostics: &AnalysisDiagnostics<P>,
1176) {
1177    // Always warn about non-fatal issues (details require --verbose)
1178    warn_if_issues(diagnostics);
1179    if cli.display.verbose {
1180        print_diagnostics(diagnostics);
1181    }
1182}
1183
1184/// Validates inputs, merges effective config, runs the analyzer, and
1185/// resolves the optional baseline delta. The bulk of `run_inner`'s
1186/// pre-render work lives here so `run_inner` itself stays a flat dispatch.
1187///
1188/// Constructs the coverage adapter via `coverage_factory` *after*
1189/// `merge_effective_inputs` resolves the final source root, so the
1190/// coverage parser strips the correct prefix from per-file records
1191/// even when `src` came from the adapter's config TOML rather than
1192/// the CLI.
1193fn prepare_pipeline<P, F>(
1194    cli: &mut Cli,
1195    complexity: &dyn ComplexityPort,
1196    coverage_factory: F,
1197    meta: &AdapterMeta,
1198) -> Result<PipelinePrep<P>>
1199where
1200    P: ParseDiagnostic + std::fmt::Display + 'static,
1201    F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
1202{
1203    validate_display_flags(cli)?;
1204    apply_color(cli.display.color);
1205
1206    // Load config file (explicit path or auto-discovered). Path is
1207    // kept alongside the loaded config so downstream diagnostics
1208    // (e.g., unknown `--view` preset) can point the user at the
1209    // exact file to edit.
1210    let (file_config, config_path) = load_file_config(cli, meta.config_file_name)?.unzip();
1211
1212    // Resolve `--view <NAME>` before validate_view_args runs
1213    // so preset fields participate in the same validation pass as CLI
1214    // flags. `apply_preset_to_cli` mutates `cli` in place: CLI explicit
1215    // values win on `Option<T>` fields, bools OR-merge.
1216    view_args::resolve_view_preset(
1217        cli,
1218        file_config.as_ref(),
1219        config_path.as_deref(),
1220        meta.config_file_name,
1221    )?;
1222    view_args::validate_view_args(cli)?;
1223
1224    let inputs = merge_effective_inputs(cli, &file_config, meta);
1225    let coverage_path = validate_runtime_inputs(cli, &inputs, meta)?;
1226
1227    // Canonicalize the effective `src` (post-config-merge) and hand
1228    // it to the adapter's factory closure. `validate_runtime_inputs`
1229    // already gated on existence; `canonicalize_src`'s fallback path
1230    // is purely defensive against TOCTOU between the two `metadata`
1231    // calls and emits a warning on the error arm so the regression is
1232    // observable instead of silent.
1233    let src_canonical = crate::core::canonicalize_src(&inputs.src);
1234    let coverage = coverage_factory(&src_canonical);
1235
1236    // Adapter-aware pre-flight runs after construction so
1237    // `CoveragePort::validate` can apply its own structural check
1238    // (LCOV: SF/DA records; future Istanbul: non-empty statementMap)
1239    // before the full parse pass. See ADR D-coverage-validate.
1240    preflight_checks(coverage_path, &*coverage, meta)?;
1241
1242    let options = build_analyze_options(cli, &inputs, coverage_path, meta);
1243
1244    let analysis = crate::core::analyze(&options, complexity, &*coverage)?;
1245    apply_diagnostics(cli, &analysis.diagnostics);
1246
1247    // Resolve --baseline: load a previously-emitted JSON
1248    // envelope and compute the AnalysisDelta. None when --baseline is
1249    // absent — the JSON envelope omits the `delta` block entirely so
1250    // existing consumers see byte-identical output.
1251    let delta_state = load_delta_state(cli, &analysis.result)?;
1252
1253    Ok(PipelinePrep {
1254        inputs,
1255        analysis,
1256        delta_state,
1257    })
1258}
1259
1260// ── Format dispatch ────────────────────────────────────────────────
1261
1262fn format_as_json<P: ParseDiagnostic>(
1263    cli: &Cli,
1264    view: &view::AnalysisView<'_>,
1265    delta_view: Option<&DeltaView<'_>>,
1266    delta_state: Option<&DeltaState<P>>,
1267    analysis: &AnalysisOutput<P>,
1268    inputs: &EffectiveInputs,
1269    meta: &AdapterMeta,
1270) -> Result<String> {
1271    let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
1272        view: dv,
1273        baseline_tool_version: &s.snapshot.tool_version,
1274        baseline_timestamp: &s.snapshot.timestamp,
1275        baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
1276    });
1277    let config = reporters::json::JsonConfig {
1278        tool_version: meta.tool_version.to_string(),
1279        metric: inputs.metric,
1280        threshold: inputs.threshold,
1281        timestamp: now_unix_epoch(),
1282        diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
1283        diff_ref: cli.filter.diff.as_deref(),
1284        minimal_view: cli.output.minimal_view,
1285        delta: delta_ctx,
1286    };
1287    reporters::json::format_json(view, &config).map_err(Into::into)
1288}
1289
1290/// ScorecardRow projects the unshaped analysis + delta into a
1291/// `Row::CrapDelta` JSON object. View shaping does NOT
1292/// alter scorecard-row — the aggregator consumes truth, not a filtered
1293/// subset.
1294fn format_as_scorecard_row<P: ParseDiagnostic>(
1295    delta_state: Option<&DeltaState<P>>,
1296    result: &crate::domain::types::AnalysisResult,
1297    threshold: f64,
1298) -> String {
1299    let baseline_result = delta_state.map(|s| &s.snapshot.result);
1300    let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
1301    let row_data = crate::domain::summary::project_crap_delta_row(
1302        result,
1303        baseline_result,
1304        delta_inputs,
1305        threshold.round() as u32,
1306    );
1307    reporters::format_scorecard_row(&row_data)
1308}
1309
1310// 8-arg dispatch is the cost of threading `<P>` + `meta` through the
1311// format match without restructuring the per-reporter call sites
1312// (which carry heterogeneous, irreducible signatures per `adapters.md`
1313// rule 1). Bundling them into a context struct would shadow the per-arm
1314// argument list that's the whole point of this match. Tracked under v1.0
1315// follow-up for the broader cli refactor.
1316#[allow(clippy::too_many_arguments)]
1317fn render_format<P: ParseDiagnostic>(
1318    cli: &Cli,
1319    spec: &FormatSpec,
1320    view: &view::AnalysisView<'_>,
1321    delta_view: Option<&DeltaView<'_>>,
1322    delta_state: Option<&DeltaState<P>>,
1323    analysis: &AnalysisOutput<P>,
1324    inputs: &EffectiveInputs,
1325    meta: &AdapterMeta,
1326) -> Result<String> {
1327    Ok(match spec.format {
1328        FormatArg::Table => reporters::format_table_with_explain(
1329            view,
1330            delta_view,
1331            inputs.threshold,
1332            cli.display.breakdown,
1333            cli.display.explain,
1334            meta.tool_name,
1335            meta.tool_version,
1336        ),
1337        FormatArg::Json | FormatArg::Advice => {
1338            format_as_json(cli, view, delta_view, delta_state, analysis, inputs, meta)?
1339        }
1340        FormatArg::Markdown => reporters::format_markdown(
1341            view,
1342            delta_view,
1343            inputs.threshold,
1344            cli.display.breakdown,
1345            cli.display.explain,
1346            cli.display.md_full_table,
1347            cli.display.md_top,
1348            meta.tool_name,
1349            meta.tool_version,
1350        ),
1351        FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
1352        // SARIF is a gate translation, not a display: it iterates
1353        // `view.full.functions` internally regardless of how the View
1354        // was shaped. `--top`, `--sort-by`, `--only-failing`, and
1355        // `--baseline` do NOT alter SARIF output — PR annotations
1356        // must reflect truth.
1357        FormatArg::Sarif => reporters::format_sarif(
1358            view,
1359            meta.tool_name,
1360            meta.tool_version,
1361            meta.tool_info_uri,
1362            meta.rule_help_uri,
1363        ),
1364        FormatArg::ScorecardRow => {
1365            format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
1366        }
1367        FormatArg::Html => {
1368            reporters::format_html(view, inputs.threshold, meta.tool_name, meta.tool_version)
1369        }
1370        // GitHub Actions annotations is a gate translation like SARIF —
1371        // iterates `view.full.functions` regardless of View shaping so
1372        // PR annotations reflect the gate, not a presentation choice.
1373        // `annotation_limit` is the per-step UI cap; the reporter
1374        // appends a `::notice` summary when truncation kicks in.
1375        FormatArg::GithubAnnotations => reporters::format_github_annotations(
1376            view,
1377            meta.tool_name,
1378            meta.tool_version,
1379            inputs.annotation_limit,
1380        ),
1381    })
1382}
1383
1384fn print_formatted_output<P: ParseDiagnostic>(
1385    cli: &Cli,
1386    view: &view::AnalysisView<'_>,
1387    delta_view: Option<&DeltaView<'_>>,
1388    delta_state: Option<&DeltaState<P>>,
1389    analysis: &AnalysisOutput<P>,
1390    inputs: &EffectiveInputs,
1391    meta: &AdapterMeta,
1392) -> Result<()> {
1393    // `--summary` short-circuits `--format` dispatch entirely. `--quiet`
1394    // already gates this entire function at the caller (run_inner), so
1395    // the precedence is `--quiet > --summary > --format`. Mirrors
1396    // crap4ts's implicit precedence (its formatSummaryLine bypasses the
1397    // reporter switch when set).
1398    if cli.output.summary {
1399        let line = reporters::format_summary_line(view.full, inputs.threshold);
1400        println!("{line}");
1401        return Ok(());
1402    }
1403
1404    for spec in &cli.output.format {
1405        let output = render_format(
1406            cli,
1407            spec,
1408            view,
1409            delta_view,
1410            delta_state,
1411            analysis,
1412            inputs,
1413            meta,
1414        )?;
1415        match &spec.output {
1416            Some(path) => std::fs::write(path, &output)
1417                .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
1418            None => print!("{output}"),
1419        }
1420    }
1421
1422    // Advice's stderr summary fires once even if Advice appears multiple
1423    // times in `--format`. SARIF stays silent — its primary deliverable
1424    // is the `.sarif` file uploaded to Code Scanning; stderr would noise
1425    // up CI logs.
1426    if cli
1427        .output
1428        .format
1429        .iter()
1430        .any(|s| matches!(s.format, FormatArg::Advice))
1431    {
1432        let mut stderr = std::io::stderr();
1433        let _ = reporters::render_advice_summary(view, &mut stderr);
1434    }
1435
1436    Ok(())
1437}
1438
1439fn compute_exit_code<P: ParseDiagnostic>(
1440    cli: &Cli,
1441    passed: bool,
1442    delta_state: Option<&DeltaState<P>>,
1443) -> bool {
1444    let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
1445    let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
1446    combined_passed || cli.output.no_fail
1447}
1448
1449// ── Delta orchestration ─────────────────────────────────────────────
1450
1451/// In-flight delta state — owned baseline metadata + computed delta.
1452/// `cli/mod.rs` keeps this for the lifetime of `run_inner` so reporters
1453/// can borrow through it. Constructed once per invocation when
1454/// `--baseline` is set; absent otherwise. Generic over `P:
1455/// ParseDiagnostic` so the snapshot's `BaselineSnapshot<P>` matches the
1456/// adapter's diagnostic shape.
1457struct DeltaState<P: ParseDiagnostic> {
1458    snapshot: BaselineSnapshot<P>,
1459    delta: AnalysisDelta,
1460}
1461
1462fn load_delta_state<P: ParseDiagnostic>(
1463    cli: &Cli,
1464    current: &crate::domain::types::AnalysisResult,
1465) -> Result<Option<DeltaState<P>>> {
1466    let Some(path) = cli.input.baseline.as_ref() else {
1467        return Ok(None);
1468    };
1469    let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
1470    // delta::compute consumes both — we own snapshot.result, clone the
1471    // current analysis so the surrounding pipeline keeps its handle.
1472    let delta = delta::compute(snapshot.result.clone(), current.clone());
1473    Ok(Some(DeltaState { snapshot, delta }))
1474}
1475
1476fn validate_display_flags(cli: &Cli) -> Result<()> {
1477    let any_table = cli
1478        .output
1479        .format
1480        .iter()
1481        .any(|s| matches!(s.format, FormatArg::Table));
1482    if cli.display.explain && any_table && !cli.display.breakdown {
1483        bail!("--explain requires --breakdown for table output");
1484    }
1485    validate_format_destinations(&cli.output.format)?;
1486    Ok(())
1487}
1488
1489/// Multi-format invocations may contain at most one stdout-targeted
1490/// spec (the rest must specify a file). Two stdout sinks would
1491/// interleave indistinguishably, but a single stdout sink alongside
1492/// any number of file sinks is unambiguous and is the shape composite
1493/// CI workflows need (e.g. `markdown:scorecard.md,github-annotations`
1494/// where the markdown becomes the scorecard artefact and
1495/// `github-annotations` workflow commands are intercepted from
1496/// stdout by the GitHub Actions runner).
1497fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
1498    if specs.len() > 1 {
1499        let stdout_specs: Vec<_> = specs
1500            .iter()
1501            .filter(|s| s.output.is_none())
1502            .map(|s| format_arg_kebab(s.format).to_string())
1503            .collect();
1504        if stdout_specs.len() > 1 {
1505            bail!(
1506                "multi-format `--format` allows at most one stdout entry (the rest must specify a file, e.g. `json:envelope.json`); stdout entries: {}",
1507                stdout_specs.join(", ")
1508            );
1509        }
1510    }
1511    Ok(())
1512}
1513
1514/// User-facing kebab-case name for a `FormatArg` (matches the clap CLI
1515/// surface `--format X`). Defaults to `Debug` lowercased if clap's
1516/// `ValueEnum` registry can't resolve a name.
1517fn format_arg_kebab(arg: FormatArg) -> String {
1518    use clap::ValueEnum;
1519    arg.to_possible_value()
1520        .map(|v| v.get_name().to_string())
1521        .unwrap_or_else(|| format!("{arg:?}").to_lowercase())
1522}
1523
1524// ── Config loading & merging ───────────────────────────────────────
1525
1526/// Load the on-disk config file (explicit `--config` path or
1527/// auto-discovered by adapter convention) and return it paired with
1528/// the path it came from. The path is threaded into downstream error
1529/// hints (e.g., the `--view` unknown-preset diagnostic) so the user
1530/// sees the exact file to edit — not just the conventional name.
1531fn load_file_config(
1532    cli: &Cli,
1533    config_file_name: &str,
1534) -> Result<Option<(FileConfig, std::path::PathBuf)>> {
1535    if let Some(path) = &cli.input.config {
1536        let cfg = config::load_config(path)?;
1537        Ok(Some((cfg, path.clone())))
1538    } else {
1539        match config::discover_config(config_file_name)? {
1540            Some(path) => {
1541                let cfg = config::load_config(&path)?;
1542                Ok(Some((cfg, path)))
1543            }
1544            None => Ok(None),
1545        }
1546    }
1547}
1548
1549/// Merge CLI threshold with config file. Returns (ThresholdConfig, effective_display_threshold).
1550///
1551/// Resolution order (first match wins). Every tier-derived value is
1552/// keyed on the resolved `metric` via [`ThresholdPreset::threshold`],
1553/// so a cutoff calibrated for one metric is never applied to the
1554/// other metric's (different-magnitude) scores. `metric` is the
1555/// already-resolved effective metric (CLI > config > adapter default).
1556/// A literal cutoff (an explicit number) always beats a named preset
1557/// at the same level of specificity, because a literal is the most
1558/// specific expression of intent: CLI `--threshold N` beats CLI
1559/// `--strict`/`--lenient`, and config `threshold = N` beats config
1560/// `preset = "..."`. This keeps CLI and config-file semantics
1561/// consistent — a user who writes an explicit number gets that number.
1562///
1563/// 1. `--threshold N`   — explicit CLI value (a literal cutoff; metric-independent)
1564/// 2. `--strict`        → `ThresholdPreset::Strict.threshold(metric)`
1565/// 3. `--lenient`       → `ThresholdPreset::Lenient.threshold(metric)`
1566/// 4. config `threshold` — explicit literal cutoff (metric-independent)
1567/// 5. config `preset`   → `preset.threshold(metric)`
1568/// 6. no-flag default   → `ThresholdPreset::Default.threshold(metric)`
1569///    (cyclomatic-metric runs → 16; cognitive-metric runs → 25)
1570fn merge_threshold(
1571    cli: &Cli,
1572    file_config: &Option<FileConfig>,
1573    metric: ComplexityMetric,
1574) -> (ThresholdConfig, f64) {
1575    let global = cli
1576        .output
1577        .threshold
1578        .or_else(|| {
1579            cli.output
1580                .strict
1581                .then(|| ThresholdPreset::Strict.threshold(metric))
1582        })
1583        .or_else(|| {
1584            cli.output
1585                .lenient
1586                .then(|| ThresholdPreset::Lenient.threshold(metric))
1587        })
1588        .or_else(|| file_config.as_ref().and_then(|c| c.threshold))
1589        .or_else(|| {
1590            file_config
1591                .as_ref()
1592                .and_then(|c| c.preset)
1593                .map(|p| p.threshold(metric))
1594        })
1595        .unwrap_or(ThresholdPreset::Default.threshold(metric));
1596
1597    let overrides = file_config
1598        .as_ref()
1599        .map(|fc| fc.overrides.clone())
1600        .unwrap_or_default();
1601
1602    let config = ThresholdConfig { global, overrides };
1603    (config, global)
1604}
1605
1606/// Build the effective exclude list applied to the source walker:
1607/// `meta.forced_excludes` first (adapter-mandated, structural skips
1608/// like `**/*.d.ts` for crap4ts — see `AdapterMeta::forced_excludes`),
1609/// then CLI `--exclude` flags, then patterns from the user's config
1610/// file. Duplicates are dropped so the override builder doesn't see
1611/// the same pattern twice. Order matters because `ignore::overrides`
1612/// is order-sensitive on shadowed patterns; forced excludes lead so a
1613/// user can't accidentally re-include a structurally-skipped file.
1614fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>, meta: &AdapterMeta) -> Vec<String> {
1615    let mut exclude: Vec<String> = meta
1616        .forced_excludes
1617        .iter()
1618        .map(|s| (*s).to_string())
1619        .collect();
1620    let mut seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
1621    for pattern in &cli.filter.exclude {
1622        if seen.insert(pattern.clone()) {
1623            exclude.push(pattern.clone());
1624        }
1625    }
1626    if let Some(fc) = file_config
1627        && let Some(fc_exclude) = &fc.exclude
1628    {
1629        for pattern in fc_exclude {
1630            if seen.insert(pattern.clone()) {
1631                exclude.push(pattern.clone());
1632            }
1633        }
1634    }
1635    exclude
1636}
1637
1638// ── Validation ──────────────────────────────────────────────────────
1639
1640fn validate_inputs(
1641    coverage: &std::path::Path,
1642    src: &std::path::Path,
1643    threshold: f64,
1644    coverage_hint: &str,
1645) -> Result<()> {
1646    match std::fs::metadata(coverage) {
1647        Ok(m) if m.is_file() => {}
1648        Ok(_) => bail!(
1649            "coverage path is not a file: {}\n  \
1650             hint: pass --coverage pointing to a coverage file, not a directory",
1651            coverage.display()
1652        ),
1653        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1654            "coverage file not found: {}\n  hint: {coverage_hint}",
1655            coverage.display()
1656        ),
1657        Err(e) => bail!(
1658            "cannot access coverage file: {}: {e}\n  \
1659             hint: check file permissions",
1660            coverage.display()
1661        ),
1662    }
1663    match std::fs::metadata(src) {
1664        Ok(m) if m.is_dir() => {}
1665        Ok(_) => bail!(
1666            "source path is not a directory: {}\n  \
1667             hint: pass --src <DIR> pointing to your source root",
1668            src.display()
1669        ),
1670        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1671            "source directory not found: {}\n  \
1672             hint: pass --src <DIR> pointing to your source root",
1673            src.display()
1674        ),
1675        Err(e) => bail!(
1676            "cannot access source directory: {}: {e}\n  \
1677             hint: check directory permissions",
1678            src.display()
1679        ),
1680    }
1681    if !is_valid_threshold(threshold) {
1682        bail!(
1683            "threshold must be a finite positive number, got: {}",
1684            threshold
1685        );
1686    }
1687    Ok(())
1688}
1689
1690// ── Diff validation ────────────────────────────────────────────────
1691
1692fn validate_diff_ref(diff_ref: &str) -> Result<()> {
1693    if diff_ref.is_empty() {
1694        bail!("invalid diff ref: ref must not be empty");
1695    }
1696    if diff_ref.starts_with('-') {
1697        bail!(
1698            "invalid diff ref: {diff_ref}\n  \
1699             hint: ref must not start with a dash"
1700        );
1701    }
1702    Ok(())
1703}
1704
1705fn preflight_git_worktree(src: &Path) -> Result<()> {
1706    let output = std::process::Command::new("git")
1707        .current_dir(src)
1708        .args(["rev-parse", "--is-inside-work-tree"])
1709        .output();
1710
1711    match output {
1712        Ok(o) if o.status.success() => Ok(()),
1713        Ok(o) => {
1714            let stderr = String::from_utf8_lossy(&o.stderr);
1715            bail!(
1716                "not inside a git work tree\n  \
1717                 hint: --diff requires a git repository\n  \
1718                 git: {stderr}",
1719            );
1720        }
1721        Err(e) => bail!(
1722            "not inside a git work tree\n  \
1723             hint: --diff requires git to be installed\n  \
1724             error: {e}",
1725        ),
1726    }
1727}
1728
1729// ── Pre-flight checks ──────────────────────────────────────────────
1730
1731/// Adapter-aware coverage pre-flight: read the coverage file once and
1732/// delegate the structural check to `CoveragePort::validate`. The
1733/// source-directory check is handled by `core::ensure_source_files_found`
1734/// during the analyze pipeline — see ADR D-preflight-walker-reconcile.
1735fn preflight_checks<P>(
1736    coverage: &std::path::Path,
1737    coverage_port: &dyn CoveragePort<Diagnostic = P>,
1738    meta: &AdapterMeta,
1739) -> Result<()>
1740where
1741    P: ParseDiagnostic,
1742{
1743    check_coverage_has_data(coverage, coverage_port, meta.coverage_hint)
1744}
1745
1746fn check_coverage_has_data<P>(
1747    path: &std::path::Path,
1748    coverage_port: &dyn CoveragePort<Diagnostic = P>,
1749    coverage_hint: &str,
1750) -> Result<()>
1751where
1752    P: ParseDiagnostic,
1753{
1754    // The adapter's `validate` streams the file itself (LCOV) or
1755    // slurps (Istanbul, post-implementation) — whichever is cheaper
1756    // for that format. We do NOT pre-read here: `core::analyze` will
1757    // read the file again for the full parse pass, and slurping twice
1758    // for large workspaces (100 MB+ LCOV) is a memory regression.
1759    //
1760    // The validation reason (e.g. `"no SF/DA records"`) is surfaced
1761    // alongside the path so the user knows whether the file was
1762    // syntactically empty, malformed, or just missing data points.
1763    if let Err(reason) = coverage_port.validate(path) {
1764        bail!(
1765            "no coverage data found in {} ({reason})\n  hint: {}",
1766            path.display(),
1767            coverage_hint,
1768        );
1769    }
1770    Ok(())
1771}
1772
1773// ── Timestamp ──────────────────────────────────────────────────────
1774
1775fn now_unix_epoch() -> String {
1776    let secs = SystemTime::now()
1777        .duration_since(SystemTime::UNIX_EPOCH)
1778        .unwrap_or_default()
1779        .as_secs();
1780    format!("{secs}")
1781}
1782
1783// ── Verbose diagnostics ────────────────────────────────────────────
1784
1785fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
1786    files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
1787}
1788
1789fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
1790    if !diag.parse_diagnostics.is_empty() {
1791        eprintln!(
1792            "warning: {} coverage parse issue(s) encountered (use --verbose for details)",
1793            diag.parse_diagnostics.len()
1794        );
1795    }
1796    if diag.files_unparseable > 0 {
1797        eprintln!(
1798            "warning: {} source file(s) could not be parsed (use --verbose for details)",
1799            diag.files_unparseable
1800        );
1801    }
1802    if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
1803        eprintln!(
1804            "warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
1805            diag.files_zero_coverage, diag.files_analyzed
1806        );
1807        eprintln!(
1808            "  hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
1809        );
1810        eprintln!(
1811            "  hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
1812        );
1813    }
1814}
1815
1816fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
1817    eprintln!(
1818        "verbose: file discovery: {} files found, {} unparseable",
1819        diag.files_found, diag.files_unparseable
1820    );
1821    eprintln!(
1822        "verbose: complexity: {} functions extracted",
1823        diag.functions_extracted
1824    );
1825    eprintln!(
1826        "verbose: matching: {} matched with coverage, {} without coverage data",
1827        diag.functions_matched, diag.functions_no_coverage
1828    );
1829    eprintln!(
1830        "verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
1831        diag.files_analyzed, diag.files_zero_coverage
1832    );
1833    if !diag.parse_diagnostics.is_empty() {
1834        eprintln!(
1835            "verbose: coverage parse diagnostics ({}):",
1836            diag.parse_diagnostics.len()
1837        );
1838        for d in &diag.parse_diagnostics {
1839            eprintln!("  {d}");
1840        }
1841    }
1842}
1843
1844// ── Shell completions ───────────────────────────────────────────────
1845
1846/// Print a shell completion script to stdout for the given shell.
1847/// `clap_complete::generate` covers POSIX shells + PowerShell + Elvish;
1848/// nushell uses the separate `clap_complete_nushell` crate.
1849///
1850/// `bin_name` is the adapter binary's name (`crap4rs`, future
1851/// `crap4ts`, …) inferred at runtime from `argv[0]` — generated
1852/// completion scripts should reference the binary the user invoked,
1853/// not crap-core's library name.
1854fn emit_completions(shell: ShellArg, bin_name: &str) {
1855    let mut cmd = Cli::command();
1856    let stdout = &mut std::io::stdout();
1857    match shell {
1858        ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
1859        ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
1860        ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
1861        ShellArg::Powershell => {
1862            clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
1863        }
1864        ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
1865        ShellArg::Nushell => {
1866            clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
1867        }
1868    }
1869}
1870
1871// ── Color wiring ────────────────────────────────────────────────────
1872
1873fn apply_color(choice: ColorArg) {
1874    match choice {
1875        ColorArg::Auto => colored::control::unset_override(),
1876        ColorArg::Always => colored::control::set_override(true),
1877        ColorArg::Never => colored::control::set_override(false),
1878    }
1879}
1880
1881// ── Tests ───────────────────────────────────────────────────────────
1882
1883#[cfg(test)]
1884mod tests {
1885    use super::*;
1886    // `DEFAULT_THRESHOLD` is no longer referenced by `merge_threshold`
1887    // (it reads `meta.default_threshold` post-#218); only the tests
1888    // assert against the value crap4rs's `AdapterMeta` carries.
1889    use crate::domain::threshold::DEFAULT_THRESHOLD;
1890    use std::path::Path;
1891
1892    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1893        // argv[0] is a clap placeholder — kept adapter-agnostic
1894        // (`"test-adapter"`, not any real adapter binary's name) so
1895        // crap-core source has zero hardcoded references to its
1896        // consumers.
1897        let mut full = vec!["test-adapter"];
1898        full.extend_from_slice(args);
1899        Cli::try_parse_from(full)
1900    }
1901
1902    #[test]
1903    fn no_args_parses_with_coverage_none() {
1904        // `--coverage` is enforced at runtime via run_inner (so that
1905        // the `completions` subcommand can skip it), not at clap parse
1906        // time. Bare `crap4rs` therefore parses successfully here but
1907        // would `bail!` once dispatched.
1908        let cli = parse(&[]).unwrap();
1909        assert!(cli.input.coverage.is_none());
1910        assert!(cli.command.is_none());
1911    }
1912
1913    #[test]
1914    fn minimal_valid_args() {
1915        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1916        assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
1917        assert_eq!(cli.input.src, None);
1918    }
1919
1920    #[test]
1921    fn completions_subcommand_does_not_require_coverage() {
1922        let cli = parse(&["completions", "bash"]).unwrap();
1923        assert!(matches!(
1924            cli.command,
1925            Some(Command::Completions {
1926                shell: ShellArg::Bash
1927            })
1928        ));
1929        assert!(cli.input.coverage.is_none());
1930    }
1931
1932    #[test]
1933    fn default_metric_is_none() {
1934        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1935        assert!(cli.input.metric.is_none());
1936    }
1937
1938    #[test]
1939    fn default_format_is_table() {
1940        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1941        assert_eq!(cli.output.format.len(), 1);
1942        assert!(matches!(cli.output.format[0].format, FormatArg::Table));
1943        assert!(cli.output.format[0].output.is_none());
1944    }
1945
1946    #[test]
1947    fn default_threshold_is_none() {
1948        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1949        assert!(cli.output.threshold.is_none());
1950    }
1951
1952    #[test]
1953    fn default_color_is_auto() {
1954        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1955        assert!(matches!(cli.display.color, ColorArg::Auto));
1956    }
1957
1958    #[test]
1959    fn metric_cyclomatic() {
1960        let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
1961        assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
1962    }
1963
1964    #[test]
1965    fn format_json() {
1966        let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
1967        assert_eq!(cli.output.format.len(), 1);
1968        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1969        assert!(cli.output.format[0].output.is_none());
1970    }
1971
1972    #[test]
1973    fn format_sarif() {
1974        let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
1975        assert_eq!(cli.output.format.len(), 1);
1976        assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
1977    }
1978
1979    #[test]
1980    fn format_with_file_destination() {
1981        let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
1982        assert_eq!(cli.output.format.len(), 1);
1983        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1984        assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1985    }
1986
1987    #[test]
1988    fn format_multi_with_files() {
1989        let cli = parse(&[
1990            "--coverage",
1991            "lcov.info",
1992            "--format",
1993            "json:env.json,markdown:report.md",
1994        ])
1995        .unwrap();
1996        assert_eq!(cli.output.format.len(), 2);
1997        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1998        assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1999        assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
2000        assert_eq!(
2001            cli.output.format[1].output,
2002            Some(PathBuf::from("report.md"))
2003        );
2004    }
2005
2006    #[test]
2007    fn format_multi_with_two_stdout_specs_rejected() {
2008        let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
2009        let err = validate_display_flags(&cli).unwrap_err();
2010        let msg = err.to_string();
2011        assert!(msg.contains("multi-format"), "got: {msg}");
2012        assert!(msg.contains("stdout"), "got: {msg}");
2013        assert!(
2014            msg.contains("json"),
2015            "msg should name the stdout specs: {msg}"
2016        );
2017        assert!(
2018            msg.contains("markdown"),
2019            "msg should name the stdout specs: {msg}"
2020        );
2021    }
2022
2023    #[test]
2024    fn format_multi_with_single_stdout_plus_file_accepted() {
2025        // Locked shape D1 for github-annotations: composite workflows
2026        // emit `markdown:scorecard.md,github-annotations` in one
2027        // invocation. The validator permits exactly one stdout sink
2028        // alongside any number of file sinks.
2029        let cli = parse(&[
2030            "--coverage",
2031            "lcov.info",
2032            "--format",
2033            "markdown:scorecard.md,github-annotations",
2034        ])
2035        .unwrap();
2036        assert!(validate_display_flags(&cli).is_ok());
2037    }
2038
2039    #[test]
2040    fn format_multi_with_three_stdout_specs_rejected() {
2041        let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown,csv"]).unwrap();
2042        let err = validate_display_flags(&cli).unwrap_err();
2043        let msg = err.to_string();
2044        assert!(
2045            msg.contains("at most one stdout"),
2046            "rejection must name the rule, got: {msg}"
2047        );
2048    }
2049
2050    #[test]
2051    fn format_empty_path_rejected() {
2052        let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
2053        let msg = format!("{err}");
2054        assert!(msg.contains("empty file path"));
2055    }
2056
2057    #[test]
2058    fn custom_threshold() {
2059        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
2060        assert_eq!(cli.output.threshold, Some(15.5));
2061    }
2062
2063    #[test]
2064    fn custom_src() {
2065        let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2066        assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
2067    }
2068
2069    #[test]
2070    fn exclude_repeatable() {
2071        let cli = parse(&[
2072            "--coverage",
2073            "lcov.info",
2074            "--exclude",
2075            "tests/**",
2076            "--exclude",
2077            "benches/**",
2078        ])
2079        .unwrap();
2080        assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
2081    }
2082
2083    #[test]
2084    fn no_gitignore_flag() {
2085        let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
2086        assert!(cli.filter.no_gitignore);
2087    }
2088
2089    #[test]
2090    fn only_failing_flag() {
2091        let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
2092        assert!(cli.filter.only_failing);
2093    }
2094
2095    #[test]
2096    fn group_by_file_parses() {
2097        let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
2098        assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
2099    }
2100
2101    #[test]
2102    fn group_by_absence_is_none() {
2103        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2104        assert!(cli.filter.group_by.is_none());
2105    }
2106
2107    #[test]
2108    fn group_by_invalid_value_rejected() {
2109        let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
2110        let msg = err.to_string();
2111        assert!(msg.contains("invalid value"), "expected clap error: {msg}");
2112        assert!(
2113            msg.contains("--group-by") || msg.contains("module"),
2114            "error should attribute to --group-by: {msg}"
2115        );
2116    }
2117
2118    #[test]
2119    fn group_by_arg_to_domain_file() {
2120        let domain: GroupKey = GroupByArg::File.into();
2121        assert_eq!(domain, GroupKey::File);
2122    }
2123
2124    #[test]
2125    fn verbose_flag() {
2126        let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
2127        assert!(cli.display.verbose);
2128    }
2129
2130    #[test]
2131    fn quiet_flag() {
2132        let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
2133        assert!(cli.display.quiet);
2134    }
2135
2136    #[test]
2137    fn color_always() {
2138        let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
2139        assert!(matches!(cli.display.color, ColorArg::Always));
2140    }
2141
2142    #[test]
2143    fn color_never() {
2144        let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
2145        assert!(matches!(cli.display.color, ColorArg::Never));
2146    }
2147
2148    #[test]
2149    fn invalid_metric_rejected() {
2150        let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
2151        assert!(err.to_string().contains("invalid value"));
2152    }
2153
2154    #[test]
2155    fn invalid_format_rejected() {
2156        let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
2157        assert!(err.to_string().contains("invalid value"));
2158    }
2159
2160    #[test]
2161    fn metric_arg_to_domain_cognitive() {
2162        let domain: ComplexityMetric = MetricArg::Cognitive.into();
2163        assert_eq!(domain, ComplexityMetric::Cognitive);
2164    }
2165
2166    #[test]
2167    fn metric_arg_to_domain_cyclomatic() {
2168        let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
2169        assert_eq!(domain, ComplexityMetric::Cyclomatic);
2170    }
2171
2172    #[test]
2173    fn validate_missing_coverage_file_uses_adapter_hint() {
2174        let err = validate_inputs(
2175            Path::new("nonexistent.info"),
2176            Path::new("src"),
2177            DEFAULT_THRESHOLD,
2178            "run `cargo llvm-cov --lcov --output-path lcov.info` first",
2179        )
2180        .unwrap_err();
2181        let msg = format!("{err:#}");
2182        assert!(msg.contains("coverage file not found"));
2183        // Adapter-supplied hint flows through; crap-core itself stays neutral.
2184        assert!(msg.contains("cargo llvm-cov"));
2185    }
2186
2187    #[test]
2188    fn validate_missing_src_dir() {
2189        let err = validate_inputs(
2190            Path::new("Cargo.toml"),
2191            Path::new("nonexistent_dir"),
2192            DEFAULT_THRESHOLD,
2193            "test-hint",
2194        )
2195        .unwrap_err();
2196        let msg = format!("{err:#}");
2197        assert!(msg.contains("source directory not found"));
2198    }
2199
2200    #[test]
2201    fn validate_negative_threshold() {
2202        let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0, "test-hint")
2203            .unwrap_err();
2204        let msg = format!("{err:#}");
2205        assert!(msg.contains("threshold must be a finite positive number"));
2206    }
2207
2208    #[test]
2209    fn validate_zero_threshold() {
2210        let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0, "test-hint")
2211            .unwrap_err();
2212        let msg = format!("{err:#}");
2213        assert!(msg.contains("threshold must be a finite positive number"));
2214    }
2215
2216    #[test]
2217    fn validate_infinity_threshold() {
2218        let err = validate_inputs(
2219            Path::new("Cargo.toml"),
2220            Path::new("src"),
2221            f64::INFINITY,
2222            "test-hint",
2223        )
2224        .unwrap_err();
2225        let msg = format!("{err:#}");
2226        assert!(msg.contains("threshold must be a finite positive number"));
2227    }
2228
2229    #[test]
2230    fn validate_src_is_file_not_dir() {
2231        let err = validate_inputs(
2232            Path::new("Cargo.toml"),
2233            Path::new("Cargo.toml"),
2234            DEFAULT_THRESHOLD,
2235            "test-hint",
2236        )
2237        .unwrap_err();
2238        let msg = format!("{err:#}");
2239        assert!(msg.contains("source path is not a directory"));
2240    }
2241
2242    #[test]
2243    fn validate_coverage_is_dir_not_file() {
2244        let err = validate_inputs(
2245            Path::new("src"),
2246            Path::new("src"),
2247            DEFAULT_THRESHOLD,
2248            "test-hint",
2249        )
2250        .unwrap_err();
2251        let msg = format!("{err:#}");
2252        assert!(msg.contains("coverage path is not a file"));
2253    }
2254
2255    #[test]
2256    fn format_short_flag() {
2257        let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
2258        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
2259    }
2260
2261    #[test]
2262    fn config_flag_accepts_path() {
2263        let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
2264        assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
2265    }
2266
2267    #[test]
2268    fn config_flag_defaults_to_none() {
2269        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2270        assert_eq!(cli.input.config, None);
2271    }
2272
2273    #[test]
2274    fn view_flag_accepts_name() {
2275        let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
2276        assert_eq!(cli.input.view, Some("ci".to_string()));
2277    }
2278
2279    #[test]
2280    fn view_flag_defaults_to_none() {
2281        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2282        assert_eq!(cli.input.view, None);
2283    }
2284
2285    #[test]
2286    fn merge_threshold_cli_overrides_config() {
2287        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2288        let file_config = Some(FileConfig {
2289            threshold: Some(10.0),
2290            ..FileConfig::default()
2291        });
2292        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2293        assert_eq!(config.global, 15.0);
2294        assert_eq!(display, 15.0);
2295    }
2296
2297    #[test]
2298    fn merge_threshold_uses_config_when_cli_default() {
2299        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2300        let file_config = Some(FileConfig {
2301            threshold: Some(12.0),
2302            ..FileConfig::default()
2303        });
2304        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2305        assert_eq!(config.global, 12.0);
2306        assert_eq!(display, 12.0);
2307    }
2308
2309    #[test]
2310    fn merge_threshold_preserves_overrides() {
2311        use crate::domain::threshold::ThresholdOverride;
2312        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2313        let file_config = Some(FileConfig {
2314            threshold: Some(10.0),
2315            overrides: vec![ThresholdOverride {
2316                pattern: "domain/**".to_string(),
2317                threshold: 5.0,
2318            }],
2319            ..FileConfig::default()
2320        });
2321        let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2322        assert_eq!(config.overrides.len(), 1);
2323        assert_eq!(config.overrides[0].pattern, "domain/**");
2324    }
2325
2326    #[test]
2327    fn merge_threshold_no_config() {
2328        let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
2329        let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2330        assert_eq!(config.global, 20.0);
2331        assert!(config.overrides.is_empty());
2332        assert_eq!(display, 20.0);
2333    }
2334
2335    #[test]
2336    fn merge_threshold_explicit_default_overrides_config() {
2337        // User explicitly passes --threshold 15.0 (same as DEFAULT_THRESHOLD).
2338        // This MUST override the config file's threshold of 12.0.
2339        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2340        let file_config = Some(FileConfig {
2341            threshold: Some(12.0),
2342            ..FileConfig::default()
2343        });
2344        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2345        assert_eq!(
2346            config.global, 15.0,
2347            "explicit CLI default must override config"
2348        );
2349        assert_eq!(display, 15.0);
2350    }
2351
2352    #[test]
2353    fn merge_threshold_no_flag_default_is_metric_keyed() {
2354        // No-flag/no-config fallthrough is the `Default` tier resolved
2355        // against the effective metric. Both columns currently hold 15
2356        // (post-#272 alignment); the metric-keyed routing path is
2357        // exercised so a future per-metric divergence surfaces here.
2358        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2359        let (cog, cog_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2360        assert_eq!(cog.global, 15.0);
2361        assert_eq!(cog_disp, 15.0);
2362        let (cyc, cyc_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cyclomatic);
2363        assert_eq!(cyc.global, 15.0);
2364        assert_eq!(cyc_disp, 15.0);
2365    }
2366
2367    #[test]
2368    fn merge_threshold_strict_lenient_are_metric_keyed() {
2369        // `--strict` / `--lenient` resolve per metric. Columns are
2370        // flat-equal post-#272; the metric-keyed routing path is still
2371        // exercised so a future per-metric divergence surfaces here.
2372        let strict = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2373        assert_eq!(
2374            merge_threshold(&strict, &None, ComplexityMetric::Cognitive).1,
2375            8.0
2376        );
2377        assert_eq!(
2378            merge_threshold(&strict, &None, ComplexityMetric::Cyclomatic).1,
2379            8.0
2380        );
2381        let lenient = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2382        assert_eq!(
2383            merge_threshold(&lenient, &None, ComplexityMetric::Cognitive).1,
2384            25.0
2385        );
2386        assert_eq!(
2387            merge_threshold(&lenient, &None, ComplexityMetric::Cyclomatic).1,
2388            25.0
2389        );
2390    }
2391
2392    #[test]
2393    fn merge_exclude_combines_cli_and_config() {
2394        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2395        let file_config = Some(FileConfig {
2396            exclude: Some(vec!["benches/**".to_string()]),
2397            ..FileConfig::default()
2398        });
2399        let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2400        assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2401    }
2402
2403    #[test]
2404    fn merge_exclude_deduplicates() {
2405        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2406        let file_config = Some(FileConfig {
2407            exclude: Some(vec!["tests/**".to_string()]),
2408            ..FileConfig::default()
2409        });
2410        let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2411        assert_eq!(exclude, vec!["tests/**"]);
2412    }
2413
2414    /// `AdapterMeta::forced_excludes` patterns are prepended to the
2415    /// effective exclude list at analysis time so an adapter can
2416    /// structurally skip files that are never source code in its
2417    /// language (`**/*.d.ts` for crap4ts — crap-rs#253). CLI flags and
2418    /// config-file entries layer on top, with duplicates dropped.
2419    #[test]
2420    fn merge_exclude_prepends_forced_excludes_from_adapter_meta() {
2421        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2422        let file_config = Some(FileConfig {
2423            exclude: Some(vec!["benches/**".to_string()]),
2424            ..FileConfig::default()
2425        });
2426        let meta = AdapterMeta {
2427            forced_excludes: &["**/*.d.ts"],
2428            ..fake_meta()
2429        };
2430        let exclude = merge_exclude(&cli, &file_config, &meta);
2431        assert_eq!(exclude, vec!["**/*.d.ts", "tests/**", "benches/**"]);
2432    }
2433
2434    /// Empty `forced_excludes` (crap4rs's setting today) is a no-op —
2435    /// the merge is identical to the pre-#253 behavior of CLI then
2436    /// file-config patterns.
2437    #[test]
2438    fn merge_exclude_with_empty_forced_excludes_matches_legacy_behavior() {
2439        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2440        let file_config = Some(FileConfig {
2441            exclude: Some(vec!["benches/**".to_string()]),
2442            ..FileConfig::default()
2443        });
2444        let meta = AdapterMeta {
2445            forced_excludes: &[],
2446            ..fake_meta()
2447        };
2448        let exclude = merge_exclude(&cli, &file_config, &meta);
2449        assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2450    }
2451
2452    /// A `forced_excludes` pattern already present in CLI flags is
2453    /// deduplicated — appears once, in its forced-prefix position.
2454    #[test]
2455    fn merge_exclude_forced_excludes_deduplicates_against_cli_and_config() {
2456        let cli = parse(&["--coverage", "lcov.info", "--exclude", "**/*.d.ts"]).unwrap();
2457        let file_config = Some(FileConfig {
2458            exclude: Some(vec!["**/*.d.ts".to_string(), "benches/**".to_string()]),
2459            ..FileConfig::default()
2460        });
2461        let meta = AdapterMeta {
2462            forced_excludes: &["**/*.d.ts"],
2463            ..fake_meta()
2464        };
2465        let exclude = merge_exclude(&cli, &file_config, &meta);
2466        assert_eq!(exclude, vec!["**/*.d.ts", "benches/**"]);
2467    }
2468
2469    // ── --diff flag tests ───────────────────────────────────────────
2470
2471    #[test]
2472    fn diff_flag_accepts_ref() {
2473        let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
2474        assert_eq!(cli.filter.diff, Some("main".to_string()));
2475    }
2476
2477    #[test]
2478    fn diff_flag_defaults_to_none() {
2479        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2480        assert_eq!(cli.filter.diff, None);
2481    }
2482
2483    #[test]
2484    fn diff_flag_accepts_commit_sha() {
2485        let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
2486        assert_eq!(cli.filter.diff, Some("abc123".to_string()));
2487    }
2488
2489    #[test]
2490    fn diff_flag_accepts_head_tilde() {
2491        let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
2492        assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
2493    }
2494
2495    #[test]
2496    fn validate_diff_ref_rejects_empty_string() {
2497        let err = validate_diff_ref("").unwrap_err();
2498        let msg = format!("{err:#}");
2499        assert!(msg.contains("must not be empty"));
2500    }
2501
2502    #[test]
2503    fn validate_diff_ref_rejects_dash_prefix() {
2504        let err = validate_diff_ref("--malicious").unwrap_err();
2505        let msg = format!("{err:#}");
2506        assert!(msg.contains("invalid diff ref"));
2507        assert!(msg.contains("must not start with a dash"));
2508    }
2509
2510    #[test]
2511    fn validate_diff_ref_accepts_normal_ref() {
2512        assert!(validate_diff_ref("main").is_ok());
2513        assert!(validate_diff_ref("HEAD~1").is_ok());
2514        assert!(validate_diff_ref("abc123").is_ok());
2515    }
2516
2517    #[test]
2518    fn preflight_git_worktree_passes_in_git_repo() {
2519        // Initialize a fresh git repo in a temp dir so the test is self-contained
2520        // and works under tools (e.g. cargo-mutants) that copy the source tree
2521        // without `.git`.
2522        let tmp = tempfile::tempdir().unwrap();
2523        let status = std::process::Command::new("git")
2524            .arg("init")
2525            .arg("--quiet")
2526            .current_dir(tmp.path())
2527            .status()
2528            .expect("git init");
2529        assert!(status.success(), "git init failed");
2530        assert!(preflight_git_worktree(tmp.path()).is_ok());
2531    }
2532
2533    #[test]
2534    fn breakdown_flag_parsed() {
2535        let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
2536        assert!(cli.display.breakdown);
2537    }
2538
2539    #[test]
2540    fn breakdown_flag_default_false() {
2541        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2542        assert!(!cli.display.breakdown);
2543    }
2544
2545    #[test]
2546    fn explain_flag_parsed() {
2547        let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2548        assert!(cli.display.explain);
2549    }
2550
2551    #[test]
2552    fn explain_flag_default_false() {
2553        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2554        assert!(!cli.display.explain);
2555    }
2556
2557    #[test]
2558    fn explain_requires_breakdown_for_table_output() {
2559        let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2560        let err = validate_display_flags(&cli).unwrap_err();
2561        let msg = err.to_string();
2562        assert!(msg.contains("--breakdown"));
2563        assert!(msg.contains("--explain"));
2564    }
2565
2566    #[test]
2567    fn explain_allowed_for_json_output() {
2568        let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
2569        assert!(validate_display_flags(&cli).is_ok());
2570    }
2571
2572    #[test]
2573    fn color_overrides_set_global_state() {
2574        // Combined into one test to avoid nondeterministic interleaving —
2575        // colored::control uses a process-global flag that parallel tests
2576        // can race on.
2577        apply_color(ColorArg::Never);
2578        assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
2579
2580        apply_color(ColorArg::Always);
2581        assert!(colored::control::SHOULD_COLORIZE.should_colorize());
2582
2583        apply_color(ColorArg::Auto);
2584    }
2585
2586    // ── Pre-flight check tests ─────────────────────────────────────────
2587
2588    // Synthetic adapter values for tests — match the placeholder used
2589    // throughout the in-crate test suite. Real adapters supply real
2590    // values via `AdapterMeta`.
2591    const TEST_COVERAGE_HINT: &str =
2592        "ensure tests ran with coverage enabled (test-tool's `--coverage` flag)";
2593
2594    /// Stub `CoveragePort` whose `validate` returns whatever the caller
2595    /// configured. `parse` panics — these tests exercise the CLI-layer
2596    /// preflight wrapper, not the adapter's parsing path.
2597    struct StubCoveragePort {
2598        validate_result: Result<(), String>,
2599    }
2600
2601    impl CoveragePort for StubCoveragePort {
2602        type Diagnostic = crate::test_strategies::DummyParseDiagnostic;
2603
2604        fn parse(
2605            &self,
2606            _path: &std::path::Path,
2607        ) -> Result<crate::ports::ParseOutput<Self::Diagnostic>, crate::domain::types::CrapError>
2608        {
2609            unreachable!("preflight tests never invoke parse")
2610        }
2611
2612        fn validate(&self, _path: &std::path::Path) -> Result<(), String> {
2613            self.validate_result.clone()
2614        }
2615    }
2616
2617    fn stub_ok() -> StubCoveragePort {
2618        StubCoveragePort {
2619            validate_result: Ok(()),
2620        }
2621    }
2622
2623    fn stub_err(reason: &str) -> StubCoveragePort {
2624        StubCoveragePort {
2625            validate_result: Err(reason.to_string()),
2626        }
2627    }
2628
2629    #[test]
2630    fn preflight_surfaces_hint_when_adapter_reports_no_data() {
2631        let dir = tempfile::tempdir().unwrap();
2632        let cov = dir.path().join("empty.info");
2633        std::fs::write(&cov, "").unwrap();
2634
2635        let err =
2636            check_coverage_has_data(&cov, &stub_err("no records"), TEST_COVERAGE_HINT).unwrap_err();
2637        let msg = format!("{err:#}");
2638        assert!(msg.contains("no coverage data found"));
2639        // The adapter's structural reason is surfaced alongside the
2640        // path so the user knows whether the file was empty,
2641        // malformed, or just missing data points.
2642        assert!(msg.contains("no records"), "expected reason in msg: {msg}");
2643        assert!(msg.contains(TEST_COVERAGE_HINT));
2644    }
2645
2646    #[test]
2647    fn preflight_passes_when_adapter_accepts_data() {
2648        let dir = tempfile::tempdir().unwrap();
2649        let cov = dir.path().join("ok.info");
2650        std::fs::write(&cov, "any contents — adapter decides").unwrap();
2651
2652        assert!(check_coverage_has_data(&cov, &stub_ok(), TEST_COVERAGE_HINT).is_ok());
2653    }
2654
2655    // ── --strict / --lenient flag tests ───────────────────────────────
2656
2657    #[test]
2658    fn strict_flag_parses() {
2659        let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2660        assert!(cli.output.strict);
2661    }
2662
2663    #[test]
2664    fn lenient_flag_parses() {
2665        let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2666        assert!(cli.output.lenient);
2667    }
2668
2669    #[test]
2670    fn strict_and_threshold_mutually_exclusive() {
2671        parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
2672    }
2673
2674    #[test]
2675    fn strict_and_lenient_mutually_exclusive() {
2676        parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
2677    }
2678
2679    #[test]
2680    fn merge_threshold_strict_flag() {
2681        use crate::domain::threshold::STRICT_THRESHOLD;
2682        let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2683        let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2684        assert_eq!(config.global, STRICT_THRESHOLD);
2685        assert_eq!(display, STRICT_THRESHOLD);
2686    }
2687
2688    #[test]
2689    fn merge_threshold_lenient_flag() {
2690        use crate::domain::threshold::LENIENT_THRESHOLD;
2691        let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2692        let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2693        assert_eq!(config.global, LENIENT_THRESHOLD);
2694        assert_eq!(display, LENIENT_THRESHOLD);
2695    }
2696
2697    #[test]
2698    fn merge_threshold_toml_preset_used_when_no_cli_flag() {
2699        use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
2700        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2701        let file_config = Some(FileConfig {
2702            preset: Some(ThresholdPreset::Strict),
2703            ..FileConfig::default()
2704        });
2705        let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2706        assert_eq!(config.global, STRICT_THRESHOLD);
2707    }
2708
2709    #[test]
2710    fn merge_threshold_config_literal_overrides_config_preset() {
2711        // A literal `threshold = N` in the config is the most specific
2712        // expression of intent and must beat a named `preset` in the
2713        // same file — mirrors CLI semantics where `--threshold N` beats
2714        // `--strict`/`--lenient`. Without this, a user who sets both
2715        // would silently get the preset and never the number they typed.
2716        use crate::domain::threshold::ThresholdPreset;
2717        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2718        let file_config = Some(FileConfig {
2719            preset: Some(ThresholdPreset::Strict),
2720            threshold: Some(99.0),
2721            ..FileConfig::default()
2722        });
2723        let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2724        assert_eq!(config.global, 99.0);
2725        assert_eq!(display, 99.0);
2726    }
2727
2728    #[test]
2729    fn merge_threshold_cli_threshold_overrides_toml_preset() {
2730        use crate::domain::threshold::ThresholdPreset;
2731        let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
2732        let file_config = Some(FileConfig {
2733            preset: Some(ThresholdPreset::Strict),
2734            ..FileConfig::default()
2735        });
2736        let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2737        assert_eq!(config.global, 50.0);
2738    }
2739
2740    // ── majority_zero_coverage predicate tests ─────────────────────────
2741
2742    #[test]
2743    fn zero_coverage_warn_triggers_above_50_percent() {
2744        assert!(majority_zero_coverage(10, 6));
2745        assert!(majority_zero_coverage(1, 1));
2746        assert!(majority_zero_coverage(3, 2));
2747    }
2748
2749    #[test]
2750    fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
2751        assert!(!majority_zero_coverage(10, 5));
2752        assert!(!majority_zero_coverage(2, 1));
2753    }
2754
2755    #[test]
2756    fn zero_coverage_warn_does_not_trigger_when_no_files() {
2757        assert!(!majority_zero_coverage(0, 0));
2758    }
2759
2760    // ── merge_effective_inputs tests ───────────────────────────────────
2761
2762    #[test]
2763    fn merge_effective_inputs_default_src() {
2764        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2765        let inputs = merge_effective_inputs(&cli, &None, &fake_meta());
2766        assert_eq!(inputs.src, PathBuf::from("src"));
2767    }
2768
2769    #[test]
2770    fn merge_effective_inputs_cli_src_wins_over_config() {
2771        let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2772        let file_config = Some(FileConfig {
2773            src: Some(PathBuf::from("from-config/")),
2774            ..FileConfig::default()
2775        });
2776        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2777        assert_eq!(inputs.src, PathBuf::from("crates/"));
2778    }
2779
2780    #[test]
2781    fn merge_effective_inputs_config_src_when_cli_absent() {
2782        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2783        let file_config = Some(FileConfig {
2784            src: Some(PathBuf::from("from-config/")),
2785            ..FileConfig::default()
2786        });
2787        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2788        assert_eq!(inputs.src, PathBuf::from("from-config/"));
2789    }
2790
2791    #[test]
2792    fn merge_effective_inputs_uses_adapter_default_metric_cognitive() {
2793        // Replaces the pre-W2.5 `merge_effective_inputs_default_metric_is_cognitive`
2794        // test. Now that the fallthrough comes from `meta.default_metric`
2795        // (not `ComplexityMetric::default()`), the assertion is "the
2796        // adapter's default flows through when neither CLI nor config
2797        // override it." crap4rs sets this to `Cognitive`.
2798        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2799        let meta = AdapterMeta {
2800            default_metric: ComplexityMetric::Cognitive,
2801            ..fake_meta()
2802        };
2803        let inputs = merge_effective_inputs(&cli, &None, &meta);
2804        assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2805    }
2806
2807    #[test]
2808    fn merge_effective_inputs_uses_adapter_default_metric_cyclomatic() {
2809        // Replaces the pre-W2.5 `merge_effective_inputs_default_metric_is_cognitive`
2810        // test. Mirror of the Cognitive case for the crap4ts adapter
2811        // (locked decision #2: crap4ts default = cyclomatic).
2812        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2813        let meta = AdapterMeta {
2814            default_metric: ComplexityMetric::Cyclomatic,
2815            ..fake_meta()
2816        };
2817        let inputs = merge_effective_inputs(&cli, &None, &meta);
2818        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2819    }
2820
2821    #[test]
2822    fn merge_effective_inputs_cli_metric_overrides_config() {
2823        let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
2824        let file_config = Some(FileConfig {
2825            metric: Some(ComplexityMetric::Cognitive),
2826            ..FileConfig::default()
2827        });
2828        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2829        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2830    }
2831
2832    #[test]
2833    fn merge_effective_inputs_default_threshold_follows_adapter_metric_cognitive() {
2834        // End-to-end wiring: an adapter whose default metric is
2835        // cognitive, with no `--threshold`/`--metric`/config, resolves
2836        // the no-flag gate to the cognitive `Default` cutoff (15).
2837        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2838        let meta = AdapterMeta {
2839            default_metric: ComplexityMetric::Cognitive,
2840            ..fake_meta()
2841        };
2842        let inputs = merge_effective_inputs(&cli, &None, &meta);
2843        assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2844        assert_eq!(inputs.threshold, 15.0);
2845    }
2846
2847    #[test]
2848    fn merge_effective_inputs_default_threshold_follows_adapter_metric_cyclomatic() {
2849        // Mirror for a cyclomatic-default adapter (crap4ts): the no-flag
2850        // gate routes through the cyclomatic column. Columns are flat-
2851        // equal post-#272 (both 15); the metric-keyed routing is what
2852        // this test locks, not the numeric value.
2853        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2854        let meta = AdapterMeta {
2855            default_metric: ComplexityMetric::Cyclomatic,
2856            ..fake_meta()
2857        };
2858        let inputs = merge_effective_inputs(&cli, &None, &meta);
2859        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2860        assert_eq!(inputs.threshold, 15.0);
2861    }
2862
2863    #[test]
2864    fn merge_effective_inputs_exclude_combines_cli_and_config() {
2865        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2866        let file_config = Some(FileConfig {
2867            exclude: Some(vec!["benches/**".to_string()]),
2868            ..FileConfig::default()
2869        });
2870        let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2871        assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
2872    }
2873
2874    #[test]
2875    fn merge_effective_inputs_config_metric_wins_over_adapter_default() {
2876        // Config-file metric should still beat the adapter default —
2877        // adapter default is the FINAL fallthrough, below CLI and
2878        // config-file precedence.
2879        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2880        let file_config = Some(FileConfig {
2881            metric: Some(ComplexityMetric::Cyclomatic),
2882            ..FileConfig::default()
2883        });
2884        let meta = AdapterMeta {
2885            default_metric: ComplexityMetric::Cognitive,
2886            ..fake_meta()
2887        };
2888        let inputs = merge_effective_inputs(&cli, &file_config, &meta);
2889        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2890    }
2891
2892    // ── compute_exit_code tests ────────────────────────────────────────
2893    //
2894    // delta_state=None covers the analysis-only paths; the delta-gate +
2895    // delta_state=Some interactions are exercised end-to-end in
2896    // delta_gate_integration.rs (where AnalysisDelta is built through
2897    // the real `delta::compute` path rather than mocked).
2898
2899    #[test]
2900    fn compute_exit_code_passing_no_delta() {
2901        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2902        assert!(compute_exit_code::<
2903            crate::test_strategies::DummyParseDiagnostic,
2904        >(&cli, true, None));
2905    }
2906
2907    #[test]
2908    fn compute_exit_code_failing_no_delta() {
2909        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2910        assert!(!compute_exit_code::<
2911            crate::test_strategies::DummyParseDiagnostic,
2912        >(&cli, false, None));
2913    }
2914
2915    #[test]
2916    fn compute_exit_code_no_fail_overrides_failure() {
2917        let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
2918        assert!(compute_exit_code::<
2919            crate::test_strategies::DummyParseDiagnostic,
2920        >(&cli, false, None));
2921    }
2922
2923    #[test]
2924    fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
2925        // delta_state=None → delta_passed defaults to true even with
2926        // --delta-gate; this matches the runtime behavior when the
2927        // baseline file is missing or unreadable. Clap requires
2928        // --baseline to accompany --delta-gate at parse time, so we
2929        // pass a sentinel path to satisfy the parser without exercising
2930        // the file load (compute_exit_code only inspects the resolved
2931        // delta state, not cli.input.baseline).
2932        let cli = parse(&[
2933            "--coverage",
2934            "lcov.info",
2935            "--delta-gate",
2936            "--baseline",
2937            "/dev/null",
2938        ])
2939        .unwrap();
2940        assert!(compute_exit_code::<
2941            crate::test_strategies::DummyParseDiagnostic,
2942        >(&cli, true, None));
2943    }
2944
2945    #[test]
2946    fn compute_exit_code_no_fail_with_delta_gate() {
2947        // --no-fail is the master override even when --delta-gate
2948        // is set.
2949        let cli = parse(&[
2950            "--coverage",
2951            "lcov.info",
2952            "--delta-gate",
2953            "--baseline",
2954            "/dev/null",
2955            "--no-fail",
2956        ])
2957        .unwrap();
2958        assert!(compute_exit_code::<
2959            crate::test_strategies::DummyParseDiagnostic,
2960        >(&cli, false, None));
2961    }
2962
2963    // ── AdapterMeta unit tests (#161) ──────────────────────────────
2964
2965    fn fake_meta() -> AdapterMeta {
2966        AdapterMeta {
2967            tool_name: "fake-adapter",
2968            tool_version: "9.9.9",
2969            long_version: "9.9.9 (test 2099-01-01)",
2970            about: "Fake adapter for tests",
2971            long_about: "Fake adapter for tests — verifies AdapterMeta plumbing without binding crap-core to any real adapter.",
2972            after_help: "",
2973            coverage_hint: "no coverage tool — fake adapter",
2974            extensions: &["fake"],
2975            tool_info_uri: "https://example.invalid/fake-adapter",
2976            rule_help_uri: "https://example.invalid/fake-adapter#rules",
2977            config_file_name: "fake-adapter.toml",
2978            default_excludes: &["fixtures/**"],
2979            // Empty in the shared fake so tests that don't care don't
2980            // see an unexpected prefix; the two `merge_exclude` tests
2981            // that DO care construct their own AdapterMeta with an
2982            // explicit `forced_excludes`.
2983            forced_excludes: &[],
2984            // `Cognitive` preserves the pre-W2.5 fallthrough semantics
2985            // for tests that don't care which default they get (the two
2986            // tests that DO care construct their own AdapterMeta with
2987            // an explicit `default_metric`).
2988            default_metric: ComplexityMetric::Cognitive,
2989        }
2990    }
2991
2992    #[test]
2993    fn adapter_meta_extensions_owned_roundtrips_to_owned_strings() {
2994        let meta = AdapterMeta {
2995            extensions: &["ts", "tsx", "js"],
2996            ..fake_meta()
2997        };
2998        let owned = meta.extensions_owned();
2999        assert_eq!(
3000            owned,
3001            vec!["ts".to_string(), "tsx".to_string(), "js".to_string()]
3002        );
3003        // Round-trip via Vec<&str> back to a slice-equivalent shape.
3004        let back: Vec<&str> = owned.iter().map(String::as_str).collect();
3005        assert_eq!(back, &["ts", "tsx", "js"]);
3006    }
3007
3008    #[test]
3009    fn adapter_meta_extensions_owned_handles_empty_slice() {
3010        // `extensions` is allowed to be empty; the diagnostic surfaces
3011        // downstream in `core::ensure_source_files_found`.
3012        let meta = AdapterMeta {
3013            extensions: &[],
3014            ..fake_meta()
3015        };
3016        assert!(meta.extensions_owned().is_empty());
3017    }
3018
3019    #[test]
3020    #[should_panic(expected = "tool_name must not be empty")]
3021    fn adapter_meta_debug_assert_trips_on_empty_tool_name() {
3022        let meta = AdapterMeta {
3023            tool_name: "",
3024            ..fake_meta()
3025        };
3026        meta.debug_assert_required_fields();
3027    }
3028
3029    #[test]
3030    #[should_panic(expected = "config_file_name must not be empty")]
3031    fn adapter_meta_debug_assert_trips_on_empty_config_file_name() {
3032        let meta = AdapterMeta {
3033            config_file_name: "",
3034            ..fake_meta()
3035        };
3036        meta.debug_assert_required_fields();
3037    }
3038
3039    #[test]
3040    fn adapter_meta_debug_assert_passes_on_all_fields_set() {
3041        // Smoke test: a meta with every required field populated should
3042        // pass the debug_assert sweep without panicking.
3043        fake_meta().debug_assert_required_fields();
3044    }
3045}