use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::SystemTime;
use anyhow::{Result, bail};
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, ValueHint};
use clap_complete::Shell as ClapShell;
use crate::adapters::baseline::{self, BaselineSnapshot};
use crate::adapters::config::{self, FileConfig};
use crate::adapters::reporters;
use crate::adapters::reporters::json::DeltaContext;
use crate::core::{AnalysisOutput, AnalyzeOptions};
use crate::domain::delta::{self, AnalysisDelta, DeltaView};
use crate::domain::threshold::{
DEFAULT_THRESHOLD, LENIENT_THRESHOLD, STRICT_THRESHOLD, ThresholdConfig, is_valid_threshold,
};
use crate::domain::types::{AnalysisDiagnostics, ComplexityMetric};
use crate::domain::view::{self, GroupKey, SortKey};
use crate::ports::{ComplexityPort, CoveragePort, ParseDiagnostic};
mod delta_args;
mod view_args;
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum MetricArg {
Cognitive,
Cyclomatic,
}
impl From<MetricArg> for ComplexityMetric {
fn from(arg: MetricArg) -> Self {
match arg {
MetricArg::Cognitive => ComplexityMetric::Cognitive,
MetricArg::Cyclomatic => ComplexityMetric::Cyclomatic,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum FormatArg {
Table,
Json,
Markdown,
Csv,
Sarif,
Advice,
ScorecardRow,
Html,
}
#[derive(Debug, Clone)]
pub struct FormatSpec {
pub format: FormatArg,
pub output: Option<PathBuf>,
}
impl std::str::FromStr for FormatSpec {
type Err = String;
fn from_str(spec: &str) -> Result<Self, Self::Err> {
let (fmt_str, output) = match spec.split_once(':') {
Some((f, path)) if !path.is_empty() => (f, Some(PathBuf::from(path))),
Some((_, _)) => return Err(format!("empty file path in `--format {spec}`")),
None => (spec, None),
};
let format = FormatArg::from_str(fmt_str, true)
.map_err(|e| format!("invalid format `{fmt_str}`: {e}"))?;
Ok(FormatSpec { format, output })
}
}
fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
s.parse()
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum SortKeyArg {
Crap,
Coverage,
Complexity,
Path,
}
impl From<SortKeyArg> for SortKey {
fn from(arg: SortKeyArg) -> Self {
match arg {
SortKeyArg::Crap => SortKey::Crap,
SortKeyArg::Coverage => SortKey::Coverage,
SortKeyArg::Complexity => SortKey::Complexity,
SortKeyArg::Path => SortKey::Path,
}
}
}
impl From<SortKey> for SortKeyArg {
fn from(key: SortKey) -> Self {
match key {
SortKey::Crap => SortKeyArg::Crap,
SortKey::Coverage => SortKeyArg::Coverage,
SortKey::Complexity => SortKeyArg::Complexity,
SortKey::Path => SortKeyArg::Path,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum GroupByArg {
File,
}
impl From<GroupByArg> for GroupKey {
fn from(arg: GroupByArg) -> Self {
match arg {
GroupByArg::File => GroupKey::File,
}
}
}
impl From<GroupKey> for GroupByArg {
fn from(key: GroupKey) -> Self {
match key {
GroupKey::File => GroupByArg::File,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum DeltaSortKeyArg {
ScoreDelta,
CurrentCrap,
BaselineCrap,
Path,
}
impl From<DeltaSortKeyArg> for crate::domain::delta::DeltaSortKey {
fn from(arg: DeltaSortKeyArg) -> Self {
use crate::domain::delta::DeltaSortKey;
match arg {
DeltaSortKeyArg::ScoreDelta => DeltaSortKey::ScoreDelta,
DeltaSortKeyArg::CurrentCrap => DeltaSortKey::CurrentCrap,
DeltaSortKeyArg::BaselineCrap => DeltaSortKey::BaselineCrap,
DeltaSortKeyArg::Path => DeltaSortKey::Path,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum DeltaKindArg {
Added,
Removed,
Modified,
}
impl From<DeltaKindArg> for crate::domain::delta::ChangeKind {
fn from(arg: DeltaKindArg) -> Self {
use crate::domain::delta::ChangeKind;
match arg {
DeltaKindArg::Added => ChangeKind::Added,
DeltaKindArg::Removed => ChangeKind::Removed,
DeltaKindArg::Modified => ChangeKind::Modified,
}
}
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum ColorArg {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ShellArg {
Bash,
Zsh,
Fish,
Powershell,
Elvish,
Nushell,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Completions {
#[arg(value_enum)]
shell: ShellArg,
},
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Input")]
pub struct InputArgs {
#[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
pub coverage: Option<PathBuf>,
#[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
pub src: Option<PathBuf>,
#[arg(long, value_enum)]
pub metric: Option<MetricArg>,
#[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
pub config: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
pub view: Option<String>,
#[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
pub baseline: Option<PathBuf>,
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Output")]
pub struct OutputArgs {
#[arg(
short,
long,
value_delimiter = ',',
default_value = "table",
value_parser = parse_format_spec
)]
pub format: Vec<FormatSpec>,
#[arg(long, allow_hyphen_values = true, group = "threshold_select")]
pub threshold: Option<f64>,
#[arg(long, group = "threshold_select")]
pub strict: bool,
#[arg(long, group = "threshold_select")]
pub lenient: bool,
#[arg(long)]
pub no_fail: bool,
#[arg(long, requires = "baseline")]
pub delta_gate: bool,
#[arg(long)]
pub minimal_view: bool,
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Filtering")]
pub struct FilterArgs {
#[arg(long, action = clap::ArgAction::Append)]
pub exclude: Vec<String>,
#[arg(long)]
pub no_gitignore: bool,
#[arg(long, value_name = "REF")]
pub diff: Option<String>,
#[arg(long)]
pub only_failing: bool,
#[arg(long, allow_hyphen_values = true, value_name = "PCT")]
pub min_coverage: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_name = "PCT")]
pub max_coverage: Option<f64>,
#[arg(long, value_enum, value_name = "KEY")]
pub sort_by: Option<SortKeyArg>,
#[arg(long, allow_hyphen_values = true, value_name = "N")]
pub top: Option<u32>,
#[arg(long, value_enum, value_name = "KEY")]
pub group_by: Option<GroupByArg>,
#[arg(long, allow_hyphen_values = true, value_name = "N")]
pub delta_top: Option<u32>,
#[arg(long, value_enum, value_name = "KEY")]
pub delta_sort: Option<DeltaSortKeyArg>,
#[arg(long, value_delimiter = ',', value_name = "KINDS")]
pub delta_only: Vec<DeltaKindArg>,
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Display")]
pub struct DisplayArgs {
#[arg(long, value_enum, default_value_t = ColorArg::Auto)]
pub color: ColorArg,
#[arg(short, long)]
pub verbose: bool,
#[arg(short, long)]
pub quiet: bool,
#[arg(long)]
pub breakdown: bool,
#[arg(long)]
pub explain: bool,
#[arg(long)]
pub md_full_table: bool,
#[arg(long, value_name = "N", default_value_t = 10)]
pub md_top: usize,
}
#[derive(Debug, Parser)]
#[command(
version,
author,
about = "CRAP score analyzer for Rust",
long_about = "CRAP (Change Risk Anti-Patterns) score analyzer for Rust codebases.\n\n\
Combines complexity analysis (via syn) with line coverage data \
(LCOV from cargo-llvm-cov) to identify functions that are both \
complex and under-tested.\n\n\
Default metric is cognitive complexity (not cyclomatic), which \
better captures Rust idioms like match arms and nested control flow.",
after_help = "\
EXAMPLES:
crap4rs --coverage lcov.info
crap4rs --coverage lcov.info --threshold 15 --metric cyclomatic
crap4rs --coverage lcov.info --format json | jq '.functions[] | select(.exceeds)'
crap4rs --coverage lcov.info --only-failing
crap4rs --coverage lcov.info --exclude \"tests/**\" --exclude \"benches/**\"
INVESTIGATION PATTERNS:
# First-run scan: keep the report short
crap4rs --coverage lcov.info --top 20
# Worst partially-covered functions, sorted by coverage ascending,
# never fail the build — useful when investigating an untested codebase
crap4rs --coverage lcov.info --min-coverage 1 --max-coverage 90 --sort-by coverage --top 10 --no-fail
# Saved view preset: bake a flag set under [views.ci] in crap4rs.toml,
# then invoke it by name. CLI flags override preset values.
crap4rs --coverage lcov.info --view ci
# GitHub Code Scanning: emit SARIF and let upload-sarif annotate the PR
# diff inline. Use --no-fail so the gate exit code doesn't skip the
# upload step on regressions.
crap4rs --coverage lcov.info --format sarif --no-fail > crap.sarif
COMPARING TWO ANALYSES (issue #81):
# Capture a baseline (e.g., from main):
crap4rs --coverage lcov.info --format json > baseline.json
# Then compare the working tree to it (informational by default):
crap4rs --coverage lcov.info --baseline baseline.json
# CI usage: fail the build when new threshold violations land
crap4rs --coverage lcov.info --baseline baseline.json --delta-gate
# PR-comment scorecard (markdown — drop into the comment body verbatim)
crap4rs --coverage lcov.info --baseline baseline.json --format markdown"
)]
pub struct Cli {
#[command(flatten)]
pub input: InputArgs,
#[command(flatten)]
pub output: OutputArgs,
#[command(flatten)]
pub filter: FilterArgs,
#[command(flatten)]
pub display: DisplayArgs,
#[command(subcommand)]
pub command: Option<Command>,
}
pub fn parse_args(tool_version: &str, long_version: &str) -> Cli {
let cmd = build_command(tool_version, long_version);
let matches = cmd.get_matches();
Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
}
fn current_bin_name() -> String {
std::env::args()
.next()
.and_then(|first| {
std::path::PathBuf::from(first)
.file_stem()
.map(|os| os.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "crap4rs".to_string())
}
fn build_command(tool_version: &str, long_version: &str) -> clap::Command {
let bin_static: &'static str = Box::leak(current_bin_name().into_boxed_str());
let version_static: &'static str = Box::leak(tool_version.to_string().into_boxed_str());
let long_version_static: &'static str = Box::leak(long_version.to_string().into_boxed_str());
Cli::command()
.name(bin_static)
.bin_name(bin_static)
.version(version_static)
.long_version(long_version_static)
}
pub fn run<P: ParseDiagnostic + std::fmt::Display>(
cli: Cli,
complexity: &dyn ComplexityPort,
coverage: &dyn CoveragePort<Diagnostic = P>,
tool_version: &str,
) -> ExitCode {
match run_inner(cli, complexity, coverage, tool_version) {
Ok(true) => ExitCode::from(0),
Ok(false) => ExitCode::from(1),
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(2)
}
}
}
fn run_inner<P: ParseDiagnostic + std::fmt::Display>(
mut cli: Cli,
complexity: &dyn ComplexityPort,
coverage: &dyn CoveragePort<Diagnostic = P>,
tool_version: &str,
) -> Result<bool> {
if let Some(Command::Completions { shell }) = cli.command {
emit_completions(shell, ¤t_bin_name());
return Ok(true);
}
let prep = prepare_pipeline(&mut cli, complexity, coverage)?;
let spec = view_args::build_view_spec(&cli);
let view = view::apply(&prep.analysis.result, spec);
let delta_spec = delta_args::build_delta_view_spec(&cli);
let delta_view: Option<DeltaView<'_>> = prep
.delta_state
.as_ref()
.map(move |s| delta::apply(&s.delta, delta_spec));
if !cli.display.quiet {
print_formatted_output(
&cli,
&view,
delta_view.as_ref(),
prep.delta_state.as_ref(),
&prep.analysis,
&prep.inputs,
tool_version,
)?;
}
Ok(compute_exit_code(
&cli,
prep.analysis.result.passed,
prep.delta_state.as_ref(),
))
}
struct EffectiveInputs {
src: PathBuf,
metric: ComplexityMetric,
threshold_config: ThresholdConfig,
threshold: f64,
exclude: Vec<String>,
}
struct PipelinePrep<P: ParseDiagnostic> {
inputs: EffectiveInputs,
analysis: AnalysisOutput<P>,
delta_state: Option<DeltaState<P>>,
}
fn merge_effective_inputs(cli: &Cli, file_config: &Option<FileConfig>) -> EffectiveInputs {
let src = cli
.input
.src
.clone()
.or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
.unwrap_or_else(|| PathBuf::from("src"));
let metric: ComplexityMetric = cli
.input
.metric
.map(Into::into)
.or_else(|| file_config.as_ref().and_then(|c| c.metric))
.unwrap_or_default();
let (threshold_config, threshold) = merge_threshold(cli, file_config);
let exclude = merge_exclude(cli, file_config);
EffectiveInputs {
src,
metric,
threshold_config,
threshold,
exclude,
}
}
fn validate_runtime_inputs<'a>(cli: &'a Cli, inputs: &EffectiveInputs) -> Result<&'a Path> {
let Some(coverage_path) = cli.input.coverage.as_deref() else {
bail!(
"--coverage <FILE> is required (run `crap4rs --help` for usage, or `crap4rs completions <SHELL>` for shell completion scripts)"
);
};
validate_inputs(coverage_path, &inputs.src, inputs.threshold)?;
preflight_checks(coverage_path, &inputs.src)?;
if let Some(diff_ref) = cli.filter.diff.as_deref() {
validate_diff_ref(diff_ref)?;
preflight_git_worktree(&inputs.src)?;
}
Ok(coverage_path)
}
fn build_analyze_options(cli: &Cli, inputs: &EffectiveInputs, coverage: &Path) -> AnalyzeOptions {
AnalyzeOptions {
src: inputs.src.clone(),
coverage: coverage.to_path_buf(),
threshold_config: inputs.threshold_config.clone(),
metric: inputs.metric,
exclude: inputs.exclude.clone(),
respect_gitignore: !cli.filter.no_gitignore,
diff_ref: cli.filter.diff.clone(),
compute_diagnostics: cli
.output
.format
.iter()
.any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
..AnalyzeOptions::default()
}
}
fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
cli: &Cli,
diagnostics: &AnalysisDiagnostics<P>,
) {
warn_if_issues(diagnostics);
if cli.display.verbose {
print_diagnostics(diagnostics);
}
}
fn prepare_pipeline<P: ParseDiagnostic + std::fmt::Display>(
cli: &mut Cli,
complexity: &dyn ComplexityPort,
coverage: &dyn CoveragePort<Diagnostic = P>,
) -> Result<PipelinePrep<P>> {
validate_display_flags(cli)?;
apply_color(cli.display.color);
let file_config = load_file_config(cli)?;
view_args::resolve_view_preset(cli, file_config.as_ref())?;
view_args::validate_view_args(cli)?;
let inputs = merge_effective_inputs(cli, &file_config);
let coverage_path = validate_runtime_inputs(cli, &inputs)?;
let options = build_analyze_options(cli, &inputs, coverage_path);
let analysis = crate::core::analyze(&options, complexity, coverage)?;
apply_diagnostics(cli, &analysis.diagnostics);
let delta_state = load_delta_state(cli, &analysis.result)?;
Ok(PipelinePrep {
inputs,
analysis,
delta_state,
})
}
fn format_as_json<P: ParseDiagnostic>(
cli: &Cli,
view: &view::AnalysisView<'_>,
delta_view: Option<&DeltaView<'_>>,
delta_state: Option<&DeltaState<P>>,
analysis: &AnalysisOutput<P>,
inputs: &EffectiveInputs,
tool_version: &str,
) -> Result<String> {
let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
view: dv,
baseline_tool_version: &s.snapshot.tool_version,
baseline_timestamp: &s.snapshot.timestamp,
baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
});
let config = reporters::json::JsonConfig {
tool_version: tool_version.to_string(),
metric: inputs.metric,
threshold: inputs.threshold,
timestamp: now_unix_epoch(),
diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
diff_ref: cli.filter.diff.as_deref(),
minimal_view: cli.output.minimal_view,
delta: delta_ctx,
};
reporters::json::format_json(view, &config).map_err(Into::into)
}
fn format_as_scorecard_row<P: ParseDiagnostic>(
delta_state: Option<&DeltaState<P>>,
result: &crate::domain::types::AnalysisResult,
threshold: f64,
) -> String {
let baseline_result = delta_state.map(|s| &s.snapshot.result);
let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
let row_data = crate::domain::summary::project_crap_delta_row(
result,
baseline_result,
delta_inputs,
threshold.round() as u32,
);
reporters::format_scorecard_row(&row_data)
}
#[allow(clippy::too_many_arguments)]
fn render_format<P: ParseDiagnostic>(
cli: &Cli,
spec: &FormatSpec,
view: &view::AnalysisView<'_>,
delta_view: Option<&DeltaView<'_>>,
delta_state: Option<&DeltaState<P>>,
analysis: &AnalysisOutput<P>,
inputs: &EffectiveInputs,
tool_version: &str,
) -> Result<String> {
Ok(match spec.format {
FormatArg::Table => reporters::format_table_with_explain(
view,
delta_view,
inputs.threshold,
cli.display.breakdown,
cli.display.explain,
tool_version,
),
FormatArg::Json | FormatArg::Advice => format_as_json(
cli,
view,
delta_view,
delta_state,
analysis,
inputs,
tool_version,
)?,
FormatArg::Markdown => reporters::format_markdown(
view,
delta_view,
inputs.threshold,
cli.display.breakdown,
cli.display.explain,
cli.display.md_full_table,
cli.display.md_top,
tool_version,
),
FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
FormatArg::Sarif => reporters::format_sarif(view, tool_version),
FormatArg::ScorecardRow => {
format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
}
FormatArg::Html => reporters::format_html(view, inputs.threshold, tool_version),
})
}
fn print_formatted_output<P: ParseDiagnostic>(
cli: &Cli,
view: &view::AnalysisView<'_>,
delta_view: Option<&DeltaView<'_>>,
delta_state: Option<&DeltaState<P>>,
analysis: &AnalysisOutput<P>,
inputs: &EffectiveInputs,
tool_version: &str,
) -> Result<()> {
for spec in &cli.output.format {
let output = render_format(
cli,
spec,
view,
delta_view,
delta_state,
analysis,
inputs,
tool_version,
)?;
match &spec.output {
Some(path) => std::fs::write(path, &output)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
None => print!("{output}"),
}
}
if cli
.output
.format
.iter()
.any(|s| matches!(s.format, FormatArg::Advice))
{
let mut stderr = std::io::stderr();
let _ = reporters::render_advice_summary(view, &mut stderr);
}
Ok(())
}
fn compute_exit_code<P: ParseDiagnostic>(
cli: &Cli,
passed: bool,
delta_state: Option<&DeltaState<P>>,
) -> bool {
let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
combined_passed || cli.output.no_fail
}
struct DeltaState<P: ParseDiagnostic> {
snapshot: BaselineSnapshot<P>,
delta: AnalysisDelta,
}
fn load_delta_state<P: ParseDiagnostic>(
cli: &Cli,
current: &crate::domain::types::AnalysisResult,
) -> Result<Option<DeltaState<P>>> {
let Some(path) = cli.input.baseline.as_ref() else {
return Ok(None);
};
let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
let delta = delta::compute(snapshot.result.clone(), current.clone());
Ok(Some(DeltaState { snapshot, delta }))
}
fn validate_display_flags(cli: &Cli) -> Result<()> {
let any_table = cli
.output
.format
.iter()
.any(|s| matches!(s.format, FormatArg::Table));
if cli.display.explain && any_table && !cli.display.breakdown {
bail!("--explain requires --breakdown for table output");
}
validate_format_destinations(&cli.output.format)?;
Ok(())
}
fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
if specs.len() > 1 {
let stdout_specs: Vec<_> = specs
.iter()
.filter(|s| s.output.is_none())
.map(|s| format_arg_kebab(s.format).to_string())
.collect();
if !stdout_specs.is_empty() {
bail!(
"multi-format `--format` requires every entry to specify a file (e.g. `json:envelope.json`); stdout-only entries: {}",
stdout_specs.join(", ")
);
}
}
Ok(())
}
fn format_arg_kebab(arg: FormatArg) -> String {
use clap::ValueEnum;
arg.to_possible_value()
.map(|v| v.get_name().to_string())
.unwrap_or_else(|| format!("{arg:?}").to_lowercase())
}
fn load_file_config(cli: &Cli) -> Result<Option<FileConfig>> {
if let Some(path) = &cli.input.config {
Ok(Some(config::load_config(path)?))
} else {
match config::discover_config()? {
Some(path) => Ok(Some(config::load_config(&path)?)),
None => Ok(None),
}
}
}
fn merge_threshold(cli: &Cli, file_config: &Option<FileConfig>) -> (ThresholdConfig, f64) {
let global = cli
.output
.threshold
.or_else(|| cli.output.strict.then_some(STRICT_THRESHOLD))
.or_else(|| cli.output.lenient.then_some(LENIENT_THRESHOLD))
.or_else(|| {
file_config
.as_ref()
.and_then(|c| c.preset)
.map(|p| p.threshold())
})
.or_else(|| file_config.as_ref().and_then(|c| c.threshold))
.unwrap_or(DEFAULT_THRESHOLD);
let overrides = file_config
.as_ref()
.map(|fc| fc.overrides.clone())
.unwrap_or_default();
let config = ThresholdConfig { global, overrides };
(config, global)
}
fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>) -> Vec<String> {
let mut exclude = cli.filter.exclude.clone();
if let Some(fc) = file_config
&& let Some(fc_exclude) = &fc.exclude
{
let seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
for pattern in fc_exclude {
if !seen.contains(pattern) {
exclude.push(pattern.clone());
}
}
}
exclude
}
fn validate_inputs(
coverage: &std::path::Path,
src: &std::path::Path,
threshold: f64,
) -> Result<()> {
match std::fs::metadata(coverage) {
Ok(m) if m.is_file() => {}
Ok(_) => bail!(
"coverage path is not a file: {}\n \
hint: pass --coverage pointing to an LCOV file, not a directory",
coverage.display()
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
"coverage file not found: {}\n \
hint: run `cargo llvm-cov --lcov --output-path lcov.info` first",
coverage.display()
),
Err(e) => bail!(
"cannot access coverage file: {}: {e}\n \
hint: check file permissions",
coverage.display()
),
}
match std::fs::metadata(src) {
Ok(m) if m.is_dir() => {}
Ok(_) => bail!(
"source path is not a directory: {}\n \
hint: pass --src <DIR> pointing to your Rust source root",
src.display()
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
"source directory not found: {}\n \
hint: pass --src <DIR> pointing to your Rust source root",
src.display()
),
Err(e) => bail!(
"cannot access source directory: {}: {e}\n \
hint: check directory permissions",
src.display()
),
}
if !is_valid_threshold(threshold) {
bail!(
"threshold must be a finite positive number, got: {}",
threshold
);
}
Ok(())
}
fn validate_diff_ref(diff_ref: &str) -> Result<()> {
if diff_ref.is_empty() {
bail!("invalid diff ref: ref must not be empty");
}
if diff_ref.starts_with('-') {
bail!(
"invalid diff ref: {diff_ref}\n \
hint: ref must not start with a dash"
);
}
Ok(())
}
fn preflight_git_worktree(src: &Path) -> Result<()> {
let output = std::process::Command::new("git")
.current_dir(src)
.args(["rev-parse", "--is-inside-work-tree"])
.output();
match output {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
bail!(
"not inside a git work tree\n \
hint: --diff requires a git repository\n \
git: {stderr}",
);
}
Err(e) => bail!(
"not inside a git work tree\n \
hint: --diff requires git to be installed\n \
error: {e}",
),
}
}
fn preflight_checks(coverage: &std::path::Path, src: &std::path::Path) -> Result<()> {
check_coverage_has_data(coverage)?;
check_src_has_rust_files(src)?;
Ok(())
}
fn check_coverage_has_data(path: &std::path::Path) -> Result<()> {
use std::io::{BufRead, BufReader};
let file = std::fs::File::open(path)?;
let reader = BufReader::new(file);
let mut in_sf_block = false;
for line in reader.lines() {
let line = line?;
if line.starts_with("SF:") {
in_sf_block = true;
continue;
}
if in_sf_block
&& let Some(rest) = line.strip_prefix("DA:")
&& let Some((line_no, hits)) = rest.split_once(',')
&& line_no.parse::<usize>().is_ok()
&& hits.split(',').next().unwrap_or("").parse::<u64>().is_ok()
{
return Ok(());
}
}
bail!(
"no coverage data found in {}\n \
hint: ensure tests ran with coverage enabled (`cargo llvm-cov --lcov`)",
path.display()
);
}
fn check_src_has_rust_files(path: &std::path::Path) -> Result<()> {
fn has_rs_files(dir: &std::path::Path) -> std::io::Result<bool> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_file() && entry.path().extension().is_some_and(|ext| ext == "rs") {
return Ok(true);
}
if ft.is_dir() && has_rs_files(&entry.path())? {
return Ok(true);
}
}
Ok(false)
}
if !has_rs_files(path)? {
bail!(
"no Rust source files found in {}\n \
hint: check that --src points to a directory containing .rs files",
path.display()
);
}
Ok(())
}
fn now_unix_epoch() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{secs}")
}
fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
}
fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
if !diag.parse_diagnostics.is_empty() {
eprintln!(
"warning: {} LCOV parse issue(s) encountered (use --verbose for details)",
diag.parse_diagnostics.len()
);
}
if diag.files_unparseable > 0 {
eprintln!(
"warning: {} source file(s) could not be parsed (use --verbose for details)",
diag.files_unparseable
);
}
if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
eprintln!(
"warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
diag.files_zero_coverage, diag.files_analyzed
);
eprintln!(
" hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
);
eprintln!(
" hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
);
}
}
fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
eprintln!(
"verbose: file discovery: {} files found, {} unparseable",
diag.files_found, diag.files_unparseable
);
eprintln!(
"verbose: complexity: {} functions extracted",
diag.functions_extracted
);
eprintln!(
"verbose: matching: {} matched with coverage, {} without coverage data",
diag.functions_matched, diag.functions_no_coverage
);
eprintln!(
"verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
diag.files_analyzed, diag.files_zero_coverage
);
if !diag.parse_diagnostics.is_empty() {
eprintln!(
"verbose: LCOV parse diagnostics ({}):",
diag.parse_diagnostics.len()
);
for d in &diag.parse_diagnostics {
eprintln!(" {d}");
}
}
}
fn emit_completions(shell: ShellArg, bin_name: &str) {
let mut cmd = Cli::command();
let stdout = &mut std::io::stdout();
match shell {
ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
ShellArg::Powershell => {
clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
}
ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
ShellArg::Nushell => {
clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
}
}
}
fn apply_color(choice: ColorArg) {
match choice {
ColorArg::Auto => colored::control::unset_override(),
ColorArg::Always => colored::control::set_override(true),
ColorArg::Never => colored::control::set_override(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
let mut full = vec!["crap4rs"];
full.extend_from_slice(args);
Cli::try_parse_from(full)
}
#[test]
fn no_args_parses_with_coverage_none() {
let cli = parse(&[]).unwrap();
assert!(cli.input.coverage.is_none());
assert!(cli.command.is_none());
}
#[test]
fn minimal_valid_args() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
assert_eq!(cli.input.src, None);
}
#[test]
fn completions_subcommand_does_not_require_coverage() {
let cli = parse(&["completions", "bash"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Completions {
shell: ShellArg::Bash
})
));
assert!(cli.input.coverage.is_none());
}
#[test]
fn default_metric_is_none() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(cli.input.metric.is_none());
}
#[test]
fn default_format_is_table() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert_eq!(cli.output.format.len(), 1);
assert!(matches!(cli.output.format[0].format, FormatArg::Table));
assert!(cli.output.format[0].output.is_none());
}
#[test]
fn default_threshold_is_none() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(cli.output.threshold.is_none());
}
#[test]
fn default_color_is_auto() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(matches!(cli.display.color, ColorArg::Auto));
}
#[test]
fn metric_cyclomatic() {
let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
}
#[test]
fn format_json() {
let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
assert_eq!(cli.output.format.len(), 1);
assert!(matches!(cli.output.format[0].format, FormatArg::Json));
assert!(cli.output.format[0].output.is_none());
}
#[test]
fn format_sarif() {
let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
assert_eq!(cli.output.format.len(), 1);
assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
}
#[test]
fn format_with_file_destination() {
let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
assert_eq!(cli.output.format.len(), 1);
assert!(matches!(cli.output.format[0].format, FormatArg::Json));
assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
}
#[test]
fn format_multi_with_files() {
let cli = parse(&[
"--coverage",
"lcov.info",
"--format",
"json:env.json,markdown:report.md",
])
.unwrap();
assert_eq!(cli.output.format.len(), 2);
assert!(matches!(cli.output.format[0].format, FormatArg::Json));
assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
assert_eq!(
cli.output.format[1].output,
Some(PathBuf::from("report.md"))
);
}
#[test]
fn format_multi_without_files_rejected() {
let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
let err = validate_display_flags(&cli).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("multi-format"));
assert!(msg.contains("file"));
}
#[test]
fn format_empty_path_rejected() {
let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("empty file path"));
}
#[test]
fn custom_threshold() {
let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
assert_eq!(cli.output.threshold, Some(15.5));
}
#[test]
fn custom_src() {
let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
}
#[test]
fn exclude_repeatable() {
let cli = parse(&[
"--coverage",
"lcov.info",
"--exclude",
"tests/**",
"--exclude",
"benches/**",
])
.unwrap();
assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
}
#[test]
fn no_gitignore_flag() {
let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
assert!(cli.filter.no_gitignore);
}
#[test]
fn only_failing_flag() {
let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
assert!(cli.filter.only_failing);
}
#[test]
fn group_by_file_parses() {
let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
}
#[test]
fn group_by_absence_is_none() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(cli.filter.group_by.is_none());
}
#[test]
fn group_by_invalid_value_rejected() {
let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid value"), "expected clap error: {msg}");
assert!(
msg.contains("--group-by") || msg.contains("module"),
"error should attribute to --group-by: {msg}"
);
}
#[test]
fn group_by_arg_to_domain_file() {
let domain: GroupKey = GroupByArg::File.into();
assert_eq!(domain, GroupKey::File);
}
#[test]
fn verbose_flag() {
let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
assert!(cli.display.verbose);
}
#[test]
fn quiet_flag() {
let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
assert!(cli.display.quiet);
}
#[test]
fn color_always() {
let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
assert!(matches!(cli.display.color, ColorArg::Always));
}
#[test]
fn color_never() {
let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
assert!(matches!(cli.display.color, ColorArg::Never));
}
#[test]
fn invalid_metric_rejected() {
let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
assert!(err.to_string().contains("invalid value"));
}
#[test]
fn invalid_format_rejected() {
let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
assert!(err.to_string().contains("invalid value"));
}
#[test]
fn metric_arg_to_domain_cognitive() {
let domain: ComplexityMetric = MetricArg::Cognitive.into();
assert_eq!(domain, ComplexityMetric::Cognitive);
}
#[test]
fn metric_arg_to_domain_cyclomatic() {
let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
assert_eq!(domain, ComplexityMetric::Cyclomatic);
}
#[test]
fn validate_missing_coverage_file() {
let err = validate_inputs(
Path::new("nonexistent.info"),
Path::new("src"),
DEFAULT_THRESHOLD,
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("coverage file not found"));
assert!(msg.contains("cargo llvm-cov"));
}
#[test]
fn validate_missing_src_dir() {
let err = validate_inputs(
Path::new("Cargo.toml"),
Path::new("nonexistent_dir"),
DEFAULT_THRESHOLD,
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("source directory not found"));
}
#[test]
fn validate_negative_threshold() {
let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("threshold must be a finite positive number"));
}
#[test]
fn validate_zero_threshold() {
let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("threshold must be a finite positive number"));
}
#[test]
fn validate_infinity_threshold() {
let err =
validate_inputs(Path::new("Cargo.toml"), Path::new("src"), f64::INFINITY).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("threshold must be a finite positive number"));
}
#[test]
fn validate_src_is_file_not_dir() {
let err = validate_inputs(
Path::new("Cargo.toml"),
Path::new("Cargo.toml"),
DEFAULT_THRESHOLD,
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("source path is not a directory"));
}
#[test]
fn validate_coverage_is_dir_not_file() {
let err =
validate_inputs(Path::new("src"), Path::new("src"), DEFAULT_THRESHOLD).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("coverage path is not a file"));
}
#[test]
fn format_short_flag() {
let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
assert!(matches!(cli.output.format[0].format, FormatArg::Json));
}
#[test]
fn config_flag_accepts_path() {
let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
}
#[test]
fn config_flag_defaults_to_none() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert_eq!(cli.input.config, None);
}
#[test]
fn view_flag_accepts_name() {
let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
assert_eq!(cli.input.view, Some("ci".to_string()));
}
#[test]
fn view_flag_defaults_to_none() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert_eq!(cli.input.view, None);
}
#[test]
fn merge_threshold_cli_overrides_config() {
let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
let file_config = Some(FileConfig {
threshold: Some(10.0),
..FileConfig::default()
});
let (config, display) = merge_threshold(&cli, &file_config);
assert_eq!(config.global, 15.0);
assert_eq!(display, 15.0);
}
#[test]
fn merge_threshold_uses_config_when_cli_default() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let file_config = Some(FileConfig {
threshold: Some(12.0),
..FileConfig::default()
});
let (config, display) = merge_threshold(&cli, &file_config);
assert_eq!(config.global, 12.0);
assert_eq!(display, 12.0);
}
#[test]
fn merge_threshold_preserves_overrides() {
use crate::domain::threshold::ThresholdOverride;
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let file_config = Some(FileConfig {
threshold: Some(10.0),
overrides: vec![ThresholdOverride {
pattern: "domain/**".to_string(),
threshold: 5.0,
}],
..FileConfig::default()
});
let (config, _) = merge_threshold(&cli, &file_config);
assert_eq!(config.overrides.len(), 1);
assert_eq!(config.overrides[0].pattern, "domain/**");
}
#[test]
fn merge_threshold_no_config() {
let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
let (config, display) = merge_threshold(&cli, &None);
assert_eq!(config.global, 20.0);
assert!(config.overrides.is_empty());
assert_eq!(display, 20.0);
}
#[test]
fn merge_threshold_explicit_default_overrides_config() {
let cli = parse(&["--coverage", "lcov.info", "--threshold", "8.0"]).unwrap();
let file_config = Some(FileConfig {
threshold: Some(12.0),
..FileConfig::default()
});
let (config, display) = merge_threshold(&cli, &file_config);
assert_eq!(
config.global, 8.0,
"explicit CLI default must override config"
);
assert_eq!(display, 8.0);
}
#[test]
fn merge_threshold_no_cli_no_config_uses_hardcoded_default() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let (config, display) = merge_threshold(&cli, &None);
assert_eq!(config.global, DEFAULT_THRESHOLD);
assert_eq!(display, DEFAULT_THRESHOLD);
}
#[test]
fn merge_exclude_combines_cli_and_config() {
let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
let file_config = Some(FileConfig {
exclude: Some(vec!["benches/**".to_string()]),
..FileConfig::default()
});
let exclude = merge_exclude(&cli, &file_config);
assert_eq!(exclude, vec!["tests/**", "benches/**"]);
}
#[test]
fn merge_exclude_deduplicates() {
let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
let file_config = Some(FileConfig {
exclude: Some(vec!["tests/**".to_string()]),
..FileConfig::default()
});
let exclude = merge_exclude(&cli, &file_config);
assert_eq!(exclude, vec!["tests/**"]);
}
#[test]
fn diff_flag_accepts_ref() {
let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
assert_eq!(cli.filter.diff, Some("main".to_string()));
}
#[test]
fn diff_flag_defaults_to_none() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert_eq!(cli.filter.diff, None);
}
#[test]
fn diff_flag_accepts_commit_sha() {
let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
assert_eq!(cli.filter.diff, Some("abc123".to_string()));
}
#[test]
fn diff_flag_accepts_head_tilde() {
let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
}
#[test]
fn validate_diff_ref_rejects_empty_string() {
let err = validate_diff_ref("").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("must not be empty"));
}
#[test]
fn validate_diff_ref_rejects_dash_prefix() {
let err = validate_diff_ref("--malicious").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("invalid diff ref"));
assert!(msg.contains("must not start with a dash"));
}
#[test]
fn validate_diff_ref_accepts_normal_ref() {
assert!(validate_diff_ref("main").is_ok());
assert!(validate_diff_ref("HEAD~1").is_ok());
assert!(validate_diff_ref("abc123").is_ok());
}
#[test]
fn preflight_git_worktree_passes_in_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let status = std::process::Command::new("git")
.arg("init")
.arg("--quiet")
.current_dir(tmp.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
assert!(preflight_git_worktree(tmp.path()).is_ok());
}
#[test]
fn breakdown_flag_parsed() {
let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
assert!(cli.display.breakdown);
}
#[test]
fn breakdown_flag_default_false() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(!cli.display.breakdown);
}
#[test]
fn explain_flag_parsed() {
let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
assert!(cli.display.explain);
}
#[test]
fn explain_flag_default_false() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(!cli.display.explain);
}
#[test]
fn explain_requires_breakdown_for_table_output() {
let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
let err = validate_display_flags(&cli).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("--breakdown"));
assert!(msg.contains("--explain"));
}
#[test]
fn explain_allowed_for_json_output() {
let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
assert!(validate_display_flags(&cli).is_ok());
}
#[test]
fn color_overrides_set_global_state() {
apply_color(ColorArg::Never);
assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
apply_color(ColorArg::Always);
assert!(colored::control::SHOULD_COLORIZE.should_colorize());
apply_color(ColorArg::Auto);
}
#[test]
fn preflight_empty_coverage_file() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("empty.info");
std::fs::write(&cov, "").unwrap();
let err = check_coverage_has_data(&cov).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no coverage data found"));
assert!(msg.contains("cargo llvm-cov"));
}
#[test]
fn preflight_coverage_no_da_lines() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("no_da.info");
std::fs::write(&cov, "SF:src/main.rs\nend_of_record\n").unwrap();
let err = check_coverage_has_data(&cov).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no coverage data found"));
}
#[test]
fn preflight_coverage_with_da_lines_passes() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("good.info");
std::fs::write(&cov, "SF:src/main.rs\nDA:1,5\nend_of_record\n").unwrap();
assert!(check_coverage_has_data(&cov).is_ok());
}
#[test]
fn preflight_coverage_da_outside_sf_block_rejected() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("orphan_da.info");
std::fs::write(&cov, "DA:1,5\nend_of_record\n").unwrap();
let err = check_coverage_has_data(&cov).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no coverage data found"));
}
#[test]
fn preflight_coverage_malformed_da_rejected() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("bad_da.info");
std::fs::write(&cov, "SF:src/main.rs\nDA:not_a_number\nend_of_record\n").unwrap();
let err = check_coverage_has_data(&cov).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no coverage data found"));
}
#[test]
fn preflight_src_dir_no_rust_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
let err = check_src_has_rust_files(dir.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no Rust source files found"));
}
#[test]
fn preflight_src_dir_empty() {
let dir = tempfile::tempdir().unwrap();
let err = check_src_has_rust_files(dir.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no Rust source files found"));
}
#[test]
fn preflight_src_dir_with_rs_files_passes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
assert!(check_src_has_rust_files(dir.path()).is_ok());
}
#[test]
fn preflight_src_dir_nested_rs_files_passes() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("sub");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("lib.rs"), "pub fn foo() {}").unwrap();
assert!(check_src_has_rust_files(dir.path()).is_ok());
}
#[test]
fn strict_flag_parses() {
let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
assert!(cli.output.strict);
}
#[test]
fn lenient_flag_parses() {
let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
assert!(cli.output.lenient);
}
#[test]
fn strict_and_threshold_mutually_exclusive() {
parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
}
#[test]
fn strict_and_lenient_mutually_exclusive() {
parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
}
#[test]
fn merge_threshold_strict_flag() {
use crate::domain::threshold::STRICT_THRESHOLD;
let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
let (config, display) = merge_threshold(&cli, &None);
assert_eq!(config.global, STRICT_THRESHOLD);
assert_eq!(display, STRICT_THRESHOLD);
}
#[test]
fn merge_threshold_lenient_flag() {
use crate::domain::threshold::LENIENT_THRESHOLD;
let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
let (config, display) = merge_threshold(&cli, &None);
assert_eq!(config.global, LENIENT_THRESHOLD);
assert_eq!(display, LENIENT_THRESHOLD);
}
#[test]
fn merge_threshold_toml_preset_used_when_no_cli_flag() {
use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let file_config = Some(FileConfig {
preset: Some(ThresholdPreset::Strict),
..FileConfig::default()
});
let (config, _) = merge_threshold(&cli, &file_config);
assert_eq!(config.global, STRICT_THRESHOLD);
}
#[test]
fn merge_threshold_cli_threshold_overrides_toml_preset() {
use crate::domain::threshold::ThresholdPreset;
let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
let file_config = Some(FileConfig {
preset: Some(ThresholdPreset::Strict),
..FileConfig::default()
});
let (config, _) = merge_threshold(&cli, &file_config);
assert_eq!(config.global, 50.0);
}
#[test]
fn zero_coverage_warn_triggers_above_50_percent() {
assert!(majority_zero_coverage(10, 6));
assert!(majority_zero_coverage(1, 1));
assert!(majority_zero_coverage(3, 2));
}
#[test]
fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
assert!(!majority_zero_coverage(10, 5));
assert!(!majority_zero_coverage(2, 1));
}
#[test]
fn zero_coverage_warn_does_not_trigger_when_no_files() {
assert!(!majority_zero_coverage(0, 0));
}
#[test]
fn merge_effective_inputs_default_src() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let inputs = merge_effective_inputs(&cli, &None);
assert_eq!(inputs.src, PathBuf::from("src"));
}
#[test]
fn merge_effective_inputs_cli_src_wins_over_config() {
let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
let file_config = Some(FileConfig {
src: Some(PathBuf::from("from-config/")),
..FileConfig::default()
});
let inputs = merge_effective_inputs(&cli, &file_config);
assert_eq!(inputs.src, PathBuf::from("crates/"));
}
#[test]
fn merge_effective_inputs_config_src_when_cli_absent() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let file_config = Some(FileConfig {
src: Some(PathBuf::from("from-config/")),
..FileConfig::default()
});
let inputs = merge_effective_inputs(&cli, &file_config);
assert_eq!(inputs.src, PathBuf::from("from-config/"));
}
#[test]
fn merge_effective_inputs_default_metric_is_cognitive() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let inputs = merge_effective_inputs(&cli, &None);
assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
}
#[test]
fn merge_effective_inputs_cli_metric_overrides_config() {
let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
let file_config = Some(FileConfig {
metric: Some(ComplexityMetric::Cognitive),
..FileConfig::default()
});
let inputs = merge_effective_inputs(&cli, &file_config);
assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
}
#[test]
fn merge_effective_inputs_threshold_default() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let inputs = merge_effective_inputs(&cli, &None);
assert_eq!(inputs.threshold, DEFAULT_THRESHOLD);
}
#[test]
fn merge_effective_inputs_exclude_combines_cli_and_config() {
let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
let file_config = Some(FileConfig {
exclude: Some(vec!["benches/**".to_string()]),
..FileConfig::default()
});
let inputs = merge_effective_inputs(&cli, &file_config);
assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
}
#[test]
fn compute_exit_code_passing_no_delta() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(compute_exit_code::<
crate::test_strategies::DummyParseDiagnostic,
>(&cli, true, None));
}
#[test]
fn compute_exit_code_failing_no_delta() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
assert!(!compute_exit_code::<
crate::test_strategies::DummyParseDiagnostic,
>(&cli, false, None));
}
#[test]
fn compute_exit_code_no_fail_overrides_failure() {
let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
assert!(compute_exit_code::<
crate::test_strategies::DummyParseDiagnostic,
>(&cli, false, None));
}
#[test]
fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
let cli = parse(&[
"--coverage",
"lcov.info",
"--delta-gate",
"--baseline",
"/dev/null",
])
.unwrap();
assert!(compute_exit_code::<
crate::test_strategies::DummyParseDiagnostic,
>(&cli, true, None));
}
#[test]
fn compute_exit_code_no_fail_with_delta_gate() {
let cli = parse(&[
"--coverage",
"lcov.info",
"--delta-gate",
"--baseline",
"/dev/null",
"--no-fail",
])
.unwrap();
assert!(compute_exit_code::<
crate::test_strategies::DummyParseDiagnostic,
>(&cli, false, None));
}
}