use anyhow::{Context, Result, bail};
use cargo_crap::{
complexity,
coverage::{self, FileCoverage},
delta::{compute_delta, load_baseline},
merge::{MissingCoveragePolicy, ScopeDiagnostics, SortOrder, merge, sort_entries},
report::{
Format, RenderOptions, SourceLinks, crappy_count, render, render_delta,
render_delta_summary, render_summary, set_color_enabled,
},
score::DEFAULT_THRESHOLD,
};
use clap::{Parser, ValueEnum};
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufWriter, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
#[derive(Parser, Debug)]
#[command(
name = "cargo-crap",
about = "Compute the CRAP (Change Risk Anti-Patterns) metric for Rust projects.",
long_about = None,
version
)]
#[expect(
clippy::struct_excessive_bools,
reason = "the bools come from clap-derived `--flag` switches (--workspace, --summary, --fail-above, --fail-regression, --no-default-excludes, --show-unchanged); not a struct-design smell"
)]
struct Cli {
#[arg(long, value_name = "FILE")]
lcov: Option<PathBuf>,
#[arg(long, value_name = "DIR", default_value = ".")]
path: PathBuf,
#[arg(long)]
workspace: bool,
#[arg(
short = 'p',
long = "package",
value_name = "NAME",
conflicts_with = "workspace"
)]
package: Vec<String>,
#[arg(long, value_name = "GLOB")]
exclude: Vec<String>,
#[arg(long)]
no_default_excludes: bool,
#[arg(long)]
threshold: Option<f64>,
#[arg(long, value_name = "SCORE")]
min: Option<f64>,
#[arg(long, value_name = "N")]
top: Option<usize>,
#[arg(long, value_enum)]
missing: Option<MissingPolicy>,
#[arg(long, value_enum, default_value_t = FormatArg::Human)]
format: FormatArg,
#[arg(long)]
summary: bool,
#[arg(long)]
fail_above: bool,
#[arg(long, value_name = "GLOB")]
allow: Vec<String>,
#[arg(long, value_name = "FILE")]
baseline: Option<PathBuf>,
#[arg(long)]
fail_regression: bool,
#[arg(long)]
show_unchanged: bool,
#[arg(long, value_enum)]
sort: Option<SortArg>,
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long, value_name = "N")]
jobs: Option<usize>,
#[arg(long, value_name = "VALUE", allow_negative_numbers = true)]
epsilon: Option<f64>,
#[arg(long, value_name = "URL")]
repo_url: Option<String>,
#[arg(long, value_name = "REF")]
commit_ref: Option<String>,
}
#[derive(ValueEnum, Clone, Copy, Debug)]
enum MissingPolicy {
Pessimistic,
Optimistic,
Skip,
}
impl From<MissingPolicy> for MissingCoveragePolicy {
fn from(p: MissingPolicy) -> Self {
match p {
MissingPolicy::Pessimistic => Self::Pessimistic,
MissingPolicy::Optimistic => Self::Optimistic,
MissingPolicy::Skip => Self::Skip,
}
}
}
#[derive(ValueEnum, Clone, Copy, Debug)]
enum SortArg {
Crap,
File,
}
impl From<SortArg> for SortOrder {
fn from(s: SortArg) -> Self {
match s {
SortArg::Crap => Self::Crap,
SortArg::File => Self::File,
}
}
}
#[derive(ValueEnum, Clone, Copy, Debug)]
enum FormatArg {
Human,
Json,
Github,
Markdown,
PrComment,
Sarif,
Shields,
}
impl From<FormatArg> for Format {
fn from(f: FormatArg) -> Self {
match f {
FormatArg::Human => Self::Human,
FormatArg::Json => Self::Json,
FormatArg::Github => Self::GitHub,
FormatArg::Markdown => Self::Markdown,
FormatArg::PrComment => Self::PrComment,
FormatArg::Sarif => Self::Sarif,
FormatArg::Shields => Self::Shields,
}
}
}
const DEFAULT_EXCLUDES: &[&str] = &["tests/**", "benches/**", "examples/**"];
fn effective_excludes(
no_default_excludes: bool,
config_default_excludes: Option<Vec<String>>,
config_exclude: Vec<String>,
cli_exclude: Vec<String>,
) -> Vec<String> {
let mut out = if no_default_excludes {
Vec::new()
} else {
config_default_excludes
.unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect())
};
out.extend(config_exclude);
out.extend(cli_exclude);
out
}
fn strip_cargo_subcommand(mut args: Vec<String>) -> Vec<String> {
if args.get(1).map(String::as_str) == Some("crap") {
args.remove(1);
}
args
}
fn is_path_allow_pattern(pattern: &str) -> bool {
pattern.contains('/') || pattern.contains("**")
}
fn build_allow_set(patterns: &[&str]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pat in patterns {
let glob = GlobBuilder::new(pat)
.build()
.with_context(|| format!("invalid allow pattern: {pat:?}"))?;
builder.add(glob);
}
builder.build().context("building allow glob set")
}
fn build_path_set(
patterns: &[&str],
what: &str,
) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pat in patterns {
let glob = GlobBuilder::new(pat)
.literal_separator(true)
.build()
.with_context(|| format!("invalid {what} pattern: {pat:?}"))?;
builder.add(glob);
}
builder
.build()
.with_context(|| format!("building {what} path glob set"))
}
fn path_set_matches_suffix(
set: &GlobSet,
path: &Path,
) -> bool {
if set.is_empty() {
return false;
}
let components: Vec<_> = path.components().collect();
for i in 0..components.len() {
let suffix: PathBuf = components[i..].iter().collect();
if set.is_match(&suffix) {
return true;
}
}
false
}
#[derive(Debug, Clone)]
struct WorkspaceMember {
name: String,
dir: PathBuf,
}
struct AnalyzedSources {
fns: Vec<complexity::FunctionComplexity>,
members: Vec<WorkspaceMember>,
member_scope: Option<MemberScope>,
}
fn analyze_sources(
workspace: bool,
packages: &[String],
path: &std::path::Path,
excludes: &[String],
jobs: Option<usize>,
) -> Result<AnalyzedSources> {
if let Some(n) = jobs {
rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build_global()
.with_context(|| format!("configuring rayon thread pool to {n} threads"))?;
}
if !workspace && packages.is_empty() {
let fns = complexity::analyze_tree(path, excludes)
.with_context(|| format!("analyzing {}", path.display()))?;
return Ok(AnalyzedSources {
fns,
members: Vec::new(),
member_scope: None,
});
}
analyze_workspace_members(packages, excludes)
}
fn analyze_workspace_members(
packages: &[String],
excludes: &[String],
) -> Result<AnalyzedSources> {
let (workspace_root, discovered) = workspace_members()?;
let members = if packages.is_empty() {
discovered.clone()
} else {
select_members(&discovered, packages)?
};
let mut fns = Vec::new();
for m in &members {
let mut walk_excludes = excludes.to_vec();
walk_excludes.extend(nested_member_excludes(&m.dir, &discovered));
let member_fns = complexity::analyze_tree(&m.dir, &walk_excludes)
.with_context(|| format!("analyzing {}", m.dir.display()))?;
fns.extend(member_fns);
}
let member_scope =
(!packages.is_empty()).then(|| MemberScope::new(&workspace_root, &discovered, &members));
Ok(AnalyzedSources {
fns,
members,
member_scope,
})
}
struct ScopedMember {
dir: PathBuf,
rel: Vec<String>,
selected: bool,
}
struct MemberScope {
members: Vec<ScopedMember>,
}
impl MemberScope {
fn new(
workspace_root: &Path,
discovered: &[WorkspaceMember],
selected: &[WorkspaceMember],
) -> Self {
let selected_names: std::collections::HashSet<&str> =
selected.iter().map(|m| m.name.as_str()).collect();
let members = discovered
.iter()
.map(|m| ScopedMember {
dir: PathBuf::from(m.dir.to_string_lossy().replace('\\', "/")),
rel: m
.dir
.strip_prefix(workspace_root)
.map(|rel| {
rel.to_string_lossy()
.replace('\\', "/")
.split('/')
.filter(|c| !c.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
selected: selected_names.contains(m.name.as_str()),
})
.collect();
Self { members }
}
fn is_out_of_scope(
&self,
file: &Path,
) -> bool {
let normalized = PathBuf::from(file.to_string_lossy().replace('\\', "/"));
if let Some(owner) = self
.members
.iter()
.filter(|m| normalized.starts_with(&m.dir))
.max_by_key(|m| m.dir.components().count())
{
return !owner.selected;
}
let components: Vec<&str> = normalized
.to_str()
.map(|s| s.split('/').filter(|c| !c.is_empty()).collect())
.unwrap_or_default();
let best_len = self
.members
.iter()
.filter(|m| components_contain(&components, &m.rel))
.map(|m| m.rel.len())
.max();
let Some(best_len) = best_len else {
return false;
};
!self
.members
.iter()
.filter(|m| m.rel.len() == best_len && components_contain(&components, &m.rel))
.any(|m| m.selected)
}
}
fn components_contain(
haystack: &[&str],
window: &[String],
) -> bool {
!window.is_empty()
&& haystack
.windows(window.len())
.any(|w| w.iter().zip(window).all(|(a, b)| *a == b.as_str()))
}
fn select_members(
discovered: &[WorkspaceMember],
requested: &[String],
) -> Result<Vec<WorkspaceMember>> {
let available: std::collections::HashSet<&str> =
discovered.iter().map(|m| m.name.as_str()).collect();
let mut unknown: Vec<&str> = requested
.iter()
.map(String::as_str)
.filter(|n| !available.contains(n))
.collect();
if !unknown.is_empty() {
unknown.sort_unstable();
unknown.dedup();
let mut names: Vec<&str> = discovered.iter().map(|m| m.name.as_str()).collect();
names.sort_unstable();
bail!(
"unknown package(s): {}; available workspace members: {}",
unknown.join(", "),
names.join(", "),
);
}
let wanted: std::collections::HashSet<&str> = requested.iter().map(String::as_str).collect();
Ok(discovered
.iter()
.filter(|m| wanted.contains(m.name.as_str()))
.cloned()
.collect())
}
fn nested_member_excludes(
root: &std::path::Path,
discovered: &[WorkspaceMember],
) -> Vec<String> {
discovered
.iter()
.filter(|m| m.dir != root)
.filter_map(|m| m.dir.strip_prefix(root).ok())
.map(|rel| format!("{}/**", rel.to_string_lossy().replace('\\', "/")))
.collect()
}
fn assign_crate_names(
entries: &mut [cargo_crap::merge::CrapEntry],
members: &[WorkspaceMember],
) {
if members.is_empty() {
return;
}
let mut sorted: Vec<&WorkspaceMember> = members.iter().collect();
sorted.sort_by_key(|m| std::cmp::Reverse(m.dir.as_os_str().len()));
for entry in entries.iter_mut() {
for m in &sorted {
if entry.file.starts_with(&m.dir) {
entry.crate_name = Some(m.name.clone());
break;
}
}
}
}
fn apply_filters(
entries: &mut Vec<cargo_crap::merge::CrapEntry>,
allow_patterns: &[String],
min: Option<f64>,
top: Option<usize>,
) -> Result<()> {
if !allow_patterns.is_empty() {
let (path_pats, name_pats): (Vec<&str>, Vec<&str>) = allow_patterns
.iter()
.map(String::as_str)
.partition(|p| is_path_allow_pattern(p));
let name_set = build_allow_set(&name_pats)?;
let path_set = build_path_set(&path_pats, "allow")?;
entries.retain(|e| {
!name_set.is_match(&e.function) && !path_set_matches_suffix(&path_set, &e.file)
});
}
if let Some(min) = min {
entries.retain(|e| e.crap >= min);
}
if let Some(top) = top {
entries.truncate(top);
}
Ok(())
}
struct BaselineFilter {
exclude_set: GlobSet,
name_allow: GlobSet,
path_allow: GlobSet,
roots: Vec<PathBuf>,
}
impl BaselineFilter {
fn new(
excludes: &[String],
allow_patterns: &[String],
mut roots: Vec<PathBuf>,
) -> Result<Self> {
let exclude_pats: Vec<&str> = excludes.iter().map(String::as_str).collect();
let (path_pats, name_pats): (Vec<&str>, Vec<&str>) = allow_patterns
.iter()
.map(String::as_str)
.partition(|p| is_path_allow_pattern(p));
roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len()));
Ok(Self {
exclude_set: build_path_set(&exclude_pats, "exclude")?,
name_allow: build_allow_set(&name_pats)?,
path_allow: build_path_set(&path_pats, "allow")?,
roots,
})
}
fn is_excluded(
&self,
file: &Path,
) -> bool {
if self.exclude_set.is_empty() {
return false;
}
let normalized = PathBuf::from(file.to_string_lossy().replace('\\', "/"));
let rel = self
.roots
.iter()
.find_map(|root| normalized.strip_prefix(root).ok())
.unwrap_or(&normalized);
self.exclude_set.is_match(rel)
}
fn retain(
&self,
entries: &mut Vec<cargo_crap::merge::CrapEntry>,
) {
entries.retain(|e| {
!self.is_excluded(&e.file)
&& !self.name_allow.is_match(&e.function)
&& !path_set_matches_suffix(&self.path_allow, &e.file)
});
}
}
fn load_coverage(lcov: Option<&PathBuf>) -> Result<HashMap<PathBuf, FileCoverage>> {
match lcov {
Some(path) => coverage::parse_lcov(path)
.with_context(|| format!("parsing LCOV file {}", path.display())),
None => Ok(HashMap::new()),
}
}
#[expect(
clippy::fn_params_excessive_bools,
reason = "the bools are independent environment facts (env vars, --output, TTY); \
bundling them into a struct would only rename the same four flags"
)]
fn resolve_color(
no_color: bool,
force_color: bool,
output_is_file: bool,
stdout_is_tty: bool,
) -> bool {
if no_color {
false
} else if force_color {
true
} else {
!output_is_file && stdout_is_tty
}
}
fn open_output(path: Option<&PathBuf>) -> Result<Box<dyn Write>> {
Ok(match path {
Some(p) => {
Box::new(BufWriter::new(File::create(p).with_context(|| {
format!("creating output file {}", p.display())
})?))
},
None => Box::new(io::stdout()),
})
}
fn spinner(msg: &'static str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]),
);
pb.set_message(msg);
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
fn workspace_members() -> Result<(PathBuf, Vec<WorkspaceMember>)> {
let output = std::process::Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.output()
.context("running `cargo metadata`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("`cargo metadata` failed: {stderr}");
}
parse_workspace_metadata(&output.stdout)
}
fn parse_workspace_metadata(stdout: &[u8]) -> Result<(PathBuf, Vec<WorkspaceMember>)> {
let meta: serde_json::Value =
serde_json::from_slice(stdout).context("parsing `cargo metadata` output")?;
let workspace_root = meta["workspace_root"]
.as_str()
.map(PathBuf::from)
.context("`cargo metadata` output missing `workspace_root`")?;
let members: Vec<WorkspaceMember> = meta["packages"]
.as_array()
.context("`cargo metadata` output missing `packages`")?
.iter()
.filter_map(|pkg| {
let name = pkg["name"].as_str()?.to_string();
let dir = pkg["manifest_path"]
.as_str()
.and_then(|p| PathBuf::from(p).parent().map(std::path::Path::to_path_buf))?;
Some(WorkspaceMember { name, dir })
})
.collect();
if members.is_empty() {
bail!("`cargo metadata` returned no packages");
}
Ok((workspace_root, members))
}
fn scope_warning_lead(diag: &ScopeDiagnostics) -> String {
if diag.matched_files == 0 {
format!(
"warning: no overlap between the analyzed sources and the LCOV report \
({} analyzed, {} in LCOV, 0 matched) — the analyzed tree and the \
coverage run describe different scopes",
diag.analyzed_files, diag.lcov_files,
)
} else if diag.matched_files * 2 < diag.analyzed_files {
format!(
"warning: only {} of {} analyzed files match the LCOV report — the \
analyzed tree and the coverage run likely describe different scopes",
diag.matched_files, diag.analyzed_files,
)
} else {
format!(
"warning: source/LCOV scope mismatch ({} analyzed, {} in LCOV, {} matched) \
— verify your --lcov path or coverage tool configuration",
diag.analyzed_files, diag.lcov_files, diag.matched_files,
)
}
}
fn stray_lines(
label: &str,
strays: &cargo_crap::merge::StrayFiles,
) -> Vec<String> {
if strays.count == 0 {
return Vec::new();
}
let mut lines = vec![format!(" {} {label}:", strays.count)];
lines.extend(
strays
.examples
.iter()
.map(|f| format!(" {}", f.display())),
);
if strays.count > strays.examples.len() {
lines.push(format!(
" ... and {} more",
strays.count - strays.examples.len()
));
}
lines
}
fn warn_scope_mismatch(diag: Option<&ScopeDiagnostics>) {
let Some(d) = diag else { return };
if d.source_only.count == 0 && d.lcov_only.count == 0 {
return;
}
eprintln!("{}", scope_warning_lead(d));
let details = stray_lines(
"analyzed source file(s) had no matching entry in the LCOV report",
&d.source_only,
)
.into_iter()
.chain(stray_lines(
"LCOV file(s) matched no analyzed source file",
&d.lcov_only,
));
for line in details {
eprintln!("{line}");
}
}
fn resolve_bool(
cli_flag: bool,
config_value: Option<bool>,
) -> bool {
cli_flag || config_value.unwrap_or(false)
}
fn require_baseline(
flag_set: bool,
baseline_present: bool,
flag: &str,
) -> Result<()> {
if flag_set && !baseline_present {
bail!("{flag} requires --baseline");
}
Ok(())
}
fn path_is_ignored(cli: &Cli) -> bool {
cli.workspace || !cli.package.is_empty()
}
fn validate_merged_values(
epsilon: f64,
jobs: Option<usize>,
) -> Result<()> {
if epsilon < 0.0 {
bail!("invalid epsilon value (--epsilon or config): must be non-negative");
}
if matches!(jobs, Some(0)) {
bail!("invalid jobs value (--jobs or config): must be a positive integer");
}
Ok(())
}
fn validate_args(cli: &Cli) -> Result<()> {
if !path_is_ignored(cli) && !cli.path.exists() {
bail!("path does not exist: {}", cli.path.display());
}
let has_baseline = cli.baseline.is_some();
require_baseline(cli.fail_regression, has_baseline, "--fail-regression")?;
require_baseline(cli.show_unchanged, has_baseline, "--show-unchanged")?;
if matches!(cli.jobs, Some(0)) {
bail!("invalid --jobs value: must be a positive integer");
}
if let Some(eps) = cli.epsilon
&& eps < 0.0
{
bail!("invalid --epsilon value: must be non-negative");
}
Ok(())
}
struct RenderOpts<'a> {
render: RenderOptions<'a>,
epsilon: f64,
summary: bool,
sort: SortOrder,
}
fn effective_summary(
summary: bool,
format: Format,
) -> bool {
summary && !matches!(format, Format::Json | Format::GitHub)
}
fn load_filtered_baseline(
baseline: Option<&PathBuf>,
excludes: &[String],
allow_patterns: &[String],
path: &Path,
members: &[WorkspaceMember],
member_scope: Option<&MemberScope>,
) -> Result<Option<Vec<cargo_crap::merge::CrapEntry>>> {
let Some(baseline_path) = baseline else {
return Ok(None);
};
let mut data = load_baseline(baseline_path)?;
let roots = if members.is_empty() {
vec![path.to_path_buf()]
} else {
members.iter().map(|m| m.dir.clone()).collect()
};
BaselineFilter::new(excludes, allow_patterns, roots)?.retain(&mut data);
apply_member_scope(&mut data, member_scope);
Ok(Some(data))
}
fn apply_member_scope(
data: &mut Vec<cargo_crap::merge::CrapEntry>,
member_scope: Option<&MemberScope>,
) {
if let Some(scope) = member_scope {
data.retain(|e| !scope.is_out_of_scope(&e.file));
}
}
fn do_render(
entries: &[cargo_crap::merge::CrapEntry],
baseline: Option<&[cargo_crap::merge::CrapEntry]>,
opts: &RenderOpts,
out: &mut dyn Write,
) -> Result<(bool, bool)> {
let summary = effective_summary(opts.summary, opts.render.format);
if let Some(baseline_data) = baseline {
let mut report = compute_delta(entries, baseline_data, opts.epsilon);
report.sort(opts.sort);
let has_crappy = crappy_count(entries, opts.render.threshold) > 0;
let has_regression = report.regression_count() > 0;
if summary {
render_delta_summary(&report, out)?;
} else {
render_delta(&report, &opts.render, out)?;
}
Ok((has_crappy, has_regression))
} else {
let has_crappy = crappy_count(entries, opts.render.threshold) > 0;
if summary {
render_summary(entries, opts.render.threshold, out)?;
} else {
render(entries, &opts.render, out)?;
}
Ok((has_crappy, false))
}
}
fn resolve_source_links(
cli_repo_url: Option<String>,
cli_commit_ref: Option<String>,
) -> Option<SourceLinks> {
let repo_url = cli_repo_url.or_else(|| {
let server = std::env::var("GITHUB_SERVER_URL").ok()?;
let repo = std::env::var("GITHUB_REPOSITORY").ok()?;
Some(format!(
"{}/{}",
server.trim_end_matches('/'),
repo.trim_start_matches('/')
))
})?;
let commit_ref = cli_commit_ref.or_else(|| std::env::var("GITHUB_SHA").ok())?;
Some(SourceLinks::new(repo_url, commit_ref))
}
fn parse_and_load_config() -> Result<(Cli, cargo_crap::config::Config)> {
let cli = Cli::parse_from(strip_cargo_subcommand(std::env::args().collect()));
validate_args(&cli)?;
let cwd = std::env::current_dir().unwrap_or_else(|_| cli.path.clone());
let config = cargo_crap::config::load(&cwd)?;
Ok((cli, config))
}
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("Error: {e:?}");
ExitCode::from(2)
},
}
}
struct LoadedArgs {
cli: Cli,
config: cargo_crap::config::Config,
epsilon: f64,
jobs: Option<usize>,
}
fn parse_and_validate() -> Result<LoadedArgs> {
let (cli, config) = parse_and_load_config()?;
let epsilon = cli
.epsilon
.or(config.epsilon)
.unwrap_or(cargo_crap::delta::DEFAULT_EPSILON);
let jobs = cli.jobs.or(config.jobs);
validate_merged_values(epsilon, jobs)?;
Ok(LoadedArgs {
cli,
config,
epsilon,
jobs,
})
}
fn run() -> Result<ExitCode> {
let LoadedArgs {
cli,
config,
epsilon,
jobs,
} = parse_and_validate()?;
let threshold = cli
.threshold
.or(config.threshold)
.unwrap_or(DEFAULT_THRESHOLD);
let missing_policy: MissingCoveragePolicy = cli
.missing
.map(Into::into)
.or(config.missing)
.unwrap_or(MissingCoveragePolicy::Pessimistic);
let fail_above = resolve_bool(cli.fail_above, config.fail_above);
let fail_regression = resolve_bool(cli.fail_regression, config.fail_regression);
let show_unchanged = resolve_bool(cli.show_unchanged, config.show_unchanged);
let sort_order = cli.sort.map(Into::into).or(config.sort).unwrap_or_default();
let effective_exclude = effective_excludes(
cli.no_default_excludes,
config.default_excludes,
config.exclude,
cli.exclude,
);
let mut effective_allow = config.allow;
effective_allow.extend(cli.allow);
let pb = spinner("Analyzing source files…");
let AnalyzedSources {
fns,
members,
member_scope,
} = analyze_sources(
cli.workspace,
&cli.package,
&cli.path,
&effective_exclude,
jobs,
)?;
pb.set_message("Parsing coverage report…");
let coverage = load_coverage(cli.lcov.as_ref())?;
pb.finish_and_clear();
let merge_result = merge(fns, coverage, missing_policy);
let diagnostics = merge_result.diagnostics;
warn_scope_mismatch(diagnostics.as_ref());
let mut entries = merge_result.entries;
assign_crate_names(&mut entries, &members);
apply_filters(
&mut entries,
&effective_allow,
cli.min.or(config.min),
cli.top.or(config.top),
)?;
sort_entries(&mut entries, sort_order);
let baseline_data = load_filtered_baseline(
cli.baseline.as_ref(),
&effective_exclude,
&effective_allow,
&cli.path,
&members,
member_scope.as_ref(),
)?;
let env_set = |name: &str| std::env::var_os(name).is_some_and(|v| !v.is_empty());
set_color_enabled(resolve_color(
env_set("NO_COLOR"),
env_set("FORCE_COLOR"),
cli.output.is_some(),
io::stdout().is_terminal(),
));
let mut out_box = open_output(cli.output.as_ref())?;
let links = resolve_source_links(cli.repo_url, cli.commit_ref);
let opts = RenderOpts {
render: RenderOptions {
threshold,
format: cli.format.into(),
links: links.as_ref(),
diagnostics: diagnostics.as_ref(),
show_unchanged,
},
epsilon,
summary: cli.summary,
sort: sort_order,
};
let (has_crappy, has_regression) =
do_render(&entries, baseline_data.as_deref(), &opts, out_box.as_mut())?;
out_box.flush().context("flushing output")?;
if (fail_above && has_crappy) || (fail_regression && has_regression) {
return Ok(ExitCode::from(1));
}
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use cargo_crap::merge::StrayFiles;
fn diag(
analyzed: usize,
lcov: usize,
matched: usize,
) -> ScopeDiagnostics {
ScopeDiagnostics {
analyzed_files: analyzed,
lcov_files: lcov,
matched_files: matched,
source_only: StrayFiles {
count: analyzed - matched,
examples: Vec::new(),
},
lcov_only: StrayFiles {
count: lcov - matched,
examples: Vec::new(),
},
}
}
#[test]
fn scope_warning_lead_zero_overlap_gets_strongest_wording() {
let lead = scope_warning_lead(&diag(3, 5, 0));
assert!(lead.contains("no overlap"), "got: {lead}");
assert!(lead.contains("3 analyzed"), "got: {lead}");
assert!(lead.contains("5 in LCOV"), "got: {lead}");
}
#[test]
fn scope_warning_lead_below_half_escalates() {
let lead = scope_warning_lead(&diag(40, 20, 15));
assert!(lead.contains("only 15 of 40"), "got: {lead}");
assert!(
lead.contains("likely describe different scopes"),
"got: {lead}"
);
}
#[test]
fn scope_warning_lead_exactly_half_stays_neutral() {
let lead = scope_warning_lead(&diag(4, 4, 2));
assert!(!lead.contains("only"), "got: {lead}");
assert!(
lead.contains("4 analyzed, 4 in LCOV, 2 matched"),
"got: {lead}"
);
}
#[test]
fn scope_warning_lead_majority_overlap_stays_neutral() {
let lead = scope_warning_lead(&diag(10, 12, 9));
assert!(
lead.contains("10 analyzed, 12 in LCOV, 9 matched"),
"got: {lead}"
);
assert!(lead.contains("verify your --lcov path"), "got: {lead}");
}
#[test]
fn stray_lines_empty_side_produces_nothing() {
let s = StrayFiles {
count: 0,
examples: Vec::new(),
};
assert!(stray_lines("label", &s).is_empty());
}
#[test]
fn stray_lines_no_tail_when_examples_cover_the_count() {
let s = StrayFiles {
count: 2,
examples: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
};
let lines = stray_lines("file(s) missing", &s);
assert_eq!(
lines,
vec![
" 2 file(s) missing:".to_string(),
" src/a.rs".to_string(),
" src/b.rs".to_string(),
]
);
}
#[test]
fn stray_lines_tail_counts_the_truncated_remainder() {
let s = StrayFiles {
count: 13,
examples: (0..10).map(|i| PathBuf::from(format!("f{i}.rs"))).collect(),
};
let lines = stray_lines("stray", &s);
assert_eq!(lines.len(), 12, "header + 10 examples + tail");
assert_eq!(lines.last().unwrap(), " ... and 3 more");
}
fn member(
name: &str,
dir: &str,
) -> WorkspaceMember {
WorkspaceMember {
name: name.into(),
dir: PathBuf::from(dir),
}
}
#[test]
fn select_members_filters_and_preserves_discovery_order() {
let discovered = vec![
member("alpha", "/ws/crates/alpha"),
member("beta", "/ws/crates/beta"),
member("gamma", "/ws/crates/gamma"),
];
let selected = select_members(&discovered, &["gamma".into(), "alpha".into()]).unwrap();
let names: Vec<&str> = selected.iter().map(|m| m.name.as_str()).collect();
assert_eq!(
names,
["alpha", "gamma"],
"discovery order, not request order"
);
}
#[test]
fn select_members_deduplicates_repeated_selections() {
let discovered = vec![member("alpha", "/ws/a"), member("beta", "/ws/b")];
let selected = select_members(&discovered, &["alpha".into(), "alpha".into()]).unwrap();
assert_eq!(selected.len(), 1);
}
#[test]
fn select_members_rejects_unknown_names_listing_available() {
let discovered = vec![member("alpha", "/ws/a"), member("beta", "/ws/b")];
let err = select_members(&discovered, &["alpha".into(), "zeta".into()])
.unwrap_err()
.to_string();
assert!(err.contains("unknown package(s): zeta"), "got: {err}");
assert!(
err.contains("available workspace members: alpha, beta"),
"got: {err}"
);
}
#[test]
fn parse_workspace_metadata_extracts_root_and_members() {
let json = serde_json::json!({
"workspace_root": "/ws",
"packages": [
{"name": "alpha", "manifest_path": "/ws/crates/alpha/Cargo.toml"},
]
});
let (root, members) = parse_workspace_metadata(json.to_string().as_bytes()).unwrap();
assert_eq!(root, PathBuf::from("/ws"));
assert_eq!(members.len(), 1);
assert_eq!(members[0].name, "alpha");
assert_eq!(members[0].dir, PathBuf::from("/ws/crates/alpha"));
}
#[test]
fn parse_workspace_metadata_rejects_missing_root_and_empty_packages() {
let no_root = serde_json::json!({"packages": []});
let err = parse_workspace_metadata(no_root.to_string().as_bytes())
.unwrap_err()
.to_string();
assert!(err.contains("workspace_root"), "got: {err}");
let empty = serde_json::json!({"workspace_root": "/ws", "packages": []});
let err = parse_workspace_metadata(empty.to_string().as_bytes())
.unwrap_err()
.to_string();
assert!(err.contains("no packages"), "got: {err}");
}
fn root_layout_scope() -> MemberScope {
let discovered = vec![
member("rooty", "/ws"),
member("alpha", "/ws/crates/alpha"),
member("beta", "/ws/crates/beta"),
];
let selected = vec![member("alpha", "/ws/crates/alpha")];
MemberScope::new(Path::new("/ws"), &discovered, &selected)
}
#[test]
fn scope_prefix_tier_deepest_member_owns_the_file() {
let scope = root_layout_scope();
assert!(scope.is_out_of_scope(Path::new("/ws/src/lib.rs")));
assert!(!scope.is_out_of_scope(Path::new("/ws/crates/alpha/src/lib.rs")));
assert!(scope.is_out_of_scope(Path::new("/ws/crates/beta/src/lib.rs")));
}
#[test]
fn scope_window_tier_matches_cross_root_baselines() {
let scope = root_layout_scope();
assert!(scope.is_out_of_scope(Path::new("/ci/build/repo/crates/beta/src/lib.rs")));
assert!(!scope.is_out_of_scope(Path::new("/ci/build/repo/crates/alpha/src/lib.rs")));
assert!(!scope.is_out_of_scope(Path::new("/ci/build/repo/src/lib.rs")));
}
#[test]
fn scope_window_tie_with_a_selected_member_keeps_the_entry() {
let discovered = vec![
member("frontend", "/ws/frontend"),
member("beta", "/ws/beta"),
];
let selected = vec![member("frontend", "/ws/frontend")];
let scope = MemberScope::new(Path::new("/ws"), &discovered, &selected);
assert!(!scope.is_out_of_scope(Path::new("/ci/x/frontend/src/beta/mod.rs")));
assert!(scope.is_out_of_scope(Path::new("/ci/x/beta/src/lib.rs")));
}
#[test]
fn scope_window_deeper_rel_wins_over_its_parent() {
let discovered = vec![
member("parent", "/ws/parent"),
member("nested", "/ws/parent/nested"),
];
let selected = vec![member("nested", "/ws/parent/nested")];
let scope = MemberScope::new(Path::new("/ws"), &discovered, &selected);
assert!(!scope.is_out_of_scope(Path::new("/ci/r/parent/nested/src/lib.rs")));
assert!(scope.is_out_of_scope(Path::new("/ci/r/parent/src/lib.rs")));
}
#[test]
fn scope_normalizes_windows_separators() {
let scope = root_layout_scope();
assert!(scope.is_out_of_scope(Path::new(r"C:\ci\repo\crates\beta\src\lib.rs")));
assert!(!scope.is_out_of_scope(Path::new(r"C:\ci\repo\crates\alpha\src\lib.rs")));
}
#[test]
fn components_contain_requires_contiguous_full_window() {
let hay = ["ws", "crates", "beta", "src"];
assert!(components_contain(&hay, &["crates".into(), "beta".into()]));
assert!(!components_contain(
&hay,
&["crates".into(), "alpha".into()]
));
assert!(
!components_contain(&hay, &["ws".into(), "beta".into()]),
"non-contiguous components must not match"
);
assert!(!components_contain(&hay, &[]), "empty window never matches");
}
#[test]
fn nested_member_excludes_covers_only_roots_beneath_this_one() {
let discovered = vec![
member("parent", "/ws/parent"),
member("nested", "/ws/parent/nested"),
member("deep", "/ws/parent/sub/deep"),
member("sibling", "/ws/sibling"),
];
let mut globs = nested_member_excludes(Path::new("/ws/parent"), &discovered);
globs.sort();
assert_eq!(
globs,
["nested/**", "sub/deep/**"],
"nested roots excluded relative to the walk root; siblings untouched"
);
assert!(
nested_member_excludes(Path::new("/ws/sibling"), &discovered).is_empty(),
"a leaf member excludes nothing"
);
assert!(
nested_member_excludes(Path::new("/ws/parent/nested"), &discovered).is_empty(),
"a member never excludes itself"
);
}
#[test]
fn validate_merged_values_truth_table() {
assert!(
validate_merged_values(0.0, None).is_ok(),
"zero epsilon is valid"
);
assert!(validate_merged_values(0.01, Some(4)).is_ok());
assert!(
validate_merged_values(-0.001, None).is_err(),
"negative epsilon"
);
assert!(validate_merged_values(0.01, Some(0)).is_err(), "zero jobs");
let err = validate_merged_values(-1.0, Some(0))
.unwrap_err()
.to_string();
assert!(err.contains("epsilon"), "epsilon is checked first: {err}");
}
#[test]
fn resolve_color_truth_table() {
let cases = [
((false, false, false, true), true), ((false, false, false, false), false), ((false, false, true, true), false), ((false, false, true, false), false),
((false, true, true, false), true), ((false, true, false, false), true),
((true, true, false, true), false), ((true, false, false, true), false),
];
for ((no_color, force, file, tty), expected) in cases {
assert_eq!(
resolve_color(no_color, force, file, tty),
expected,
"resolve_color({no_color}, {force}, {file}, {tty})"
);
}
}
#[test]
fn require_baseline_only_errs_when_flag_set_without_baseline() {
let err = require_baseline(true, false, "--show-unchanged").unwrap_err();
assert!(
err.to_string()
.contains("--show-unchanged requires --baseline")
);
assert!(require_baseline(true, true, "--show-unchanged").is_ok());
assert!(require_baseline(false, false, "--show-unchanged").is_ok());
assert!(require_baseline(false, true, "--show-unchanged").is_ok());
}
#[test]
fn sort_arg_maps_to_matching_sort_order() {
assert_eq!(SortOrder::from(SortArg::Crap), SortOrder::Crap);
assert_eq!(SortOrder::from(SortArg::File), SortOrder::File);
}
#[test]
fn resolve_bool_cli_wins_then_config_then_false() {
assert!(resolve_bool(true, Some(false)));
assert!(resolve_bool(true, None));
assert!(resolve_bool(false, Some(true)));
assert!(!resolve_bool(false, Some(false)));
assert!(!resolve_bool(false, None));
}
#[test]
fn name_glob_classifier_keeps_function_patterns() {
assert!(!is_path_allow_pattern("trivial"));
assert!(!is_path_allow_pattern("Foo::*"));
assert!(!is_path_allow_pattern("generated_*"));
assert!(!is_path_allow_pattern("*"));
}
#[test]
fn path_glob_classifier_recognizes_path_patterns() {
assert!(is_path_allow_pattern("src/generated/**"));
assert!(is_path_allow_pattern("tests/**"));
assert!(is_path_allow_pattern("**/build.rs"));
assert!(is_path_allow_pattern("a/b"));
}
#[test]
fn path_set_matches_relative_pattern_against_absolute_file() {
let set = build_path_set(&["src/generated/**"], "allow").unwrap();
let abs = Path::new("/home/u/project/src/generated/foo.rs");
assert!(path_set_matches_suffix(&set, abs));
}
#[test]
fn path_set_does_not_match_unrelated_file() {
let set = build_path_set(&["src/generated/**"], "allow").unwrap();
let other = Path::new("/home/u/project/src/main.rs");
assert!(!path_set_matches_suffix(&set, other));
}
#[test]
fn empty_path_set_is_no_op() {
let set = build_path_set(&[], "allow").unwrap();
assert!(!path_set_matches_suffix(&set, Path::new("any/path.rs")));
}
#[test]
fn path_set_respects_literal_separator() {
let set = build_path_set(&["src/*"], "allow").unwrap();
assert!(!path_set_matches_suffix(
&set,
Path::new("/abs/proj/src/generated/foo.rs"),
));
}
fn strs(v: &[&str]) -> Vec<String> {
v.iter().map(ToString::to_string).collect()
}
#[test]
fn effective_excludes_defaults_to_builtin_list() {
let out = effective_excludes(false, None, vec![], vec![]);
assert_eq!(out, strs(&["tests/**", "benches/**", "examples/**"]));
}
#[test]
fn effective_excludes_config_replaces_builtin_list() {
let out = effective_excludes(
false,
Some(strs(&["benches/**", "examples/**"])),
vec![],
vec![],
);
assert_eq!(out, strs(&["benches/**", "examples/**"]));
}
#[test]
fn effective_excludes_empty_config_list_disables_defaults() {
let out = effective_excludes(false, Some(vec![]), vec![], vec![]);
assert!(out.is_empty());
}
#[test]
fn effective_excludes_flag_overrides_config_replacement() {
let out = effective_excludes(true, Some(strs(&["tests/**"])), vec![], vec![]);
assert!(out.is_empty());
}
#[test]
fn effective_excludes_user_globs_append_never_replace() {
let out = effective_excludes(
false,
None,
strs(&["src/legacy/**"]),
strs(&["src/generated/**"]),
);
assert_eq!(
out,
strs(&[
"tests/**",
"benches/**",
"examples/**",
"src/legacy/**",
"src/generated/**",
])
);
}
fn baseline_entry(
file: &str,
function: &str,
) -> cargo_crap::merge::CrapEntry {
cargo_crap::merge::CrapEntry {
file: PathBuf::from(file),
function: function.to_string(),
line: 1,
cyclomatic: 1.0,
coverage: Some(100.0),
crap: 1.0,
crate_name: None,
}
}
fn names(entries: &[cargo_crap::merge::CrapEntry]) -> Vec<&str> {
entries.iter().map(|e| e.function.as_str()).collect()
}
#[test]
fn baseline_filter_drops_root_level_target_dirs() {
let filter = BaselineFilter::new(
&strs(&["tests/**", "benches/**", "examples/**"]),
&[],
vec![PathBuf::from(".")],
)
.unwrap();
let mut entries = vec![
baseline_entry("./src/lib.rs", "kept"),
baseline_entry("./tests/integration.rs", "test_helper"),
baseline_entry("benches/bench.rs", "bench_helper"),
baseline_entry("examples/demo.rs", "example_helper"),
];
filter.retain(&mut entries);
assert_eq!(names(&entries), ["kept"]);
}
#[test]
fn baseline_filter_keeps_nested_tests_dir_inside_src() {
let filter =
BaselineFilter::new(&strs(&["tests/**"]), &[], vec![PathBuf::from(".")]).unwrap();
let mut entries = vec![baseline_entry("./src/tests/helpers.rs", "nested_helper")];
filter.retain(&mut entries);
assert_eq!(names(&entries), ["nested_helper"]);
}
#[test]
fn baseline_filter_normalizes_backslash_paths() {
let filter =
BaselineFilter::new(&strs(&["tests/**"]), &[], vec![PathBuf::from(".")]).unwrap();
let mut entries = vec![baseline_entry("tests\\integration.rs", "test_helper")];
filter.retain(&mut entries);
assert!(entries.is_empty(), "backslash path must match tests/**");
}
#[test]
fn baseline_filter_strips_longest_member_root_first() {
let filter = BaselineFilter::new(
&strs(&["tests/**"]),
&[],
vec![PathBuf::from("crates/foo"), PathBuf::from("crates/foo/sub")],
)
.unwrap();
let mut entries = vec![
baseline_entry("crates/foo/tests/it.rs", "member_test"),
baseline_entry("crates/foo/sub/tests/it.rs", "nested_member_test"),
baseline_entry("crates/foo/src/lib.rs", "kept"),
];
filter.retain(&mut entries);
assert_eq!(names(&entries), ["kept"]);
}
#[test]
fn baseline_filter_applies_name_allow_patterns() {
let filter =
BaselineFilter::new(&[], &strs(&["generated_*"]), vec![PathBuf::from(".")]).unwrap();
let mut entries = vec![
baseline_entry("src/codegen.rs", "generated_parse_v1"),
baseline_entry("src/lib.rs", "kept"),
];
filter.retain(&mut entries);
assert_eq!(names(&entries), ["kept"]);
}
#[test]
fn baseline_filter_applies_path_allow_patterns() {
let filter =
BaselineFilter::new(&[], &strs(&["src/generated/**"]), vec![PathBuf::from(".")])
.unwrap();
let mut entries = vec![
baseline_entry("/abs/proj/src/generated/api.rs", "dropped"),
baseline_entry("/abs/proj/src/lib.rs", "kept"),
];
filter.retain(&mut entries);
assert_eq!(names(&entries), ["kept"]);
}
#[test]
fn baseline_filter_with_no_patterns_is_a_no_op() {
let filter = BaselineFilter::new(&[], &[], vec![PathBuf::from(".")]).unwrap();
let mut entries = vec![
baseline_entry("tests/integration.rs", "test_helper"),
baseline_entry("src/lib.rs", "lib_fn"),
];
filter.retain(&mut entries);
assert_eq!(names(&entries), ["test_helper", "lib_fn"]);
}
}