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::{ThresholdConfig, ThresholdPreset, 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 init;
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,
GithubAnnotations,
}
#[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,
},
Init {
#[arg(long)]
force: bool,
#[arg(long)]
non_interactive: bool,
},
}
#[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,
#[arg(long)]
pub summary: bool,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))]
pub annotation_limit: Option<u32>,
}
#[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",
long_about = "CRAP (Change Risk Anti-Patterns) score analyzer. \
Combines complexity analysis with line-coverage data to \
identify functions that are both complex and under-tested. \
Adapter-specific binaries (crap4rs for Rust, crap4ts for \
TypeScript) wire language-specific complexity walkers and \
coverage parsers behind the same orchestrator."
)]
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>,
}
#[derive(Debug, Clone, Copy)]
pub struct AdapterMeta {
pub tool_name: &'static str,
pub tool_version: &'static str,
pub long_version: &'static str,
pub about: &'static str,
pub long_about: &'static str,
pub after_help: &'static str,
pub coverage_hint: &'static str,
pub extensions: &'static [&'static str],
pub tool_info_uri: &'static str,
pub rule_help_uri: &'static str,
pub config_file_name: &'static str,
pub default_excludes: &'static [&'static str],
pub forced_excludes: &'static [&'static str],
pub default_metric: ComplexityMetric,
}
impl AdapterMeta {
pub fn extensions_owned(&self) -> Vec<String> {
self.extensions.iter().map(|e| (*e).to_string()).collect()
}
pub(crate) fn debug_assert_required_fields(&self) {
debug_assert!(
!self.tool_name.is_empty(),
"AdapterMeta.tool_name must not be empty"
);
debug_assert!(
!self.tool_version.is_empty(),
"AdapterMeta.tool_version must not be empty"
);
debug_assert!(
!self.long_version.is_empty(),
"AdapterMeta.long_version must not be empty"
);
debug_assert!(
!self.about.is_empty(),
"AdapterMeta.about must not be empty"
);
debug_assert!(
!self.long_about.is_empty(),
"AdapterMeta.long_about must not be empty"
);
debug_assert!(
!self.coverage_hint.is_empty(),
"AdapterMeta.coverage_hint must not be empty"
);
debug_assert!(
!self.tool_info_uri.is_empty(),
"AdapterMeta.tool_info_uri must not be empty"
);
debug_assert!(
!self.rule_help_uri.is_empty(),
"AdapterMeta.rule_help_uri must not be empty"
);
debug_assert!(
!self.config_file_name.is_empty(),
"AdapterMeta.config_file_name must not be empty"
);
}
}
pub fn parse_args(meta: &AdapterMeta) -> Cli {
meta.debug_assert_required_fields();
let cmd = build_command(meta);
let matches = cmd.get_matches();
Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
}
fn current_bin_name(meta_fallback: &str) -> 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(|| meta_fallback.to_string())
}
fn build_command(meta: &AdapterMeta) -> clap::Command {
let bin_name = current_bin_name(meta.tool_name);
let mut cmd = Cli::command()
.name(bin_name.clone())
.bin_name(bin_name)
.version(meta.tool_version)
.long_version(meta.long_version)
.about(meta.about)
.long_about(meta.long_about);
if !meta.after_help.is_empty() {
cmd = cmd.after_help(meta.after_help);
}
cmd = cmd.mut_arg("metric", |arg| {
arg.help(format!(
"Complexity metric to use [default: {}]",
meta.default_metric
))
});
cmd
}
pub fn run<P, F>(
cli: Cli,
complexity: &dyn ComplexityPort,
coverage_factory: F,
meta: &AdapterMeta,
) -> ExitCode
where
P: ParseDiagnostic + std::fmt::Display + 'static,
F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
{
match run_inner(cli, complexity, coverage_factory, meta) {
Ok(true) => ExitCode::from(0),
Ok(false) => ExitCode::from(1),
Err(e) => {
render_error(&e, meta);
ExitCode::from(2)
}
}
}
fn render_error(err: &anyhow::Error, meta: &AdapterMeta) {
if let Some(crap_err) = err.downcast_ref::<crate::domain::types::CrapError>()
&& let crate::domain::types::CrapError::MetricNotSupported { metric } = crap_err
{
eprintln!(
"{}: complexity metric `{}` is not yet supported. Use `--metric {}` (the default for {}) or track support at {}.",
meta.tool_name, metric, meta.default_metric, meta.tool_name, meta.tool_info_uri,
);
return;
}
eprintln!("error: {err:#}");
}
fn run_inner<P, F>(
mut cli: Cli,
complexity: &dyn ComplexityPort,
coverage_factory: F,
meta: &AdapterMeta,
) -> Result<bool>
where
P: ParseDiagnostic + std::fmt::Display + 'static,
F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
{
match cli.command {
Some(Command::Completions { shell }) => {
emit_completions(shell, ¤t_bin_name(meta.tool_name));
return Ok(true);
}
Some(Command::Init {
force,
non_interactive,
}) => {
init::handle_init(force, non_interactive, meta)?;
return Ok(true);
}
None => {}
}
let prep = prepare_pipeline(&mut cli, complexity, coverage_factory, meta)?;
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,
meta,
)?;
}
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>,
annotation_limit: usize,
}
struct PipelinePrep<P: ParseDiagnostic> {
inputs: EffectiveInputs,
analysis: AnalysisOutput<P>,
delta_state: Option<DeltaState<P>>,
}
fn merge_effective_inputs(
cli: &Cli,
file_config: &Option<FileConfig>,
meta: &AdapterMeta,
) -> 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(meta.default_metric);
let (threshold_config, threshold) = merge_threshold(cli, file_config, metric);
let exclude = merge_exclude(cli, file_config, meta);
let annotation_limit = cli
.output
.annotation_limit
.or_else(|| file_config.as_ref().and_then(|c| c.output.annotation_limit))
.unwrap_or(10) as usize;
EffectiveInputs {
src,
metric,
threshold_config,
threshold,
exclude,
annotation_limit,
}
}
fn validate_runtime_inputs<'a>(
cli: &'a Cli,
inputs: &EffectiveInputs,
meta: &AdapterMeta,
) -> Result<&'a Path> {
let Some(coverage_path) = cli.input.coverage.as_deref() else {
bail!(
"--coverage <FILE> is required (run `{name} --help` for usage, or `{name} completions <SHELL>` for shell completion scripts)",
name = meta.tool_name,
);
};
validate_inputs(
coverage_path,
&inputs.src,
inputs.threshold,
meta.coverage_hint,
)?;
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,
meta: &AdapterMeta,
) -> 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(),
extensions: meta.extensions_owned(),
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, F>(
cli: &mut Cli,
complexity: &dyn ComplexityPort,
coverage_factory: F,
meta: &AdapterMeta,
) -> Result<PipelinePrep<P>>
where
P: ParseDiagnostic + std::fmt::Display + 'static,
F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
{
validate_display_flags(cli)?;
apply_color(cli.display.color);
let (file_config, config_path) = load_file_config(cli, meta.config_file_name)?.unzip();
view_args::resolve_view_preset(
cli,
file_config.as_ref(),
config_path.as_deref(),
meta.config_file_name,
)?;
view_args::validate_view_args(cli)?;
let inputs = merge_effective_inputs(cli, &file_config, meta);
let coverage_path = validate_runtime_inputs(cli, &inputs, meta)?;
let src_canonical = crate::core::canonicalize_src(&inputs.src);
let coverage = coverage_factory(&src_canonical);
preflight_checks(coverage_path, &*coverage, meta)?;
let options = build_analyze_options(cli, &inputs, coverage_path, meta);
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,
meta: &AdapterMeta,
) -> 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: meta.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,
meta: &AdapterMeta,
) -> Result<String> {
Ok(match spec.format {
FormatArg::Table => reporters::format_table_with_explain(
view,
delta_view,
inputs.threshold,
cli.display.breakdown,
cli.display.explain,
meta.tool_name,
meta.tool_version,
),
FormatArg::Json | FormatArg::Advice => {
format_as_json(cli, view, delta_view, delta_state, analysis, inputs, meta)?
}
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,
meta.tool_name,
meta.tool_version,
),
FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
FormatArg::Sarif => reporters::format_sarif(
view,
meta.tool_name,
meta.tool_version,
meta.tool_info_uri,
meta.rule_help_uri,
),
FormatArg::ScorecardRow => {
format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
}
FormatArg::Html => {
reporters::format_html(view, inputs.threshold, meta.tool_name, meta.tool_version)
}
FormatArg::GithubAnnotations => reporters::format_github_annotations(
view,
meta.tool_name,
meta.tool_version,
inputs.annotation_limit,
),
})
}
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,
meta: &AdapterMeta,
) -> Result<()> {
if cli.output.summary {
let line = reporters::format_summary_line(view.full, inputs.threshold);
println!("{line}");
return Ok(());
}
for spec in &cli.output.format {
let output = render_format(
cli,
spec,
view,
delta_view,
delta_state,
analysis,
inputs,
meta,
)?;
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.len() > 1 {
bail!(
"multi-format `--format` allows at most one stdout entry (the rest must specify a file, e.g. `json:envelope.json`); stdout 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,
config_file_name: &str,
) -> Result<Option<(FileConfig, std::path::PathBuf)>> {
if let Some(path) = &cli.input.config {
let cfg = config::load_config(path)?;
Ok(Some((cfg, path.clone())))
} else {
match config::discover_config(config_file_name)? {
Some(path) => {
let cfg = config::load_config(&path)?;
Ok(Some((cfg, path)))
}
None => Ok(None),
}
}
}
fn merge_threshold(
cli: &Cli,
file_config: &Option<FileConfig>,
metric: ComplexityMetric,
) -> (ThresholdConfig, f64) {
let global = cli
.output
.threshold
.or_else(|| {
cli.output
.strict
.then(|| ThresholdPreset::Strict.threshold(metric))
})
.or_else(|| {
cli.output
.lenient
.then(|| ThresholdPreset::Lenient.threshold(metric))
})
.or_else(|| file_config.as_ref().and_then(|c| c.threshold))
.or_else(|| {
file_config
.as_ref()
.and_then(|c| c.preset)
.map(|p| p.threshold(metric))
})
.unwrap_or(ThresholdPreset::Default.threshold(metric));
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>, meta: &AdapterMeta) -> Vec<String> {
let mut exclude: Vec<String> = meta
.forced_excludes
.iter()
.map(|s| (*s).to_string())
.collect();
let mut seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
for pattern in &cli.filter.exclude {
if seen.insert(pattern.clone()) {
exclude.push(pattern.clone());
}
}
if let Some(fc) = file_config
&& let Some(fc_exclude) = &fc.exclude
{
for pattern in fc_exclude {
if seen.insert(pattern.clone()) {
exclude.push(pattern.clone());
}
}
}
exclude
}
fn validate_inputs(
coverage: &std::path::Path,
src: &std::path::Path,
threshold: f64,
coverage_hint: &str,
) -> 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 a coverage file, not a directory",
coverage.display()
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
"coverage file not found: {}\n hint: {coverage_hint}",
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 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 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<P>(
coverage: &std::path::Path,
coverage_port: &dyn CoveragePort<Diagnostic = P>,
meta: &AdapterMeta,
) -> Result<()>
where
P: ParseDiagnostic,
{
check_coverage_has_data(coverage, coverage_port, meta.coverage_hint)
}
fn check_coverage_has_data<P>(
path: &std::path::Path,
coverage_port: &dyn CoveragePort<Diagnostic = P>,
coverage_hint: &str,
) -> Result<()>
where
P: ParseDiagnostic,
{
if let Err(reason) = coverage_port.validate(path) {
bail!(
"no coverage data found in {} ({reason})\n hint: {}",
path.display(),
coverage_hint,
);
}
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: {} coverage 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: coverage 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 crate::domain::threshold::DEFAULT_THRESHOLD;
use std::path::Path;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
let mut full = vec!["test-adapter"];
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_with_two_stdout_specs_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"), "got: {msg}");
assert!(msg.contains("stdout"), "got: {msg}");
assert!(
msg.contains("json"),
"msg should name the stdout specs: {msg}"
);
assert!(
msg.contains("markdown"),
"msg should name the stdout specs: {msg}"
);
}
#[test]
fn format_multi_with_single_stdout_plus_file_accepted() {
let cli = parse(&[
"--coverage",
"lcov.info",
"--format",
"markdown:scorecard.md,github-annotations",
])
.unwrap();
assert!(validate_display_flags(&cli).is_ok());
}
#[test]
fn format_multi_with_three_stdout_specs_rejected() {
let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown,csv"]).unwrap();
let err = validate_display_flags(&cli).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("at most one stdout"),
"rejection must name the rule, got: {msg}"
);
}
#[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_uses_adapter_hint() {
let err = validate_inputs(
Path::new("nonexistent.info"),
Path::new("src"),
DEFAULT_THRESHOLD,
"run `cargo llvm-cov --lcov --output-path lcov.info` first",
)
.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,
"test-hint",
)
.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, "test-hint")
.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, "test-hint")
.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,
"test-hint",
)
.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,
"test-hint",
)
.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,
"test-hint",
)
.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, ComplexityMetric::Cognitive);
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, ComplexityMetric::Cognitive);
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, ComplexityMetric::Cognitive);
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, ComplexityMetric::Cognitive);
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", "15.0"]).unwrap();
let file_config = Some(FileConfig {
threshold: Some(12.0),
..FileConfig::default()
});
let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
assert_eq!(
config.global, 15.0,
"explicit CLI default must override config"
);
assert_eq!(display, 15.0);
}
#[test]
fn merge_threshold_no_flag_default_is_metric_keyed() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let (cog, cog_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
assert_eq!(cog.global, 15.0);
assert_eq!(cog_disp, 15.0);
let (cyc, cyc_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cyclomatic);
assert_eq!(cyc.global, 15.0);
assert_eq!(cyc_disp, 15.0);
}
#[test]
fn merge_threshold_strict_lenient_are_metric_keyed() {
let strict = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
assert_eq!(
merge_threshold(&strict, &None, ComplexityMetric::Cognitive).1,
8.0
);
assert_eq!(
merge_threshold(&strict, &None, ComplexityMetric::Cyclomatic).1,
8.0
);
let lenient = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
assert_eq!(
merge_threshold(&lenient, &None, ComplexityMetric::Cognitive).1,
25.0
);
assert_eq!(
merge_threshold(&lenient, &None, ComplexityMetric::Cyclomatic).1,
25.0
);
}
#[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, &fake_meta());
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, &fake_meta());
assert_eq!(exclude, vec!["tests/**"]);
}
#[test]
fn merge_exclude_prepends_forced_excludes_from_adapter_meta() {
let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
let file_config = Some(FileConfig {
exclude: Some(vec!["benches/**".to_string()]),
..FileConfig::default()
});
let meta = AdapterMeta {
forced_excludes: &["**/*.d.ts"],
..fake_meta()
};
let exclude = merge_exclude(&cli, &file_config, &meta);
assert_eq!(exclude, vec!["**/*.d.ts", "tests/**", "benches/**"]);
}
#[test]
fn merge_exclude_with_empty_forced_excludes_matches_legacy_behavior() {
let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
let file_config = Some(FileConfig {
exclude: Some(vec!["benches/**".to_string()]),
..FileConfig::default()
});
let meta = AdapterMeta {
forced_excludes: &[],
..fake_meta()
};
let exclude = merge_exclude(&cli, &file_config, &meta);
assert_eq!(exclude, vec!["tests/**", "benches/**"]);
}
#[test]
fn merge_exclude_forced_excludes_deduplicates_against_cli_and_config() {
let cli = parse(&["--coverage", "lcov.info", "--exclude", "**/*.d.ts"]).unwrap();
let file_config = Some(FileConfig {
exclude: Some(vec!["**/*.d.ts".to_string(), "benches/**".to_string()]),
..FileConfig::default()
});
let meta = AdapterMeta {
forced_excludes: &["**/*.d.ts"],
..fake_meta()
};
let exclude = merge_exclude(&cli, &file_config, &meta);
assert_eq!(exclude, vec!["**/*.d.ts", "benches/**"]);
}
#[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);
}
const TEST_COVERAGE_HINT: &str =
"ensure tests ran with coverage enabled (test-tool's `--coverage` flag)";
struct StubCoveragePort {
validate_result: Result<(), String>,
}
impl CoveragePort for StubCoveragePort {
type Diagnostic = crate::test_strategies::DummyParseDiagnostic;
fn parse(
&self,
_path: &std::path::Path,
) -> Result<crate::ports::ParseOutput<Self::Diagnostic>, crate::domain::types::CrapError>
{
unreachable!("preflight tests never invoke parse")
}
fn validate(&self, _path: &std::path::Path) -> Result<(), String> {
self.validate_result.clone()
}
}
fn stub_ok() -> StubCoveragePort {
StubCoveragePort {
validate_result: Ok(()),
}
}
fn stub_err(reason: &str) -> StubCoveragePort {
StubCoveragePort {
validate_result: Err(reason.to_string()),
}
}
#[test]
fn preflight_surfaces_hint_when_adapter_reports_no_data() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("empty.info");
std::fs::write(&cov, "").unwrap();
let err =
check_coverage_has_data(&cov, &stub_err("no records"), TEST_COVERAGE_HINT).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no coverage data found"));
assert!(msg.contains("no records"), "expected reason in msg: {msg}");
assert!(msg.contains(TEST_COVERAGE_HINT));
}
#[test]
fn preflight_passes_when_adapter_accepts_data() {
let dir = tempfile::tempdir().unwrap();
let cov = dir.path().join("ok.info");
std::fs::write(&cov, "any contents — adapter decides").unwrap();
assert!(check_coverage_has_data(&cov, &stub_ok(), TEST_COVERAGE_HINT).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, ComplexityMetric::Cognitive);
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, ComplexityMetric::Cognitive);
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, ComplexityMetric::Cognitive);
assert_eq!(config.global, STRICT_THRESHOLD);
}
#[test]
fn merge_threshold_config_literal_overrides_config_preset() {
use crate::domain::threshold::ThresholdPreset;
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let file_config = Some(FileConfig {
preset: Some(ThresholdPreset::Strict),
threshold: Some(99.0),
..FileConfig::default()
});
let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
assert_eq!(config.global, 99.0);
assert_eq!(display, 99.0);
}
#[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, ComplexityMetric::Cognitive);
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, &fake_meta());
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, &fake_meta());
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, &fake_meta());
assert_eq!(inputs.src, PathBuf::from("from-config/"));
}
#[test]
fn merge_effective_inputs_uses_adapter_default_metric_cognitive() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let meta = AdapterMeta {
default_metric: ComplexityMetric::Cognitive,
..fake_meta()
};
let inputs = merge_effective_inputs(&cli, &None, &meta);
assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
}
#[test]
fn merge_effective_inputs_uses_adapter_default_metric_cyclomatic() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let meta = AdapterMeta {
default_metric: ComplexityMetric::Cyclomatic,
..fake_meta()
};
let inputs = merge_effective_inputs(&cli, &None, &meta);
assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
}
#[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, &fake_meta());
assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
}
#[test]
fn merge_effective_inputs_default_threshold_follows_adapter_metric_cognitive() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let meta = AdapterMeta {
default_metric: ComplexityMetric::Cognitive,
..fake_meta()
};
let inputs = merge_effective_inputs(&cli, &None, &meta);
assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
assert_eq!(inputs.threshold, 15.0);
}
#[test]
fn merge_effective_inputs_default_threshold_follows_adapter_metric_cyclomatic() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let meta = AdapterMeta {
default_metric: ComplexityMetric::Cyclomatic,
..fake_meta()
};
let inputs = merge_effective_inputs(&cli, &None, &meta);
assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
assert_eq!(inputs.threshold, 15.0);
}
#[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, &fake_meta());
assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
}
#[test]
fn merge_effective_inputs_config_metric_wins_over_adapter_default() {
let cli = parse(&["--coverage", "lcov.info"]).unwrap();
let file_config = Some(FileConfig {
metric: Some(ComplexityMetric::Cyclomatic),
..FileConfig::default()
});
let meta = AdapterMeta {
default_metric: ComplexityMetric::Cognitive,
..fake_meta()
};
let inputs = merge_effective_inputs(&cli, &file_config, &meta);
assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
}
#[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));
}
fn fake_meta() -> AdapterMeta {
AdapterMeta {
tool_name: "fake-adapter",
tool_version: "9.9.9",
long_version: "9.9.9 (test 2099-01-01)",
about: "Fake adapter for tests",
long_about: "Fake adapter for tests — verifies AdapterMeta plumbing without binding crap-core to any real adapter.",
after_help: "",
coverage_hint: "no coverage tool — fake adapter",
extensions: &["fake"],
tool_info_uri: "https://example.invalid/fake-adapter",
rule_help_uri: "https://example.invalid/fake-adapter#rules",
config_file_name: "fake-adapter.toml",
default_excludes: &["fixtures/**"],
forced_excludes: &[],
default_metric: ComplexityMetric::Cognitive,
}
}
#[test]
fn adapter_meta_extensions_owned_roundtrips_to_owned_strings() {
let meta = AdapterMeta {
extensions: &["ts", "tsx", "js"],
..fake_meta()
};
let owned = meta.extensions_owned();
assert_eq!(
owned,
vec!["ts".to_string(), "tsx".to_string(), "js".to_string()]
);
let back: Vec<&str> = owned.iter().map(String::as_str).collect();
assert_eq!(back, &["ts", "tsx", "js"]);
}
#[test]
fn adapter_meta_extensions_owned_handles_empty_slice() {
let meta = AdapterMeta {
extensions: &[],
..fake_meta()
};
assert!(meta.extensions_owned().is_empty());
}
#[test]
#[should_panic(expected = "tool_name must not be empty")]
fn adapter_meta_debug_assert_trips_on_empty_tool_name() {
let meta = AdapterMeta {
tool_name: "",
..fake_meta()
};
meta.debug_assert_required_fields();
}
#[test]
#[should_panic(expected = "config_file_name must not be empty")]
fn adapter_meta_debug_assert_trips_on_empty_config_file_name() {
let meta = AdapterMeta {
config_file_name: "",
..fake_meta()
};
meta.debug_assert_required_fields();
}
#[test]
fn adapter_meta_debug_assert_passes_on_all_fields_set() {
fake_meta().debug_assert_required_fields();
}
}