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//! Relocated from `crap4rs::cli` in S4 (#136). The orchestrator
7//! `cli::run<P>` is generic over the coverage adapter's parse-diagnostic
8//! type so the same dispatch shell drives every adapter binary
9//! (`crap4rs`, future `crap4ts`). Per-binary main.rs supplies the
10//! complexity + coverage ports as `&dyn` trait objects (ADR D9).
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::{
27    DEFAULT_THRESHOLD, LENIENT_THRESHOLD, STRICT_THRESHOLD, ThresholdConfig, is_valid_threshold,
28};
29use crate::domain::types::{AnalysisDiagnostics, ComplexityMetric};
30use crate::domain::view::{self, GroupKey, SortKey};
31use crate::ports::{ComplexityPort, CoveragePort, ParseDiagnostic};
32
33mod delta_args;
34mod view_args;
35
36// ── ValueEnum wrappers (keep domain types clap-free) ────────────────
37
38/// Complexity metric for CRAP score computation.
39#[derive(Debug, Clone, Copy, ValueEnum)]
40pub enum MetricArg {
41    /// Nesting depth + structural complexity (default for Rust)
42    Cognitive,
43    /// Decision-point count, classic CRAP metric
44    Cyclomatic,
45}
46
47impl From<MetricArg> for ComplexityMetric {
48    fn from(arg: MetricArg) -> Self {
49        match arg {
50            MetricArg::Cognitive => ComplexityMetric::Cognitive,
51            MetricArg::Cyclomatic => ComplexityMetric::Cyclomatic,
52        }
53    }
54}
55
56/// Output format for the CRAP report.
57#[derive(Debug, Clone, Copy, ValueEnum)]
58pub enum FormatArg {
59    /// Human-readable table with ANSI colors
60    Table,
61    /// Nested JSON envelope (pipe to jq for filtering)
62    Json,
63    /// GitHub-flavored Markdown — paste into PR comments or issues
64    Markdown,
65    /// RFC 4180 CSV — one row per function, no summary
66    Csv,
67    /// SARIF v2.1.0 — for GitHub Code Scanning (upload-sarif@v3)
68    Sarif,
69    /// Agent-oriented JSON with Diagnostic remediation hints (experimental)
70    Advice,
71    /// Single mokumo-scorecard `Row::CrapDelta` JSON object — for scorecard
72    /// aggregator consumption (mokumo schema_version=2). Issue #111.
73    ScorecardRow,
74    /// Self-contained HTML dashboard with summary stats, risk
75    /// distribution, and per-file collapsible function tables. Inline
76    /// CSS, no external assets, mobile-responsive. Issue #71.
77    Html,
78}
79
80/// One requested output: a format and an optional file destination.
81///
82/// Parsed from `--format X` (stdout) or `--format X:FILE` (write to file).
83/// `--format` accepts a comma-separated list of these specs so a single
84/// analysis pass can fan out to multiple shapes (issue #100).
85#[derive(Debug, Clone)]
86pub struct FormatSpec {
87    pub format: FormatArg,
88    pub output: Option<PathBuf>,
89}
90
91impl std::str::FromStr for FormatSpec {
92    type Err = String;
93
94    fn from_str(spec: &str) -> Result<Self, Self::Err> {
95        let (fmt_str, output) = match spec.split_once(':') {
96            Some((f, path)) if !path.is_empty() => (f, Some(PathBuf::from(path))),
97            Some((_, _)) => return Err(format!("empty file path in `--format {spec}`")),
98            None => (spec, None),
99        };
100        let format = FormatArg::from_str(fmt_str, true)
101            .map_err(|e| format!("invalid format `{fmt_str}`: {e}"))?;
102        Ok(FormatSpec { format, output })
103    }
104}
105
106/// Clap value parser for `FormatSpec` — delegates to the `FromStr` impl.
107fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
108    s.parse()
109}
110
111/// Sort key for the displayed view (issue #68).
112///
113/// CLI-side wrapper that keeps `clap::ValueEnum` out of the domain.
114/// `From<SortKeyArg> for SortKey` is the boundary; `build_view_spec`
115/// translates at the edge so `domain::view::SortKey` stays clap-free.
116#[derive(Debug, Clone, Copy, ValueEnum)]
117pub enum SortKeyArg {
118    /// CRAP score descending (default — investigator's first cut)
119    Crap,
120    /// Coverage percent ascending (lowest coverage first)
121    Coverage,
122    /// Complexity descending (most complex first)
123    Complexity,
124    /// Alphabetical by file_path, then CRAP descending within file
125    Path,
126}
127
128impl From<SortKeyArg> for SortKey {
129    fn from(arg: SortKeyArg) -> Self {
130        match arg {
131            SortKeyArg::Crap => SortKey::Crap,
132            SortKeyArg::Coverage => SortKey::Coverage,
133            SortKeyArg::Complexity => SortKey::Complexity,
134            SortKeyArg::Path => SortKey::Path,
135        }
136    }
137}
138
139/// Reverse mapping for saved view presets (issue #80) — preset stores
140/// domain `SortKey`, but `FilterArgs.sort_by` is the clap-side wrapper.
141///
142/// `SortKey` is `#[non_exhaustive]` for cross-crate consumers, but
143/// post-S4 (#136) the cli module lives in the same crate as the domain
144/// `SortKey` definition, so the compiler treats the match as exhaustive
145/// without a wildcard arm. New domain variants must still land with a
146/// paired CLI variant in the same PR — clippy's missing-pattern error
147/// is now the loud failure point (the formerly-required wildcard arm
148/// triggered `unreachable_patterns` post-relocation).
149impl From<SortKey> for SortKeyArg {
150    fn from(key: SortKey) -> Self {
151        match key {
152            SortKey::Crap => SortKeyArg::Crap,
153            SortKey::Coverage => SortKeyArg::Coverage,
154            SortKey::Complexity => SortKeyArg::Complexity,
155            SortKey::Path => SortKeyArg::Path,
156        }
157    }
158}
159
160/// Group key for the displayed view (issue #64).
161///
162/// Today only `file` is supported. The wrapper keeps `clap::ValueEnum`
163/// out of the domain; `From<GroupByArg> for GroupKey` is the boundary.
164#[derive(Debug, Clone, Copy, ValueEnum)]
165pub enum GroupByArg {
166    /// Aggregate by source file path
167    File,
168}
169
170impl From<GroupByArg> for GroupKey {
171    fn from(arg: GroupByArg) -> Self {
172        match arg {
173            GroupByArg::File => GroupKey::File,
174        }
175    }
176}
177
178/// Reverse mapping for saved view presets (issue #80). See `From<SortKey>`
179/// above for the wildcard-arm rationale (post-S4 in-crate exhaustive).
180impl From<GroupKey> for GroupByArg {
181    fn from(key: GroupKey) -> Self {
182        match key {
183            GroupKey::File => GroupByArg::File,
184        }
185    }
186}
187
188/// Sort key for the delta block (issue #81).
189#[derive(Debug, Clone, Copy, ValueEnum)]
190pub enum DeltaSortKeyArg {
191    /// Magnitude of change descending — regressions first (default)
192    ScoreDelta,
193    /// Current CRAP score descending; `Removed` rows last
194    CurrentCrap,
195    /// Baseline CRAP score descending; `Added` rows last
196    BaselineCrap,
197    /// Alphabetical by file_path then qualified_name
198    Path,
199}
200
201impl From<DeltaSortKeyArg> for crate::domain::delta::DeltaSortKey {
202    fn from(arg: DeltaSortKeyArg) -> Self {
203        use crate::domain::delta::DeltaSortKey;
204        match arg {
205            DeltaSortKeyArg::ScoreDelta => DeltaSortKey::ScoreDelta,
206            DeltaSortKeyArg::CurrentCrap => DeltaSortKey::CurrentCrap,
207            DeltaSortKeyArg::BaselineCrap => DeltaSortKey::BaselineCrap,
208            DeltaSortKeyArg::Path => DeltaSortKey::Path,
209        }
210    }
211}
212
213/// Change-kind subset for `--delta-only` (issue #81).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
215pub enum DeltaKindArg {
216    Added,
217    Removed,
218    Modified,
219}
220
221impl From<DeltaKindArg> for crate::domain::delta::ChangeKind {
222    fn from(arg: DeltaKindArg) -> Self {
223        use crate::domain::delta::ChangeKind;
224        match arg {
225            DeltaKindArg::Added => ChangeKind::Added,
226            DeltaKindArg::Removed => ChangeKind::Removed,
227            DeltaKindArg::Modified => ChangeKind::Modified,
228        }
229    }
230}
231
232/// When to colorize output.
233#[derive(Debug, Clone, Copy, Default, ValueEnum)]
234pub enum ColorArg {
235    /// Colorize when writing to a terminal
236    #[default]
237    Auto,
238    /// Always colorize output
239    Always,
240    /// Never colorize output
241    Never,
242}
243
244// ── Arg groups ──────────────────────────────────────────────────────
245
246/// Shell name for completion script generation (#69).
247#[derive(Debug, Clone, Copy, ValueEnum)]
248pub enum ShellArg {
249    Bash,
250    Zsh,
251    Fish,
252    Powershell,
253    Elvish,
254    Nushell,
255}
256
257/// Top-level subcommands. Optional — when absent, crap4rs runs the
258/// default analysis path that requires `--coverage`.
259#[derive(Debug, Subcommand)]
260pub enum Command {
261    /// Generate a shell completion script to stdout.
262    Completions {
263        #[arg(value_enum)]
264        shell: ShellArg,
265    },
266}
267
268#[derive(Debug, Args)]
269#[command(next_help_heading = "Input")]
270pub struct InputArgs {
271    /// Path to LCOV coverage file (from `cargo llvm-cov --lcov`).
272    /// Required for analysis; not required for `crap4rs completions`.
273    #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
274    pub coverage: Option<PathBuf>,
275
276    /// Root directory of Rust source files to analyze [default: src]
277    #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
278    pub src: Option<PathBuf>,
279
280    /// Complexity metric to use [default: cognitive]
281    #[arg(long, value_enum)]
282    pub metric: Option<MetricArg>,
283
284    /// Path to config file (default: auto-discover crap4rs.toml)
285    #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
286    pub config: Option<PathBuf>,
287
288    /// Resolve and apply a saved view preset from `crap4rs.toml`.
289    ///
290    /// The preset's fields (`top`, `min_coverage`, `max_coverage`, `sort`,
291    /// `only_failing`, `no_fail`, `group_by`, `minimal_view`) are folded
292    /// into the parsed CLI before the report is shaped. CLI flags
293    /// override the preset's `Option<T>` fields. Bare-bool flags
294    /// OR-merge with the preset (an explicit `--no-fail` adds to a
295    /// preset's value but cannot turn off `no_fail = true`).
296    #[arg(long, value_name = "NAME")]
297    pub view: Option<String>,
298
299    /// Path to a previously-emitted crap4rs JSON envelope, used as the
300    /// baseline for delta analysis.
301    ///
302    /// Crap4rs runs the current analysis as usual, then compares against
303    /// the baseline's `result` block to produce a `delta` block in the
304    /// output (see `--format json`, `--format markdown` for rendering).
305    /// Generate the baseline file by piping a previous run:
306    /// `crap4rs --coverage lcov.info --format json > baseline.json`.
307    ///
308    /// **Delta is informational by default.** Pass `--delta-gate` to
309    /// make the delta contribute to the exit code (fails on new
310    /// threshold violations introduced by this PR).
311    #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
312    pub baseline: Option<PathBuf>,
313}
314
315#[derive(Debug, Args)]
316#[command(next_help_heading = "Output")]
317pub struct OutputArgs {
318    /// Output format(s).
319    ///
320    /// Accepts a single format (`--format json`) for stdout, or a comma-
321    /// separated list to fan out a single analysis pass to multiple
322    /// destinations (`--format json:envelope.json,markdown:report.md`).
323    /// Each entry is `FORMAT` (stdout) or `FORMAT:FILE` (write to file).
324    /// Multi-format invocations require every entry to specify a file —
325    /// stdout cannot multiplex (issue #100).
326    #[arg(
327        short,
328        long,
329        value_delimiter = ',',
330        default_value = "table",
331        value_parser = parse_format_spec
332    )]
333    pub format: Vec<FormatSpec>,
334
335    /// CRAP score threshold — functions above this fail the check [default: 25]
336    // allow_hyphen_values: lets clap parse `--threshold -5` as a value
337    // (not a flag), so our validate_inputs can give an actionable error.
338    #[arg(long, allow_hyphen_values = true, group = "threshold_select")]
339    pub threshold: Option<f64>,
340
341    /// Use strict threshold (15) — for high-quality or safety-critical code
342    #[arg(long, group = "threshold_select")]
343    pub strict: bool,
344
345    /// Use lenient threshold (40) — for legacy or transitional code
346    #[arg(long, group = "threshold_select")]
347    pub lenient: bool,
348
349    /// Always exit 0, even when threshold violations exist.
350    ///
351    /// Overrides only the exit-code translation; the underlying analysis
352    /// is untouched and `result.passed` in JSON output still reflects
353    /// the truthful pass/fail state, so consumers can detect "would
354    /// have failed" even when the process exits 0. Composes with
355    /// `--quiet` for silent success in CI. With `--delta-gate`, also
356    /// overrides the delta-gate exit-code translation (truth still in
357    /// `delta.summary.passed`).
358    #[arg(long)]
359    pub no_fail: bool,
360
361    /// Fail the build (exit 1) when the baseline comparison introduces
362    /// new threshold violations.
363    ///
364    /// Off by default — delta is informational unless this flag is set.
365    /// Drives off `delta.summary.passed`, which is true iff
366    /// `new_violations == 0`. Pre-existing violations (functions that
367    /// already exceeded threshold in the baseline) do NOT contribute,
368    /// so re-running with no code changes never trips the gate. Only
369    /// meaningful with `--baseline`. Composes with `--no-fail` (which
370    /// overrides BOTH gates).
371    #[arg(long, requires = "baseline")]
372    pub delta_gate: bool,
373
374    /// Omit the denormalized `view.shown` row array from JSON output.
375    ///
376    /// Payload-size escape hatch for very large codebases. The
377    /// envelope's `result` block (the gate) is unaffected; `view.spec`,
378    /// `view.eligible_count`, `view.truncated`, and `view.shown_summary`
379    /// remain so consumers retain full scope context. Only meaningful
380    /// with `--format json`.
381    #[arg(long)]
382    pub minimal_view: bool,
383}
384
385#[derive(Debug, Args)]
386#[command(next_help_heading = "Filtering")]
387pub struct FilterArgs {
388    /// Glob patterns to exclude from analysis (repeatable)
389    ///
390    /// Build artifacts (target/) are excluded automatically via .gitignore.
391    /// Test files are NOT excluded by default — use `--exclude "tests/**"`
392    /// if you want to skip them.
393    #[arg(long, action = clap::ArgAction::Append)]
394    pub exclude: Vec<String>,
395
396    /// Do not respect .gitignore files
397    ///
398    /// By default, paths in .gitignore are skipped (e.g., target/).
399    /// Pass this flag to analyze all files regardless of .gitignore.
400    #[arg(long)]
401    pub no_gitignore: bool,
402
403    /// Git ref to diff against — only analyze functions in changed files/hunks
404    ///
405    /// Scopes analysis to functions in files that changed since the given ref.
406    /// Useful for CI PR gating: `crap4rs --coverage lcov.info --diff main`
407    #[arg(long, value_name = "REF")]
408    pub diff: Option<String>,
409
410    /// Only show functions that exceed the threshold
411    ///
412    /// Display-only filter: the underlying analysis (the gate) and its
413    /// summary remain over the full unfiltered set, so the exit code and
414    /// every aggregate (`average_crap`, `median_crap`, `distribution`,
415    /// etc.) reflect the whole codebase. Only the row list and
416    /// `view.shown_summary` are reduced.
417    #[arg(long)]
418    pub only_failing: bool,
419
420    /// Lower bound (inclusive) on coverage_percent for the displayed view.
421    ///
422    /// `allow_hyphen_values`: lets clap parse `--min-coverage -5` as a
423    /// value (not an unknown flag) so `validate_view_args` can report
424    /// the right error.
425    #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
426    pub min_coverage: Option<f64>,
427
428    /// Upper bound (inclusive) on coverage_percent for the displayed view.
429    #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
430    pub max_coverage: Option<f64>,
431
432    /// Sort key for the displayed view (default: crap descending).
433    ///
434    /// `crap` (default) — CRAP score descending; `coverage` — coverage
435    /// percent ascending (lowest first); `complexity` — complexity
436    /// descending; `path` — alphabetical by file, then CRAP descending
437    /// within file. Sorting reorders without reducing rows, so the gate
438    /// (exit code) is unaffected. Unknown values are rejected by clap
439    /// at parse time with an `invalid value` error attributed to
440    /// `--sort-by`, so no custom validation is needed here.
441    #[arg(long, value_enum, value_name = "KEY")]
442    pub sort_by: Option<SortKeyArg>,
443
444    /// Truncate the displayed view to the top N highest-CRAP rows.
445    ///
446    /// `--top 0` means "no limit" — equivalent to omitting the flag.
447    /// The full unfiltered analysis still drives the gate (exit code),
448    /// so truncating violations out of the view does not change the outcome.
449    ///
450    /// `allow_hyphen_values`: lets clap parse `--top -3` as a value (not an
451    /// unknown flag) so the resulting error message is attributed to `--top`.
452    #[arg(long, allow_hyphen_values = true, value_name = "N")]
453    pub top: Option<u32>,
454
455    /// Aggregate the displayed view by a key. Today: `file` only.
456    ///
457    /// When set, the report shifts to per-file rows. `--top N` truncates
458    /// to the top N **files** (not functions); `--sort-by` keys at the
459    /// file level (`crap` → average CRAP descending; `coverage` →
460    /// average coverage ascending; `complexity` → max complexity
461    /// descending; `path` → alphabetical). The full per-function row
462    /// list still appears in JSON `view.shown` for drill-down. The
463    /// gate (exit code) is unaffected.
464    #[arg(long, value_enum, value_name = "KEY")]
465    pub group_by: Option<GroupByArg>,
466
467    /// Truncate the delta block to the top N rows by `--delta-sort`.
468    /// `--delta-top 0` means "no limit". Independent of `--top`, which
469    /// truncates the analysis view (`view.shown`).
470    ///
471    /// `allow_hyphen_values`: parses `--delta-top -3` as a value (not
472    /// an unknown flag) so the error attribution to `--delta-top` is
473    /// readable.
474    #[arg(long, allow_hyphen_values = true, value_name = "N")]
475    pub delta_top: Option<u32>,
476
477    /// Sort key for the delta block.
478    ///
479    /// `score-delta` (default) — magnitude of change descending
480    /// (regressions first). `current-crap` — current CRAP descending,
481    /// `Removed` rows last. `baseline-crap` — baseline CRAP descending,
482    /// `Added` rows last. `path` — alphabetical by file then qualified
483    /// name.
484    #[arg(long, value_enum, value_name = "KEY")]
485    pub delta_sort: Option<DeltaSortKeyArg>,
486
487    /// Comma-separated list of change kinds to include in the delta
488    /// block: `added`, `removed`, `modified`. Default: all three.
489    #[arg(long, value_delimiter = ',', value_name = "KINDS")]
490    pub delta_only: Vec<DeltaKindArg>,
491}
492
493#[derive(Debug, Args)]
494#[command(next_help_heading = "Display")]
495pub struct DisplayArgs {
496    /// When to use terminal colors
497    #[arg(long, value_enum, default_value_t = ColorArg::Auto)]
498    pub color: ColorArg,
499
500    /// Show parse diagnostics and matching statistics
501    #[arg(short, long)]
502    pub verbose: bool,
503
504    /// Suppress report output, only set exit code
505    #[arg(short, long)]
506    pub quiet: bool,
507
508    /// Show complexity contributors for functions exceeding threshold.
509    ///
510    /// JSON output always includes contributors regardless of this flag.
511    #[arg(long)]
512    pub breakdown: bool,
513
514    /// Explain nested breakdown increments in table output.
515    ///
516    /// Only affects table output, and only when `--breakdown` is enabled.
517    #[arg(long)]
518    pub explain: bool,
519
520    /// Render the full per-function table in markdown output.
521    ///
522    /// By default `--format markdown` produces a compact summary plus a
523    /// top-N table (failures if any exist, otherwise the worst by CRAP).
524    /// This flag appends the legacy row-per-function table — useful when
525    /// piping into a longer document instead of a PR comment. Has no
526    /// effect on other output formats.
527    #[arg(long)]
528    pub md_full_table: bool,
529
530    /// Number of rows in the markdown top-N table (default 10).
531    ///
532    /// Bounds the failures list (or worst-by-CRAP list when nothing
533    /// exceeds threshold). The summary block is unaffected — its stats
534    /// always reflect the full unshapeable analysis.
535    #[arg(long, value_name = "N", default_value_t = 10)]
536    pub md_top: usize,
537}
538
539// ── Top-level CLI ───────────────────────────────────────────────────
540
541// `long_version` is overridden at runtime in `cli::run` so the binary's
542// build script (`crap4rs/build.rs`) can splice the git hash + build date
543// into the Rust adapter's `--version` output without forcing crap-core
544// to read an env var that's only set during crap4rs's compile. The
545// derive's `version` here resolves to the **adapter** crate's
546// `CARGO_PKG_VERSION` because clap captures the env at the macro
547// expansion site — that's the binary crate's version when compiling
548// the binary, but the lib crate's version when compiling the lib.
549// Production callers always reach `cli::run` through the binary, so
550// `--version` displays the adapter's version. Tests that go through
551// the lib see crap-core's version, which is fine for tests.
552//
553// Threading per S4 lesson 7 (tool-version threading): consumer-visible
554// version strings flow as parameters from the bin where `env!` resolves
555// against the bin's package, not against this module's home crate.
556
557#[derive(Debug, Parser)]
558#[command(
559    version,
560    author,
561    about = "CRAP score analyzer for Rust",
562    long_about = "CRAP (Change Risk Anti-Patterns) score analyzer for Rust codebases.\n\n\
563                  Combines complexity analysis (via syn) with line coverage data \
564                  (LCOV from cargo-llvm-cov) to identify functions that are both \
565                  complex and under-tested.\n\n\
566                  Default metric is cognitive complexity (not cyclomatic), which \
567                  better captures Rust idioms like match arms and nested control flow.",
568    after_help = "\
569EXAMPLES:
570  crap4rs --coverage lcov.info
571  crap4rs --coverage lcov.info --threshold 15 --metric cyclomatic
572  crap4rs --coverage lcov.info --format json | jq '.functions[] | select(.exceeds)'
573  crap4rs --coverage lcov.info --only-failing
574  crap4rs --coverage lcov.info --exclude \"tests/**\" --exclude \"benches/**\"
575
576INVESTIGATION PATTERNS:
577  # First-run scan: keep the report short
578  crap4rs --coverage lcov.info --top 20
579
580  # Worst partially-covered functions, sorted by coverage ascending,
581  # never fail the build — useful when investigating an untested codebase
582  crap4rs --coverage lcov.info --min-coverage 1 --max-coverage 90 --sort-by coverage --top 10 --no-fail
583
584  # Saved view preset: bake a flag set under [views.ci] in crap4rs.toml,
585  # then invoke it by name. CLI flags override preset values.
586  crap4rs --coverage lcov.info --view ci
587
588  # GitHub Code Scanning: emit SARIF and let upload-sarif annotate the PR
589  # diff inline. Use --no-fail so the gate exit code doesn't skip the
590  # upload step on regressions.
591  crap4rs --coverage lcov.info --format sarif --no-fail > crap.sarif
592
593COMPARING TWO ANALYSES (issue #81):
594  # Capture a baseline (e.g., from main):
595  crap4rs --coverage lcov.info --format json > baseline.json
596
597  # Then compare the working tree to it (informational by default):
598  crap4rs --coverage lcov.info --baseline baseline.json
599
600  # CI usage: fail the build when new threshold violations land
601  crap4rs --coverage lcov.info --baseline baseline.json --delta-gate
602
603  # PR-comment scorecard (markdown — drop into the comment body verbatim)
604  crap4rs --coverage lcov.info --baseline baseline.json --format markdown"
605)]
606pub struct Cli {
607    #[command(flatten)]
608    pub input: InputArgs,
609
610    #[command(flatten)]
611    pub output: OutputArgs,
612
613    #[command(flatten)]
614    pub filter: FilterArgs,
615
616    #[command(flatten)]
617    pub display: DisplayArgs,
618
619    #[command(subcommand)]
620    pub command: Option<Command>,
621}
622
623// ── Entry point ─────────────────────────────────────────────────────
624
625/// Parse process args and produce a `Cli`. Splits in half from `run`
626/// so the binary `main.rs` can consult `cli.input.src` (config-aware)
627/// before constructing its `LcovParser` (which needs the source root
628/// at construction time per the LCOV adapter's path-stripping
629/// invariant). `run` then consumes the parsed `Cli` directly.
630///
631/// `tool_version` (e.g. `crap4rs`'s `0.4.0`) and `long_version`
632/// (e.g. `0.4.0 (abc1234 2026-05-09)`) are spliced into clap's help
633/// and `--version` output at runtime so the binary's build-script
634/// metadata reaches the help text — the derive macro's `version`
635/// reads `CARGO_PKG_VERSION` at lib-crate compile time (crap-core's
636/// `0.1.0`), and `CRAP4RS_LONG_VERSION` is only set during the
637/// binary's compile.
638///
639/// `clap::Command::{version,long_version}` take
640/// `IntoResettable<Str>` which implements `From<&'static str>` but
641/// not `From<String>`. The strings live for the program's lifetime,
642/// so leaking once at startup is the cheapest path that satisfies
643/// clap's expected lifetime. The leak is fixed-size and one-shot.
644pub fn parse_args(tool_version: &str, long_version: &str) -> Cli {
645    let cmd = build_command(tool_version, long_version);
646    let matches = cmd.get_matches();
647    Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
648}
649
650/// Read the adapter binary's name from `argv[0]`. The clap-derive
651/// `Cli::command()` defaults to `CARGO_PKG_NAME` of the lib crate
652/// (crap-core) which would print `--version` lines as
653/// `crap-core 0.4.0 ...` and shape generated completion scripts to
654/// the wrong identifier; runtime detection ensures the displayed
655/// name matches whichever adapter binary (`crap4rs`, future
656/// `crap4ts`) actually ran.
657fn current_bin_name() -> String {
658    std::env::args()
659        .next()
660        .and_then(|first| {
661            // `file_stem()` (not `file_name()`) so Windows builds drop
662            // the `.exe` suffix — without it `--version` prints
663            // `crap4rs.exe 0.4.0` and breaks scripts (and the
664            // version-stamp integration tests) that match `^crap4rs `.
665            // No-op on Linux/macOS.
666            std::path::PathBuf::from(first)
667                .file_stem()
668                .map(|os| os.to_string_lossy().into_owned())
669        })
670        .unwrap_or_else(|| "crap4rs".to_string())
671}
672
673/// Build the clap `Command` with the binary's runtime metadata
674/// spliced in. Used by `parse_args`; `emit_completions` reads the
675/// bin name through `current_bin_name` directly because
676/// `clap_complete::generate` takes the bin name as a separate arg.
677fn build_command(tool_version: &str, long_version: &str) -> clap::Command {
678    let bin_static: &'static str = Box::leak(current_bin_name().into_boxed_str());
679    let version_static: &'static str = Box::leak(tool_version.to_string().into_boxed_str());
680    let long_version_static: &'static str = Box::leak(long_version.to_string().into_boxed_str());
681    Cli::command()
682        .name(bin_static)
683        .bin_name(bin_static)
684        .version(version_static)
685        .long_version(long_version_static)
686}
687
688/// Run the CRAP CLI pipeline end-to-end. Generic over `P:
689/// ParseDiagnostic` so the same orchestrator drives every adapter
690/// crate's binary (per ADR D9, mixed-dispatch).
691///
692/// `tool_version` is the binary's own version (e.g. `crap4rs`'s
693/// `CARGO_PKG_VERSION` resolves to `0.4.0`, not crap-core's `0.1.0`).
694/// It feeds the JSON envelope's `tool_version` field, the SARIF run
695/// metadata, the markdown header, the HTML report header, and clap's
696/// long-version splice when the caller threads it through.
697pub fn run<P: ParseDiagnostic + std::fmt::Display>(
698    cli: Cli,
699    complexity: &dyn ComplexityPort,
700    coverage: &dyn CoveragePort<Diagnostic = P>,
701    tool_version: &str,
702) -> ExitCode {
703    match run_inner(cli, complexity, coverage, tool_version) {
704        Ok(true) => ExitCode::from(0),
705        Ok(false) => ExitCode::from(1),
706        Err(e) => {
707            eprintln!("error: {e:#}");
708            ExitCode::from(2)
709        }
710    }
711}
712
713fn run_inner<P: ParseDiagnostic + std::fmt::Display>(
714    mut cli: Cli,
715    complexity: &dyn ComplexityPort,
716    coverage: &dyn CoveragePort<Diagnostic = P>,
717    tool_version: &str,
718) -> Result<bool> {
719    if let Some(Command::Completions { shell }) = cli.command {
720        emit_completions(shell, &current_bin_name());
721        return Ok(true);
722    }
723
724    let prep = prepare_pipeline(&mut cli, complexity, coverage)?;
725
726    // Build the spec, then shape the result through the View pipeline.
727    // V1b: `--only-failing` flows through `Filters::only_failing` here.
728    // W2 fills in `--top`, `--min/max-coverage`, `--sort-by`. The
729    // underlying `result` is never mutated — the gate is unshapeable.
730    let spec = view_args::build_view_spec(&cli);
731    let view = view::apply(&prep.analysis.result, spec);
732
733    // Shape the delta. Spec is built from --delta-top / --delta-sort /
734    // --delta-only (VS4); defaults match the dominant scorecard use
735    // case (regressions first, all kinds, no truncation). `Option::map`
736    // is `FnOnce`, so the closure moves the spec rather than cloning —
737    // `DeltaView` owns its `spec` field, no further uses upstream.
738    let delta_spec = delta_args::build_delta_view_spec(&cli);
739    let delta_view: Option<DeltaView<'_>> = prep
740        .delta_state
741        .as_ref()
742        .map(move |s| delta::apply(&s.delta, delta_spec));
743
744    if !cli.display.quiet {
745        print_formatted_output(
746            &cli,
747            &view,
748            delta_view.as_ref(),
749            prep.delta_state.as_ref(),
750            &prep.analysis,
751            &prep.inputs,
752            tool_version,
753        )?;
754    }
755
756    // Exit code derives from `view.full.passed` — i.e., the underlying
757    // analysis. The View shapes the display, never the gate.
758    //
759    // Delta is informational by default (issue #81 §gate semantics).
760    // `--delta-gate` opts in: a passing analysis with delta regressions
761    // that introduce new violations will exit 1 when `--delta-gate` is
762    // set. `--no-fail` overrides BOTH gates — truth lives in JSON
763    // (`result.passed` and `delta.summary.passed`) so consumers can
764    // still detect "would have failed."
765    Ok(compute_exit_code(
766        &cli,
767        prep.analysis.result.passed,
768        prep.delta_state.as_ref(),
769    ))
770}
771
772// ── Run-inner orchestration helpers ────────────────────────────────
773
774/// Effective inputs after CLI / config-file / preset / default merging.
775/// Everything `core::analyze` needs except the coverage path (which is
776/// validated separately and may be borrowed from `cli`).
777struct EffectiveInputs {
778    src: PathBuf,
779    metric: ComplexityMetric,
780    threshold_config: ThresholdConfig,
781    threshold: f64,
782    exclude: Vec<String>,
783}
784
785/// In-flight pipeline state assembled by `prepare_pipeline`. Owns the
786/// analysis output and the optional delta state so the dispatch layer
787/// borrows through references. Generic over `P: ParseDiagnostic` so
788/// `AnalysisOutput<P>` and `DeltaState<P>` carry the adapter's diagnostic
789/// shape (LCOV, future Istanbul, …) end-to-end.
790struct PipelinePrep<P: ParseDiagnostic> {
791    inputs: EffectiveInputs,
792    analysis: AnalysisOutput<P>,
793    delta_state: Option<DeltaState<P>>,
794}
795
796fn merge_effective_inputs(cli: &Cli, file_config: &Option<FileConfig>) -> EffectiveInputs {
797    let src = cli
798        .input
799        .src
800        .clone()
801        .or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
802        .unwrap_or_else(|| PathBuf::from("src"));
803    let metric: ComplexityMetric = cli
804        .input
805        .metric
806        .map(Into::into)
807        .or_else(|| file_config.as_ref().and_then(|c| c.metric))
808        .unwrap_or_default();
809    let (threshold_config, threshold) = merge_threshold(cli, file_config);
810    let exclude = merge_exclude(cli, file_config);
811    EffectiveInputs {
812        src,
813        metric,
814        threshold_config,
815        threshold,
816        exclude,
817    }
818}
819
820fn validate_runtime_inputs<'a>(cli: &'a Cli, inputs: &EffectiveInputs) -> Result<&'a Path> {
821    // `--coverage` is required on the analysis path; subcommands like
822    // `completions` skip this branch. Clap can't express "required
823    // unless subcommand X" in derive, so we enforce it here.
824    let Some(coverage_path) = cli.input.coverage.as_deref() else {
825        bail!(
826            "--coverage <FILE> is required (run `crap4rs --help` for usage, or `crap4rs completions <SHELL>` for shell completion scripts)"
827        );
828    };
829
830    validate_inputs(coverage_path, &inputs.src, inputs.threshold)?;
831    preflight_checks(coverage_path, &inputs.src)?;
832
833    if let Some(diff_ref) = cli.filter.diff.as_deref() {
834        validate_diff_ref(diff_ref)?;
835        preflight_git_worktree(&inputs.src)?;
836    }
837
838    Ok(coverage_path)
839}
840
841fn build_analyze_options(cli: &Cli, inputs: &EffectiveInputs, coverage: &Path) -> AnalyzeOptions {
842    AnalyzeOptions {
843        src: inputs.src.clone(),
844        coverage: coverage.to_path_buf(),
845        threshold_config: inputs.threshold_config.clone(),
846        metric: inputs.metric,
847        exclude: inputs.exclude.clone(),
848        respect_gitignore: !cli.filter.no_gitignore,
849        diff_ref: cli.filter.diff.clone(),
850        compute_diagnostics: cli
851            .output
852            .format
853            .iter()
854            .any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
855        ..AnalyzeOptions::default()
856    }
857}
858
859fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
860    cli: &Cli,
861    diagnostics: &AnalysisDiagnostics<P>,
862) {
863    // Always warn about non-fatal issues (details require --verbose)
864    warn_if_issues(diagnostics);
865    if cli.display.verbose {
866        print_diagnostics(diagnostics);
867    }
868}
869
870/// Validates inputs, merges effective config, runs the analyzer, and
871/// resolves the optional baseline delta. The bulk of `run_inner`'s
872/// pre-render work lives here so `run_inner` itself stays a flat dispatch.
873fn prepare_pipeline<P: ParseDiagnostic + std::fmt::Display>(
874    cli: &mut Cli,
875    complexity: &dyn ComplexityPort,
876    coverage: &dyn CoveragePort<Diagnostic = P>,
877) -> Result<PipelinePrep<P>> {
878    validate_display_flags(cli)?;
879    apply_color(cli.display.color);
880
881    // Load config file (explicit path or auto-discovered)
882    let file_config = load_file_config(cli)?;
883
884    // Resolve `--view <NAME>` (issue #80) before validate_view_args runs
885    // so preset fields participate in the same validation pass as CLI
886    // flags. `apply_preset_to_cli` mutates `cli` in place: CLI explicit
887    // values win on `Option<T>` fields, bools OR-merge.
888    view_args::resolve_view_preset(cli, file_config.as_ref())?;
889    view_args::validate_view_args(cli)?;
890
891    let inputs = merge_effective_inputs(cli, &file_config);
892    let coverage_path = validate_runtime_inputs(cli, &inputs)?;
893    let options = build_analyze_options(cli, &inputs, coverage_path);
894
895    let analysis = crate::core::analyze(&options, complexity, coverage)?;
896    apply_diagnostics(cli, &analysis.diagnostics);
897
898    // Resolve --baseline (issue #81): load a previously-emitted JSON
899    // envelope and compute the AnalysisDelta. None when --baseline is
900    // absent — the JSON envelope omits the `delta` block entirely so
901    // existing consumers see byte-identical output.
902    let delta_state = load_delta_state(cli, &analysis.result)?;
903
904    Ok(PipelinePrep {
905        inputs,
906        analysis,
907        delta_state,
908    })
909}
910
911// ── Format dispatch ────────────────────────────────────────────────
912
913fn format_as_json<P: ParseDiagnostic>(
914    cli: &Cli,
915    view: &view::AnalysisView<'_>,
916    delta_view: Option<&DeltaView<'_>>,
917    delta_state: Option<&DeltaState<P>>,
918    analysis: &AnalysisOutput<P>,
919    inputs: &EffectiveInputs,
920    tool_version: &str,
921) -> Result<String> {
922    let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
923        view: dv,
924        baseline_tool_version: &s.snapshot.tool_version,
925        baseline_timestamp: &s.snapshot.timestamp,
926        baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
927    });
928    let config = reporters::json::JsonConfig {
929        tool_version: tool_version.to_string(),
930        metric: inputs.metric,
931        threshold: inputs.threshold,
932        timestamp: now_unix_epoch(),
933        diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
934        diff_ref: cli.filter.diff.as_deref(),
935        minimal_view: cli.output.minimal_view,
936        delta: delta_ctx,
937    };
938    reporters::json::format_json(view, &config).map_err(Into::into)
939}
940
941/// ScorecardRow projects the unshaped analysis + delta into a mokumo
942/// `Row::CrapDelta` JSON object (issue #111). View shaping does NOT
943/// alter scorecard-row — the aggregator consumes truth, not a filtered
944/// subset.
945fn format_as_scorecard_row<P: ParseDiagnostic>(
946    delta_state: Option<&DeltaState<P>>,
947    result: &crate::domain::types::AnalysisResult,
948    threshold: f64,
949) -> String {
950    let baseline_result = delta_state.map(|s| &s.snapshot.result);
951    let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
952    let row_data = crate::domain::summary::project_crap_delta_row(
953        result,
954        baseline_result,
955        delta_inputs,
956        threshold.round() as u32,
957    );
958    reporters::format_scorecard_row(&row_data)
959}
960
961// 8-arg dispatch is the cost of threading `<P>` + `tool_version` through
962// the format match without restructuring the per-reporter call sites
963// (which carry heterogeneous, irreducible signatures per `adapters.md`
964// rule 1). Bundling them into a context struct would shadow the per-arm
965// argument list that's the whole point of this match. Tracked under v1.0
966// follow-up for the broader cli refactor.
967#[allow(clippy::too_many_arguments)]
968fn render_format<P: ParseDiagnostic>(
969    cli: &Cli,
970    spec: &FormatSpec,
971    view: &view::AnalysisView<'_>,
972    delta_view: Option<&DeltaView<'_>>,
973    delta_state: Option<&DeltaState<P>>,
974    analysis: &AnalysisOutput<P>,
975    inputs: &EffectiveInputs,
976    tool_version: &str,
977) -> Result<String> {
978    Ok(match spec.format {
979        FormatArg::Table => reporters::format_table_with_explain(
980            view,
981            delta_view,
982            inputs.threshold,
983            cli.display.breakdown,
984            cli.display.explain,
985            tool_version,
986        ),
987        FormatArg::Json | FormatArg::Advice => format_as_json(
988            cli,
989            view,
990            delta_view,
991            delta_state,
992            analysis,
993            inputs,
994            tool_version,
995        )?,
996        FormatArg::Markdown => reporters::format_markdown(
997            view,
998            delta_view,
999            inputs.threshold,
1000            cli.display.breakdown,
1001            cli.display.explain,
1002            cli.display.md_full_table,
1003            cli.display.md_top,
1004            tool_version,
1005        ),
1006        FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
1007        // SARIF is a gate translation, not a display: it iterates
1008        // `view.full.functions` internally regardless of how the View
1009        // was shaped. `--top`, `--sort-by`, `--only-failing`, and
1010        // `--baseline` do NOT alter SARIF output — PR annotations
1011        // must reflect truth.
1012        FormatArg::Sarif => reporters::format_sarif(view, tool_version),
1013        FormatArg::ScorecardRow => {
1014            format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
1015        }
1016        FormatArg::Html => reporters::format_html(view, inputs.threshold, tool_version),
1017    })
1018}
1019
1020fn print_formatted_output<P: ParseDiagnostic>(
1021    cli: &Cli,
1022    view: &view::AnalysisView<'_>,
1023    delta_view: Option<&DeltaView<'_>>,
1024    delta_state: Option<&DeltaState<P>>,
1025    analysis: &AnalysisOutput<P>,
1026    inputs: &EffectiveInputs,
1027    tool_version: &str,
1028) -> Result<()> {
1029    for spec in &cli.output.format {
1030        let output = render_format(
1031            cli,
1032            spec,
1033            view,
1034            delta_view,
1035            delta_state,
1036            analysis,
1037            inputs,
1038            tool_version,
1039        )?;
1040        match &spec.output {
1041            Some(path) => std::fs::write(path, &output)
1042                .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
1043            None => print!("{output}"),
1044        }
1045    }
1046
1047    // Advice's stderr summary fires once even if Advice appears multiple
1048    // times in `--format`. SARIF stays silent — its primary deliverable
1049    // is the `.sarif` file uploaded to Code Scanning; stderr would noise
1050    // up CI logs.
1051    if cli
1052        .output
1053        .format
1054        .iter()
1055        .any(|s| matches!(s.format, FormatArg::Advice))
1056    {
1057        let mut stderr = std::io::stderr();
1058        let _ = reporters::render_advice_summary(view, &mut stderr);
1059    }
1060
1061    Ok(())
1062}
1063
1064fn compute_exit_code<P: ParseDiagnostic>(
1065    cli: &Cli,
1066    passed: bool,
1067    delta_state: Option<&DeltaState<P>>,
1068) -> bool {
1069    let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
1070    let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
1071    combined_passed || cli.output.no_fail
1072}
1073
1074// ── Delta orchestration ─────────────────────────────────────────────
1075
1076/// In-flight delta state — owned baseline metadata + computed delta.
1077/// `cli/mod.rs` keeps this for the lifetime of `run_inner` so reporters
1078/// can borrow through it. Constructed once per invocation when
1079/// `--baseline` is set; absent otherwise. Generic over `P:
1080/// ParseDiagnostic` so the snapshot's `BaselineSnapshot<P>` matches the
1081/// adapter's diagnostic shape.
1082struct DeltaState<P: ParseDiagnostic> {
1083    snapshot: BaselineSnapshot<P>,
1084    delta: AnalysisDelta,
1085}
1086
1087fn load_delta_state<P: ParseDiagnostic>(
1088    cli: &Cli,
1089    current: &crate::domain::types::AnalysisResult,
1090) -> Result<Option<DeltaState<P>>> {
1091    let Some(path) = cli.input.baseline.as_ref() else {
1092        return Ok(None);
1093    };
1094    let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
1095    // delta::compute consumes both — we own snapshot.result, clone the
1096    // current analysis so the surrounding pipeline keeps its handle.
1097    let delta = delta::compute(snapshot.result.clone(), current.clone());
1098    Ok(Some(DeltaState { snapshot, delta }))
1099}
1100
1101fn validate_display_flags(cli: &Cli) -> Result<()> {
1102    let any_table = cli
1103        .output
1104        .format
1105        .iter()
1106        .any(|s| matches!(s.format, FormatArg::Table));
1107    if cli.display.explain && any_table && !cli.display.breakdown {
1108        bail!("--explain requires --breakdown for table output");
1109    }
1110    validate_format_destinations(&cli.output.format)?;
1111    Ok(())
1112}
1113
1114/// Multi-format invocations require every entry to specify a file —
1115/// stdout cannot multiplex (issue #100).
1116fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
1117    if specs.len() > 1 {
1118        let stdout_specs: Vec<_> = specs
1119            .iter()
1120            .filter(|s| s.output.is_none())
1121            .map(|s| format_arg_kebab(s.format).to_string())
1122            .collect();
1123        if !stdout_specs.is_empty() {
1124            bail!(
1125                "multi-format `--format` requires every entry to specify a file (e.g. `json:envelope.json`); stdout-only entries: {}",
1126                stdout_specs.join(", ")
1127            );
1128        }
1129    }
1130    Ok(())
1131}
1132
1133/// User-facing kebab-case name for a `FormatArg` (matches the clap CLI
1134/// surface `--format X`). Defaults to `Debug` lowercased if clap's
1135/// `ValueEnum` registry can't resolve a name.
1136fn format_arg_kebab(arg: FormatArg) -> String {
1137    use clap::ValueEnum;
1138    arg.to_possible_value()
1139        .map(|v| v.get_name().to_string())
1140        .unwrap_or_else(|| format!("{arg:?}").to_lowercase())
1141}
1142
1143// ── Config loading & merging ───────────────────────────────────────
1144
1145fn load_file_config(cli: &Cli) -> Result<Option<FileConfig>> {
1146    if let Some(path) = &cli.input.config {
1147        Ok(Some(config::load_config(path)?))
1148    } else {
1149        match config::discover_config()? {
1150            Some(path) => Ok(Some(config::load_config(&path)?)),
1151            None => Ok(None),
1152        }
1153    }
1154}
1155
1156/// Merge CLI threshold with config file. Returns (ThresholdConfig, effective_display_threshold).
1157///
1158/// Resolution order (first match wins):
1159/// 1. `--threshold N`   — explicit CLI value
1160/// 2. `--strict`        → STRICT_THRESHOLD
1161/// 3. `--lenient`       → LENIENT_THRESHOLD
1162/// 4. config `preset`   → preset.threshold()
1163/// 5. config `threshold`
1164/// 6. DEFAULT_THRESHOLD
1165fn merge_threshold(cli: &Cli, file_config: &Option<FileConfig>) -> (ThresholdConfig, f64) {
1166    let global = cli
1167        .output
1168        .threshold
1169        .or_else(|| cli.output.strict.then_some(STRICT_THRESHOLD))
1170        .or_else(|| cli.output.lenient.then_some(LENIENT_THRESHOLD))
1171        .or_else(|| {
1172            file_config
1173                .as_ref()
1174                .and_then(|c| c.preset)
1175                .map(|p| p.threshold())
1176        })
1177        .or_else(|| file_config.as_ref().and_then(|c| c.threshold))
1178        .unwrap_or(DEFAULT_THRESHOLD);
1179
1180    let overrides = file_config
1181        .as_ref()
1182        .map(|fc| fc.overrides.clone())
1183        .unwrap_or_default();
1184
1185    let config = ThresholdConfig { global, overrides };
1186    (config, global)
1187}
1188
1189fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>) -> Vec<String> {
1190    let mut exclude = cli.filter.exclude.clone();
1191    if let Some(fc) = file_config
1192        && let Some(fc_exclude) = &fc.exclude
1193    {
1194        let seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
1195        for pattern in fc_exclude {
1196            if !seen.contains(pattern) {
1197                exclude.push(pattern.clone());
1198            }
1199        }
1200    }
1201    exclude
1202}
1203
1204// ── Validation ──────────────────────────────────────────────────────
1205
1206fn validate_inputs(
1207    coverage: &std::path::Path,
1208    src: &std::path::Path,
1209    threshold: f64,
1210) -> Result<()> {
1211    match std::fs::metadata(coverage) {
1212        Ok(m) if m.is_file() => {}
1213        Ok(_) => bail!(
1214            "coverage path is not a file: {}\n  \
1215             hint: pass --coverage pointing to an LCOV file, not a directory",
1216            coverage.display()
1217        ),
1218        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1219            "coverage file not found: {}\n  \
1220             hint: run `cargo llvm-cov --lcov --output-path lcov.info` first",
1221            coverage.display()
1222        ),
1223        Err(e) => bail!(
1224            "cannot access coverage file: {}: {e}\n  \
1225             hint: check file permissions",
1226            coverage.display()
1227        ),
1228    }
1229    match std::fs::metadata(src) {
1230        Ok(m) if m.is_dir() => {}
1231        Ok(_) => bail!(
1232            "source path is not a directory: {}\n  \
1233             hint: pass --src <DIR> pointing to your Rust source root",
1234            src.display()
1235        ),
1236        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1237            "source directory not found: {}\n  \
1238             hint: pass --src <DIR> pointing to your Rust source root",
1239            src.display()
1240        ),
1241        Err(e) => bail!(
1242            "cannot access source directory: {}: {e}\n  \
1243             hint: check directory permissions",
1244            src.display()
1245        ),
1246    }
1247    if !is_valid_threshold(threshold) {
1248        bail!(
1249            "threshold must be a finite positive number, got: {}",
1250            threshold
1251        );
1252    }
1253    Ok(())
1254}
1255
1256// ── Diff validation ────────────────────────────────────────────────
1257
1258fn validate_diff_ref(diff_ref: &str) -> Result<()> {
1259    if diff_ref.is_empty() {
1260        bail!("invalid diff ref: ref must not be empty");
1261    }
1262    if diff_ref.starts_with('-') {
1263        bail!(
1264            "invalid diff ref: {diff_ref}\n  \
1265             hint: ref must not start with a dash"
1266        );
1267    }
1268    Ok(())
1269}
1270
1271fn preflight_git_worktree(src: &Path) -> Result<()> {
1272    let output = std::process::Command::new("git")
1273        .current_dir(src)
1274        .args(["rev-parse", "--is-inside-work-tree"])
1275        .output();
1276
1277    match output {
1278        Ok(o) if o.status.success() => Ok(()),
1279        Ok(o) => {
1280            let stderr = String::from_utf8_lossy(&o.stderr);
1281            bail!(
1282                "not inside a git work tree\n  \
1283                 hint: --diff requires a git repository\n  \
1284                 git: {stderr}",
1285            );
1286        }
1287        Err(e) => bail!(
1288            "not inside a git work tree\n  \
1289             hint: --diff requires git to be installed\n  \
1290             error: {e}",
1291        ),
1292    }
1293}
1294
1295// ── Pre-flight checks ──────────────────────────────────────────────
1296
1297fn preflight_checks(coverage: &std::path::Path, src: &std::path::Path) -> Result<()> {
1298    check_coverage_has_data(coverage)?;
1299    check_src_has_rust_files(src)?;
1300    Ok(())
1301}
1302
1303fn check_coverage_has_data(path: &std::path::Path) -> Result<()> {
1304    use std::io::{BufRead, BufReader};
1305
1306    let file = std::fs::File::open(path)?;
1307    let reader = BufReader::new(file);
1308    let mut in_sf_block = false;
1309
1310    for line in reader.lines() {
1311        let line = line?;
1312        if line.starts_with("SF:") {
1313            in_sf_block = true;
1314            continue;
1315        }
1316        if in_sf_block
1317            && let Some(rest) = line.strip_prefix("DA:")
1318            && let Some((line_no, hits)) = rest.split_once(',')
1319            && line_no.parse::<usize>().is_ok()
1320            && hits.split(',').next().unwrap_or("").parse::<u64>().is_ok()
1321        {
1322            return Ok(());
1323        }
1324    }
1325    bail!(
1326        "no coverage data found in {}\n  \
1327         hint: ensure tests ran with coverage enabled (`cargo llvm-cov --lcov`)",
1328        path.display()
1329    );
1330}
1331
1332fn check_src_has_rust_files(path: &std::path::Path) -> Result<()> {
1333    fn has_rs_files(dir: &std::path::Path) -> std::io::Result<bool> {
1334        for entry in std::fs::read_dir(dir)? {
1335            let entry = entry?;
1336            let ft = entry.file_type()?;
1337            if ft.is_file() && entry.path().extension().is_some_and(|ext| ext == "rs") {
1338                return Ok(true);
1339            }
1340            if ft.is_dir() && has_rs_files(&entry.path())? {
1341                return Ok(true);
1342            }
1343        }
1344        Ok(false)
1345    }
1346
1347    if !has_rs_files(path)? {
1348        bail!(
1349            "no Rust source files found in {}\n  \
1350             hint: check that --src points to a directory containing .rs files",
1351            path.display()
1352        );
1353    }
1354    Ok(())
1355}
1356
1357// ── Timestamp ──────────────────────────────────────────────────────
1358
1359fn now_unix_epoch() -> String {
1360    let secs = SystemTime::now()
1361        .duration_since(SystemTime::UNIX_EPOCH)
1362        .unwrap_or_default()
1363        .as_secs();
1364    format!("{secs}")
1365}
1366
1367// ── Verbose diagnostics ────────────────────────────────────────────
1368
1369fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
1370    files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
1371}
1372
1373fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
1374    if !diag.parse_diagnostics.is_empty() {
1375        eprintln!(
1376            "warning: {} LCOV parse issue(s) encountered (use --verbose for details)",
1377            diag.parse_diagnostics.len()
1378        );
1379    }
1380    if diag.files_unparseable > 0 {
1381        eprintln!(
1382            "warning: {} source file(s) could not be parsed (use --verbose for details)",
1383            diag.files_unparseable
1384        );
1385    }
1386    if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
1387        eprintln!(
1388            "warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
1389            diag.files_zero_coverage, diag.files_analyzed
1390        );
1391        eprintln!(
1392            "  hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
1393        );
1394        eprintln!(
1395            "  hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
1396        );
1397    }
1398}
1399
1400fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
1401    eprintln!(
1402        "verbose: file discovery: {} files found, {} unparseable",
1403        diag.files_found, diag.files_unparseable
1404    );
1405    eprintln!(
1406        "verbose: complexity: {} functions extracted",
1407        diag.functions_extracted
1408    );
1409    eprintln!(
1410        "verbose: matching: {} matched with coverage, {} without coverage data",
1411        diag.functions_matched, diag.functions_no_coverage
1412    );
1413    eprintln!(
1414        "verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
1415        diag.files_analyzed, diag.files_zero_coverage
1416    );
1417    if !diag.parse_diagnostics.is_empty() {
1418        eprintln!(
1419            "verbose: LCOV parse diagnostics ({}):",
1420            diag.parse_diagnostics.len()
1421        );
1422        for d in &diag.parse_diagnostics {
1423            eprintln!("  {d}");
1424        }
1425    }
1426}
1427
1428// ── Shell completions ───────────────────────────────────────────────
1429
1430/// Print a shell completion script to stdout for the given shell.
1431/// `clap_complete::generate` covers POSIX shells + PowerShell + Elvish;
1432/// nushell uses the separate `clap_complete_nushell` crate.
1433///
1434/// `bin_name` is the adapter binary's name (`crap4rs`, future
1435/// `crap4ts`, …) inferred at runtime from `argv[0]` — generated
1436/// completion scripts should reference the binary the user invoked,
1437/// not crap-core's library name.
1438fn emit_completions(shell: ShellArg, bin_name: &str) {
1439    let mut cmd = Cli::command();
1440    let stdout = &mut std::io::stdout();
1441    match shell {
1442        ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
1443        ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
1444        ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
1445        ShellArg::Powershell => {
1446            clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
1447        }
1448        ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
1449        ShellArg::Nushell => {
1450            clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
1451        }
1452    }
1453}
1454
1455// ── Color wiring ────────────────────────────────────────────────────
1456
1457fn apply_color(choice: ColorArg) {
1458    match choice {
1459        ColorArg::Auto => colored::control::unset_override(),
1460        ColorArg::Always => colored::control::set_override(true),
1461        ColorArg::Never => colored::control::set_override(false),
1462    }
1463}
1464
1465// ── Tests ───────────────────────────────────────────────────────────
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::*;
1470    use std::path::Path;
1471
1472    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1473        let mut full = vec!["crap4rs"];
1474        full.extend_from_slice(args);
1475        Cli::try_parse_from(full)
1476    }
1477
1478    #[test]
1479    fn no_args_parses_with_coverage_none() {
1480        // `--coverage` is enforced at runtime via run_inner (so that
1481        // the `completions` subcommand can skip it), not at clap parse
1482        // time. Bare `crap4rs` therefore parses successfully here but
1483        // would `bail!` once dispatched.
1484        let cli = parse(&[]).unwrap();
1485        assert!(cli.input.coverage.is_none());
1486        assert!(cli.command.is_none());
1487    }
1488
1489    #[test]
1490    fn minimal_valid_args() {
1491        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1492        assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
1493        assert_eq!(cli.input.src, None);
1494    }
1495
1496    #[test]
1497    fn completions_subcommand_does_not_require_coverage() {
1498        let cli = parse(&["completions", "bash"]).unwrap();
1499        assert!(matches!(
1500            cli.command,
1501            Some(Command::Completions {
1502                shell: ShellArg::Bash
1503            })
1504        ));
1505        assert!(cli.input.coverage.is_none());
1506    }
1507
1508    #[test]
1509    fn default_metric_is_none() {
1510        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1511        assert!(cli.input.metric.is_none());
1512    }
1513
1514    #[test]
1515    fn default_format_is_table() {
1516        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1517        assert_eq!(cli.output.format.len(), 1);
1518        assert!(matches!(cli.output.format[0].format, FormatArg::Table));
1519        assert!(cli.output.format[0].output.is_none());
1520    }
1521
1522    #[test]
1523    fn default_threshold_is_none() {
1524        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1525        assert!(cli.output.threshold.is_none());
1526    }
1527
1528    #[test]
1529    fn default_color_is_auto() {
1530        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1531        assert!(matches!(cli.display.color, ColorArg::Auto));
1532    }
1533
1534    #[test]
1535    fn metric_cyclomatic() {
1536        let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
1537        assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
1538    }
1539
1540    #[test]
1541    fn format_json() {
1542        let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
1543        assert_eq!(cli.output.format.len(), 1);
1544        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1545        assert!(cli.output.format[0].output.is_none());
1546    }
1547
1548    #[test]
1549    fn format_sarif() {
1550        let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
1551        assert_eq!(cli.output.format.len(), 1);
1552        assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
1553    }
1554
1555    #[test]
1556    fn format_with_file_destination() {
1557        let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
1558        assert_eq!(cli.output.format.len(), 1);
1559        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1560        assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1561    }
1562
1563    #[test]
1564    fn format_multi_with_files() {
1565        let cli = parse(&[
1566            "--coverage",
1567            "lcov.info",
1568            "--format",
1569            "json:env.json,markdown:report.md",
1570        ])
1571        .unwrap();
1572        assert_eq!(cli.output.format.len(), 2);
1573        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1574        assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1575        assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
1576        assert_eq!(
1577            cli.output.format[1].output,
1578            Some(PathBuf::from("report.md"))
1579        );
1580    }
1581
1582    #[test]
1583    fn format_multi_without_files_rejected() {
1584        let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
1585        let err = validate_display_flags(&cli).unwrap_err();
1586        let msg = err.to_string();
1587        assert!(msg.contains("multi-format"));
1588        assert!(msg.contains("file"));
1589    }
1590
1591    #[test]
1592    fn format_empty_path_rejected() {
1593        let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
1594        let msg = format!("{err}");
1595        assert!(msg.contains("empty file path"));
1596    }
1597
1598    #[test]
1599    fn custom_threshold() {
1600        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
1601        assert_eq!(cli.output.threshold, Some(15.5));
1602    }
1603
1604    #[test]
1605    fn custom_src() {
1606        let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
1607        assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
1608    }
1609
1610    #[test]
1611    fn exclude_repeatable() {
1612        let cli = parse(&[
1613            "--coverage",
1614            "lcov.info",
1615            "--exclude",
1616            "tests/**",
1617            "--exclude",
1618            "benches/**",
1619        ])
1620        .unwrap();
1621        assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
1622    }
1623
1624    #[test]
1625    fn no_gitignore_flag() {
1626        let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
1627        assert!(cli.filter.no_gitignore);
1628    }
1629
1630    #[test]
1631    fn only_failing_flag() {
1632        let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
1633        assert!(cli.filter.only_failing);
1634    }
1635
1636    #[test]
1637    fn group_by_file_parses() {
1638        let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
1639        assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
1640    }
1641
1642    #[test]
1643    fn group_by_absence_is_none() {
1644        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1645        assert!(cli.filter.group_by.is_none());
1646    }
1647
1648    #[test]
1649    fn group_by_invalid_value_rejected() {
1650        let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
1651        let msg = err.to_string();
1652        assert!(msg.contains("invalid value"), "expected clap error: {msg}");
1653        assert!(
1654            msg.contains("--group-by") || msg.contains("module"),
1655            "error should attribute to --group-by: {msg}"
1656        );
1657    }
1658
1659    #[test]
1660    fn group_by_arg_to_domain_file() {
1661        let domain: GroupKey = GroupByArg::File.into();
1662        assert_eq!(domain, GroupKey::File);
1663    }
1664
1665    #[test]
1666    fn verbose_flag() {
1667        let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
1668        assert!(cli.display.verbose);
1669    }
1670
1671    #[test]
1672    fn quiet_flag() {
1673        let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
1674        assert!(cli.display.quiet);
1675    }
1676
1677    #[test]
1678    fn color_always() {
1679        let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
1680        assert!(matches!(cli.display.color, ColorArg::Always));
1681    }
1682
1683    #[test]
1684    fn color_never() {
1685        let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
1686        assert!(matches!(cli.display.color, ColorArg::Never));
1687    }
1688
1689    #[test]
1690    fn invalid_metric_rejected() {
1691        let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
1692        assert!(err.to_string().contains("invalid value"));
1693    }
1694
1695    #[test]
1696    fn invalid_format_rejected() {
1697        let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
1698        assert!(err.to_string().contains("invalid value"));
1699    }
1700
1701    #[test]
1702    fn metric_arg_to_domain_cognitive() {
1703        let domain: ComplexityMetric = MetricArg::Cognitive.into();
1704        assert_eq!(domain, ComplexityMetric::Cognitive);
1705    }
1706
1707    #[test]
1708    fn metric_arg_to_domain_cyclomatic() {
1709        let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
1710        assert_eq!(domain, ComplexityMetric::Cyclomatic);
1711    }
1712
1713    #[test]
1714    fn validate_missing_coverage_file() {
1715        let err = validate_inputs(
1716            Path::new("nonexistent.info"),
1717            Path::new("src"),
1718            DEFAULT_THRESHOLD,
1719        )
1720        .unwrap_err();
1721        let msg = format!("{err:#}");
1722        assert!(msg.contains("coverage file not found"));
1723        assert!(msg.contains("cargo llvm-cov"));
1724    }
1725
1726    #[test]
1727    fn validate_missing_src_dir() {
1728        let err = validate_inputs(
1729            Path::new("Cargo.toml"),
1730            Path::new("nonexistent_dir"),
1731            DEFAULT_THRESHOLD,
1732        )
1733        .unwrap_err();
1734        let msg = format!("{err:#}");
1735        assert!(msg.contains("source directory not found"));
1736    }
1737
1738    #[test]
1739    fn validate_negative_threshold() {
1740        let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0).unwrap_err();
1741        let msg = format!("{err:#}");
1742        assert!(msg.contains("threshold must be a finite positive number"));
1743    }
1744
1745    #[test]
1746    fn validate_zero_threshold() {
1747        let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0).unwrap_err();
1748        let msg = format!("{err:#}");
1749        assert!(msg.contains("threshold must be a finite positive number"));
1750    }
1751
1752    #[test]
1753    fn validate_infinity_threshold() {
1754        let err =
1755            validate_inputs(Path::new("Cargo.toml"), Path::new("src"), f64::INFINITY).unwrap_err();
1756        let msg = format!("{err:#}");
1757        assert!(msg.contains("threshold must be a finite positive number"));
1758    }
1759
1760    #[test]
1761    fn validate_src_is_file_not_dir() {
1762        let err = validate_inputs(
1763            Path::new("Cargo.toml"),
1764            Path::new("Cargo.toml"),
1765            DEFAULT_THRESHOLD,
1766        )
1767        .unwrap_err();
1768        let msg = format!("{err:#}");
1769        assert!(msg.contains("source path is not a directory"));
1770    }
1771
1772    #[test]
1773    fn validate_coverage_is_dir_not_file() {
1774        let err =
1775            validate_inputs(Path::new("src"), Path::new("src"), DEFAULT_THRESHOLD).unwrap_err();
1776        let msg = format!("{err:#}");
1777        assert!(msg.contains("coverage path is not a file"));
1778    }
1779
1780    #[test]
1781    fn format_short_flag() {
1782        let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
1783        assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1784    }
1785
1786    #[test]
1787    fn config_flag_accepts_path() {
1788        let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
1789        assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
1790    }
1791
1792    #[test]
1793    fn config_flag_defaults_to_none() {
1794        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1795        assert_eq!(cli.input.config, None);
1796    }
1797
1798    #[test]
1799    fn view_flag_accepts_name() {
1800        let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
1801        assert_eq!(cli.input.view, Some("ci".to_string()));
1802    }
1803
1804    #[test]
1805    fn view_flag_defaults_to_none() {
1806        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1807        assert_eq!(cli.input.view, None);
1808    }
1809
1810    #[test]
1811    fn merge_threshold_cli_overrides_config() {
1812        let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
1813        let file_config = Some(FileConfig {
1814            threshold: Some(10.0),
1815            ..FileConfig::default()
1816        });
1817        let (config, display) = merge_threshold(&cli, &file_config);
1818        assert_eq!(config.global, 15.0);
1819        assert_eq!(display, 15.0);
1820    }
1821
1822    #[test]
1823    fn merge_threshold_uses_config_when_cli_default() {
1824        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1825        let file_config = Some(FileConfig {
1826            threshold: Some(12.0),
1827            ..FileConfig::default()
1828        });
1829        let (config, display) = merge_threshold(&cli, &file_config);
1830        assert_eq!(config.global, 12.0);
1831        assert_eq!(display, 12.0);
1832    }
1833
1834    #[test]
1835    fn merge_threshold_preserves_overrides() {
1836        use crate::domain::threshold::ThresholdOverride;
1837        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1838        let file_config = Some(FileConfig {
1839            threshold: Some(10.0),
1840            overrides: vec![ThresholdOverride {
1841                pattern: "domain/**".to_string(),
1842                threshold: 5.0,
1843            }],
1844            ..FileConfig::default()
1845        });
1846        let (config, _) = merge_threshold(&cli, &file_config);
1847        assert_eq!(config.overrides.len(), 1);
1848        assert_eq!(config.overrides[0].pattern, "domain/**");
1849    }
1850
1851    #[test]
1852    fn merge_threshold_no_config() {
1853        let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
1854        let (config, display) = merge_threshold(&cli, &None);
1855        assert_eq!(config.global, 20.0);
1856        assert!(config.overrides.is_empty());
1857        assert_eq!(display, 20.0);
1858    }
1859
1860    #[test]
1861    fn merge_threshold_explicit_default_overrides_config() {
1862        // User explicitly passes --threshold 8.0 (same as DEFAULT_THRESHOLD).
1863        // This MUST override the config file's threshold of 12.0.
1864        let cli = parse(&["--coverage", "lcov.info", "--threshold", "8.0"]).unwrap();
1865        let file_config = Some(FileConfig {
1866            threshold: Some(12.0),
1867            ..FileConfig::default()
1868        });
1869        let (config, display) = merge_threshold(&cli, &file_config);
1870        assert_eq!(
1871            config.global, 8.0,
1872            "explicit CLI default must override config"
1873        );
1874        assert_eq!(display, 8.0);
1875    }
1876
1877    #[test]
1878    fn merge_threshold_no_cli_no_config_uses_hardcoded_default() {
1879        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1880        let (config, display) = merge_threshold(&cli, &None);
1881        assert_eq!(config.global, DEFAULT_THRESHOLD);
1882        assert_eq!(display, DEFAULT_THRESHOLD);
1883    }
1884
1885    #[test]
1886    fn merge_exclude_combines_cli_and_config() {
1887        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
1888        let file_config = Some(FileConfig {
1889            exclude: Some(vec!["benches/**".to_string()]),
1890            ..FileConfig::default()
1891        });
1892        let exclude = merge_exclude(&cli, &file_config);
1893        assert_eq!(exclude, vec!["tests/**", "benches/**"]);
1894    }
1895
1896    #[test]
1897    fn merge_exclude_deduplicates() {
1898        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
1899        let file_config = Some(FileConfig {
1900            exclude: Some(vec!["tests/**".to_string()]),
1901            ..FileConfig::default()
1902        });
1903        let exclude = merge_exclude(&cli, &file_config);
1904        assert_eq!(exclude, vec!["tests/**"]);
1905    }
1906
1907    // ── --diff flag tests ───────────────────────────────────────────
1908
1909    #[test]
1910    fn diff_flag_accepts_ref() {
1911        let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
1912        assert_eq!(cli.filter.diff, Some("main".to_string()));
1913    }
1914
1915    #[test]
1916    fn diff_flag_defaults_to_none() {
1917        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1918        assert_eq!(cli.filter.diff, None);
1919    }
1920
1921    #[test]
1922    fn diff_flag_accepts_commit_sha() {
1923        let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
1924        assert_eq!(cli.filter.diff, Some("abc123".to_string()));
1925    }
1926
1927    #[test]
1928    fn diff_flag_accepts_head_tilde() {
1929        let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
1930        assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
1931    }
1932
1933    #[test]
1934    fn validate_diff_ref_rejects_empty_string() {
1935        let err = validate_diff_ref("").unwrap_err();
1936        let msg = format!("{err:#}");
1937        assert!(msg.contains("must not be empty"));
1938    }
1939
1940    #[test]
1941    fn validate_diff_ref_rejects_dash_prefix() {
1942        let err = validate_diff_ref("--malicious").unwrap_err();
1943        let msg = format!("{err:#}");
1944        assert!(msg.contains("invalid diff ref"));
1945        assert!(msg.contains("must not start with a dash"));
1946    }
1947
1948    #[test]
1949    fn validate_diff_ref_accepts_normal_ref() {
1950        assert!(validate_diff_ref("main").is_ok());
1951        assert!(validate_diff_ref("HEAD~1").is_ok());
1952        assert!(validate_diff_ref("abc123").is_ok());
1953    }
1954
1955    #[test]
1956    fn preflight_git_worktree_passes_in_git_repo() {
1957        // Initialize a fresh git repo in a temp dir so the test is self-contained
1958        // and works under tools (e.g. cargo-mutants) that copy the source tree
1959        // without `.git`.
1960        let tmp = tempfile::tempdir().unwrap();
1961        let status = std::process::Command::new("git")
1962            .arg("init")
1963            .arg("--quiet")
1964            .current_dir(tmp.path())
1965            .status()
1966            .expect("git init");
1967        assert!(status.success(), "git init failed");
1968        assert!(preflight_git_worktree(tmp.path()).is_ok());
1969    }
1970
1971    #[test]
1972    fn breakdown_flag_parsed() {
1973        let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
1974        assert!(cli.display.breakdown);
1975    }
1976
1977    #[test]
1978    fn breakdown_flag_default_false() {
1979        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1980        assert!(!cli.display.breakdown);
1981    }
1982
1983    #[test]
1984    fn explain_flag_parsed() {
1985        let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
1986        assert!(cli.display.explain);
1987    }
1988
1989    #[test]
1990    fn explain_flag_default_false() {
1991        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1992        assert!(!cli.display.explain);
1993    }
1994
1995    #[test]
1996    fn explain_requires_breakdown_for_table_output() {
1997        let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
1998        let err = validate_display_flags(&cli).unwrap_err();
1999        let msg = err.to_string();
2000        assert!(msg.contains("--breakdown"));
2001        assert!(msg.contains("--explain"));
2002    }
2003
2004    #[test]
2005    fn explain_allowed_for_json_output() {
2006        let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
2007        assert!(validate_display_flags(&cli).is_ok());
2008    }
2009
2010    #[test]
2011    fn color_overrides_set_global_state() {
2012        // Combined into one test to avoid nondeterministic interleaving —
2013        // colored::control uses a process-global flag that parallel tests
2014        // can race on.
2015        apply_color(ColorArg::Never);
2016        assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
2017
2018        apply_color(ColorArg::Always);
2019        assert!(colored::control::SHOULD_COLORIZE.should_colorize());
2020
2021        apply_color(ColorArg::Auto);
2022    }
2023
2024    // ── Pre-flight check tests ─────────────────────────────────────────
2025
2026    #[test]
2027    fn preflight_empty_coverage_file() {
2028        let dir = tempfile::tempdir().unwrap();
2029        let cov = dir.path().join("empty.info");
2030        std::fs::write(&cov, "").unwrap();
2031
2032        let err = check_coverage_has_data(&cov).unwrap_err();
2033        let msg = format!("{err:#}");
2034        assert!(msg.contains("no coverage data found"));
2035        assert!(msg.contains("cargo llvm-cov"));
2036    }
2037
2038    #[test]
2039    fn preflight_coverage_no_da_lines() {
2040        let dir = tempfile::tempdir().unwrap();
2041        let cov = dir.path().join("no_da.info");
2042        std::fs::write(&cov, "SF:src/main.rs\nend_of_record\n").unwrap();
2043
2044        let err = check_coverage_has_data(&cov).unwrap_err();
2045        let msg = format!("{err:#}");
2046        assert!(msg.contains("no coverage data found"));
2047    }
2048
2049    #[test]
2050    fn preflight_coverage_with_da_lines_passes() {
2051        let dir = tempfile::tempdir().unwrap();
2052        let cov = dir.path().join("good.info");
2053        std::fs::write(&cov, "SF:src/main.rs\nDA:1,5\nend_of_record\n").unwrap();
2054
2055        assert!(check_coverage_has_data(&cov).is_ok());
2056    }
2057
2058    #[test]
2059    fn preflight_coverage_da_outside_sf_block_rejected() {
2060        let dir = tempfile::tempdir().unwrap();
2061        let cov = dir.path().join("orphan_da.info");
2062        std::fs::write(&cov, "DA:1,5\nend_of_record\n").unwrap();
2063
2064        let err = check_coverage_has_data(&cov).unwrap_err();
2065        let msg = format!("{err:#}");
2066        assert!(msg.contains("no coverage data found"));
2067    }
2068
2069    #[test]
2070    fn preflight_coverage_malformed_da_rejected() {
2071        let dir = tempfile::tempdir().unwrap();
2072        let cov = dir.path().join("bad_da.info");
2073        std::fs::write(&cov, "SF:src/main.rs\nDA:not_a_number\nend_of_record\n").unwrap();
2074
2075        let err = check_coverage_has_data(&cov).unwrap_err();
2076        let msg = format!("{err:#}");
2077        assert!(msg.contains("no coverage data found"));
2078    }
2079
2080    #[test]
2081    fn preflight_src_dir_no_rust_files() {
2082        let dir = tempfile::tempdir().unwrap();
2083        std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
2084
2085        let err = check_src_has_rust_files(dir.path()).unwrap_err();
2086        let msg = format!("{err:#}");
2087        assert!(msg.contains("no Rust source files found"));
2088    }
2089
2090    #[test]
2091    fn preflight_src_dir_empty() {
2092        let dir = tempfile::tempdir().unwrap();
2093
2094        let err = check_src_has_rust_files(dir.path()).unwrap_err();
2095        let msg = format!("{err:#}");
2096        assert!(msg.contains("no Rust source files found"));
2097    }
2098
2099    #[test]
2100    fn preflight_src_dir_with_rs_files_passes() {
2101        let dir = tempfile::tempdir().unwrap();
2102        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
2103
2104        assert!(check_src_has_rust_files(dir.path()).is_ok());
2105    }
2106
2107    #[test]
2108    fn preflight_src_dir_nested_rs_files_passes() {
2109        let dir = tempfile::tempdir().unwrap();
2110        let nested = dir.path().join("sub");
2111        std::fs::create_dir(&nested).unwrap();
2112        std::fs::write(nested.join("lib.rs"), "pub fn foo() {}").unwrap();
2113
2114        assert!(check_src_has_rust_files(dir.path()).is_ok());
2115    }
2116
2117    // ── --strict / --lenient flag tests ───────────────────────────────
2118
2119    #[test]
2120    fn strict_flag_parses() {
2121        let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2122        assert!(cli.output.strict);
2123    }
2124
2125    #[test]
2126    fn lenient_flag_parses() {
2127        let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2128        assert!(cli.output.lenient);
2129    }
2130
2131    #[test]
2132    fn strict_and_threshold_mutually_exclusive() {
2133        parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
2134    }
2135
2136    #[test]
2137    fn strict_and_lenient_mutually_exclusive() {
2138        parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
2139    }
2140
2141    #[test]
2142    fn merge_threshold_strict_flag() {
2143        use crate::domain::threshold::STRICT_THRESHOLD;
2144        let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2145        let (config, display) = merge_threshold(&cli, &None);
2146        assert_eq!(config.global, STRICT_THRESHOLD);
2147        assert_eq!(display, STRICT_THRESHOLD);
2148    }
2149
2150    #[test]
2151    fn merge_threshold_lenient_flag() {
2152        use crate::domain::threshold::LENIENT_THRESHOLD;
2153        let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2154        let (config, display) = merge_threshold(&cli, &None);
2155        assert_eq!(config.global, LENIENT_THRESHOLD);
2156        assert_eq!(display, LENIENT_THRESHOLD);
2157    }
2158
2159    #[test]
2160    fn merge_threshold_toml_preset_used_when_no_cli_flag() {
2161        use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
2162        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2163        let file_config = Some(FileConfig {
2164            preset: Some(ThresholdPreset::Strict),
2165            ..FileConfig::default()
2166        });
2167        let (config, _) = merge_threshold(&cli, &file_config);
2168        assert_eq!(config.global, STRICT_THRESHOLD);
2169    }
2170
2171    #[test]
2172    fn merge_threshold_cli_threshold_overrides_toml_preset() {
2173        use crate::domain::threshold::ThresholdPreset;
2174        let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
2175        let file_config = Some(FileConfig {
2176            preset: Some(ThresholdPreset::Strict),
2177            ..FileConfig::default()
2178        });
2179        let (config, _) = merge_threshold(&cli, &file_config);
2180        assert_eq!(config.global, 50.0);
2181    }
2182
2183    // ── majority_zero_coverage predicate tests ─────────────────────────
2184
2185    #[test]
2186    fn zero_coverage_warn_triggers_above_50_percent() {
2187        assert!(majority_zero_coverage(10, 6));
2188        assert!(majority_zero_coverage(1, 1));
2189        assert!(majority_zero_coverage(3, 2));
2190    }
2191
2192    #[test]
2193    fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
2194        assert!(!majority_zero_coverage(10, 5));
2195        assert!(!majority_zero_coverage(2, 1));
2196    }
2197
2198    #[test]
2199    fn zero_coverage_warn_does_not_trigger_when_no_files() {
2200        assert!(!majority_zero_coverage(0, 0));
2201    }
2202
2203    // ── merge_effective_inputs tests ───────────────────────────────────
2204
2205    #[test]
2206    fn merge_effective_inputs_default_src() {
2207        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2208        let inputs = merge_effective_inputs(&cli, &None);
2209        assert_eq!(inputs.src, PathBuf::from("src"));
2210    }
2211
2212    #[test]
2213    fn merge_effective_inputs_cli_src_wins_over_config() {
2214        let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2215        let file_config = Some(FileConfig {
2216            src: Some(PathBuf::from("from-config/")),
2217            ..FileConfig::default()
2218        });
2219        let inputs = merge_effective_inputs(&cli, &file_config);
2220        assert_eq!(inputs.src, PathBuf::from("crates/"));
2221    }
2222
2223    #[test]
2224    fn merge_effective_inputs_config_src_when_cli_absent() {
2225        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2226        let file_config = Some(FileConfig {
2227            src: Some(PathBuf::from("from-config/")),
2228            ..FileConfig::default()
2229        });
2230        let inputs = merge_effective_inputs(&cli, &file_config);
2231        assert_eq!(inputs.src, PathBuf::from("from-config/"));
2232    }
2233
2234    #[test]
2235    fn merge_effective_inputs_default_metric_is_cognitive() {
2236        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2237        let inputs = merge_effective_inputs(&cli, &None);
2238        assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2239    }
2240
2241    #[test]
2242    fn merge_effective_inputs_cli_metric_overrides_config() {
2243        let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
2244        let file_config = Some(FileConfig {
2245            metric: Some(ComplexityMetric::Cognitive),
2246            ..FileConfig::default()
2247        });
2248        let inputs = merge_effective_inputs(&cli, &file_config);
2249        assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2250    }
2251
2252    #[test]
2253    fn merge_effective_inputs_threshold_default() {
2254        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2255        let inputs = merge_effective_inputs(&cli, &None);
2256        assert_eq!(inputs.threshold, DEFAULT_THRESHOLD);
2257    }
2258
2259    #[test]
2260    fn merge_effective_inputs_exclude_combines_cli_and_config() {
2261        let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2262        let file_config = Some(FileConfig {
2263            exclude: Some(vec!["benches/**".to_string()]),
2264            ..FileConfig::default()
2265        });
2266        let inputs = merge_effective_inputs(&cli, &file_config);
2267        assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
2268    }
2269
2270    // ── compute_exit_code tests ────────────────────────────────────────
2271    //
2272    // delta_state=None covers the analysis-only paths; the delta-gate +
2273    // delta_state=Some interactions are exercised end-to-end in
2274    // delta_gate_integration.rs (where AnalysisDelta is built through
2275    // the real `delta::compute` path rather than mocked).
2276
2277    #[test]
2278    fn compute_exit_code_passing_no_delta() {
2279        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2280        assert!(compute_exit_code::<
2281            crate::test_strategies::DummyParseDiagnostic,
2282        >(&cli, true, None));
2283    }
2284
2285    #[test]
2286    fn compute_exit_code_failing_no_delta() {
2287        let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2288        assert!(!compute_exit_code::<
2289            crate::test_strategies::DummyParseDiagnostic,
2290        >(&cli, false, None));
2291    }
2292
2293    #[test]
2294    fn compute_exit_code_no_fail_overrides_failure() {
2295        let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
2296        assert!(compute_exit_code::<
2297            crate::test_strategies::DummyParseDiagnostic,
2298        >(&cli, false, None));
2299    }
2300
2301    #[test]
2302    fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
2303        // delta_state=None → delta_passed defaults to true even with
2304        // --delta-gate; this matches the runtime behavior when the
2305        // baseline file is missing or unreadable. Clap requires
2306        // --baseline to accompany --delta-gate at parse time, so we
2307        // pass a sentinel path to satisfy the parser without exercising
2308        // the file load (compute_exit_code only inspects the resolved
2309        // delta state, not cli.input.baseline).
2310        let cli = parse(&[
2311            "--coverage",
2312            "lcov.info",
2313            "--delta-gate",
2314            "--baseline",
2315            "/dev/null",
2316        ])
2317        .unwrap();
2318        assert!(compute_exit_code::<
2319            crate::test_strategies::DummyParseDiagnostic,
2320        >(&cli, true, None));
2321    }
2322
2323    #[test]
2324    fn compute_exit_code_no_fail_with_delta_gate() {
2325        // --no-fail is the master override even when --delta-gate
2326        // is set.
2327        let cli = parse(&[
2328            "--coverage",
2329            "lcov.info",
2330            "--delta-gate",
2331            "--baseline",
2332            "/dev/null",
2333            "--no-fail",
2334        ])
2335        .unwrap();
2336        assert!(compute_exit_code::<
2337            crate::test_strategies::DummyParseDiagnostic,
2338        >(&cli, false, None));
2339    }
2340}