use anyhow::{Context, Result, bail};
use cargo_crap::{
complexity,
coverage::{self, FileCoverage},
delta::{compute_delta, load_baseline},
merge::{MissingCoveragePolicy, SortOrder, merge, sort_entries},
report::{
Format, SourceLinks, crappy_count, render, render_delta, render_delta_summary,
render_summary,
},
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, Write};
use std::path::{Path, PathBuf};
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(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,
}
fn analyze_sources(
workspace: bool,
path: &std::path::Path,
excludes: &[String],
jobs: Option<usize>,
) -> Result<(Vec<complexity::FunctionComplexity>, Vec<WorkspaceMember>)> {
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 {
let members = workspace_members()?;
let mut all = Vec::new();
for m in &members {
let fns = complexity::analyze_tree(&m.dir, excludes)
.with_context(|| format!("analyzing {}", m.dir.display()))?;
all.extend(fns);
}
Ok((all, members))
} else {
let fns = complexity::analyze_tree(path, excludes)
.with_context(|| format!("analyzing {}", path.display()))?;
Ok((fns, Vec::new()))
}
}
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()),
}
}
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<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}");
}
let meta: serde_json::Value =
serde_json::from_slice(&output.stdout).context("parsing `cargo metadata` output")?;
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(members)
}
fn warn_unmapped(files: &[std::path::PathBuf]) {
if files.is_empty() {
return;
}
let n = files.len();
eprintln!(
"warning: {} source file{} had no matching entry in the LCOV report \
— verify your --lcov path or coverage tool configuration:",
n,
if n == 1 { "" } else { "s" },
);
for f in files {
eprintln!(" {}", f.display());
}
}
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 validate_args(cli: &Cli) -> Result<()> {
if !cli.workspace && !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> {
threshold: f64,
epsilon: f64,
format: Format,
summary: bool,
links: Option<&'a SourceLinks>,
show_unchanged: bool,
sort: SortOrder,
}
fn load_filtered_baseline(
baseline: Option<&PathBuf>,
excludes: &[String],
allow_patterns: &[String],
path: &Path,
members: &[WorkspaceMember],
) -> 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);
Ok(Some(data))
}
fn do_render(
entries: &[cargo_crap::merge::CrapEntry],
baseline: Option<&[cargo_crap::merge::CrapEntry]>,
opts: &RenderOpts,
out: &mut dyn Write,
) -> Result<(bool, bool)> {
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.threshold) > 0;
let has_regression = report.regression_count() > 0;
if opts.summary {
render_delta_summary(&report, out)?;
} else {
render_delta(
&report,
opts.threshold,
opts.format,
opts.links,
opts.show_unchanged,
out,
)?;
}
Ok((has_crappy, has_regression))
} else {
let has_crappy = crappy_count(entries, opts.threshold) > 0;
if opts.summary {
render_summary(entries, opts.threshold, out)?;
} else {
render(entries, opts.threshold, opts.format, opts.links, 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() -> Result<()> {
let (cli, config) = parse_and_load_config()?;
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 epsilon = cli
.epsilon
.or(config.epsilon)
.unwrap_or(cargo_crap::delta::DEFAULT_EPSILON);
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 (fns, members) = analyze_sources(
cli.workspace,
&cli.path,
&effective_exclude,
cli.jobs.or(config.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);
warn_unmapped(&merge_result.unmapped_files);
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,
)?;
let mut out_box = open_output(cli.output.as_ref())?;
let links = resolve_source_links(cli.repo_url, cli.commit_ref);
let opts = RenderOpts {
threshold,
epsilon,
format: cli.format.into(),
summary: cli.summary,
links: links.as_ref(),
show_unchanged,
sort: sort_order,
};
let (has_crappy, has_regression) =
do_render(&entries, baseline_data.as_deref(), &opts, out_box.as_mut())?;
if (fail_above && has_crappy) || (fail_regression && has_regression) {
std::process::exit(1);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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"]);
}
}