use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use serde_json::Value;
use crate::collect::nextest_list;
use crate::db::TestId;
use crate::project::ProjectRoot;
use crate::selection::{changed_paths_since, ChangedRangesBySha, Reachability};
const TABLE: &str = "[*.metadata.affected]";
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct AffectedConfig {
#[serde(default, rename = "rule")]
rules: Vec<InputRule>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct InputRule {
globs: Vec<String>,
filterset: String,
}
#[derive(Debug)]
pub(crate) struct CompiledRule {
matcher: GlobSet,
filterset: String,
}
impl AffectedConfig {
pub(crate) fn from_metadata(metadata: &Value, workspace_root: &Path) -> Result<Self> {
let table = metadata
.get("metadata")
.and_then(|m| m.get("affected"))
.or_else(|| root_package_metadata_affected(metadata, workspace_root));
match table {
Some(value) => serde_json::from_value(value.clone())
.with_context(|| format!("failed to parse {TABLE} in Cargo.toml")),
None => Ok(Self::default()),
}
}
pub(crate) fn compile(&self) -> Result<Vec<CompiledRule>> {
self.rules.iter().map(InputRule::compile).collect()
}
}
fn root_package_metadata_affected<'a>(metadata: &'a Value, workspace_root: &Path) -> Option<&'a Value> {
let root_manifest = workspace_root.join("Cargo.toml");
metadata.get("packages")?.as_array()?.iter().find_map(|pkg| {
let manifest = pkg.get("manifest_path")?.as_str()?;
if Path::new(manifest) == root_manifest {
pkg.get("metadata")?.get("affected")
} else {
None
}
})
}
impl InputRule {
fn compile(&self) -> Result<CompiledRule> {
let mut builder = GlobSetBuilder::new();
for g in &self.globs {
builder.add(Glob::new(g).with_context(|| {
format!("invalid glob {g:?} in {TABLE}")
})?);
}
Ok(CompiledRule {
matcher: builder
.build()
.with_context(|| format!("failed to build glob matcher for {TABLE}"))?,
filterset: self.filterset.clone(),
})
}
}
pub(crate) fn config_rule_hits(
project: &ProjectRoot,
build_args: &[String],
reach: &Reachability,
changed_ranges_by_sha: &ChangedRangesBySha,
working_tree_files: &[String],
) -> Result<BTreeMap<String, BTreeSet<TestId>>> {
let rules = AffectedConfig::from_metadata(&project.metadata, &project.workspace_root)?.compile()?;
if rules.is_empty() {
return Ok(BTreeMap::new());
}
let project_root = &project.workspace_root;
let changed_paths =
changed_paths_since(project_root, reach, changed_ranges_by_sha, working_tree_files)?;
resolve_config_hits(project_root, build_args, &rules, &changed_paths)
}
pub(crate) fn resolve_config_hits(
project_root: &Path,
build_args: &[String],
rules: &[CompiledRule],
changed_paths: &BTreeSet<String>,
) -> Result<BTreeMap<String, BTreeSet<TestId>>> {
let mut out: BTreeMap<String, BTreeSet<TestId>> = BTreeMap::new();
for rule in rules {
let matched: Vec<&String> = changed_paths
.iter()
.filter(|p| rule.matcher.is_match(p.as_str()))
.collect();
if matched.is_empty() {
continue;
}
let listing = nextest_list(project_root, None, None, build_args, Some(&rule.filterset))
.with_context(|| {
format!(
"failed to resolve {TABLE} filterset {:?} \
(check it is a valid nextest filter expression)",
rule.filterset
)
})?;
let tests: BTreeSet<TestId> = listing.tests.into_iter().collect();
if tests.is_empty() {
eprintln!(
"warning: {TABLE} rule matched {} but its filterset ({:?}) \
selected no tests — those input changes may go untested",
matched.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
rule.filterset,
);
continue;
}
for p in matched {
out.entry(p.clone()).or_default().extend(tests.iter().cloned());
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace_meta(rules: Value) -> Value {
serde_json::json!({ "metadata": { "affected": { "rule": rules } }, "packages": [] })
}
#[test]
fn absent_table_is_empty_config() {
let meta = serde_json::json!({ "metadata": null, "packages": [] });
let cfg = AffectedConfig::from_metadata(&meta, Path::new("/ws")).unwrap();
assert!(cfg.rules.is_empty());
assert!(cfg.compile().unwrap().is_empty());
}
#[test]
fn parses_workspace_metadata_rules() {
let meta = workspace_meta(serde_json::json!([
{ "globs": ["**/*.snap"], "filterset": "binary_id(=pkg::integration) & test(/help/)" },
{ "globs": ["README.md", "docs/**/*.md"], "filterset": "test(/sync/)" },
]));
let cfg = AffectedConfig::from_metadata(&meta, Path::new("/ws")).unwrap();
assert_eq!(cfg.rules.len(), 2);
assert_eq!(cfg.rules[0].filterset, "binary_id(=pkg::integration) & test(/help/)");
assert_eq!(cfg.rules[1].globs, vec!["README.md", "docs/**/*.md"]);
}
#[test]
fn falls_back_to_root_package_metadata() {
let meta = serde_json::json!({
"metadata": null,
"packages": [{
"manifest_path": "/ws/Cargo.toml",
"metadata": { "affected": { "rule": [
{ "globs": ["x.snap"], "filterset": "test(=t)" }
] } },
}],
});
let cfg = AffectedConfig::from_metadata(&meta, Path::new("/ws")).unwrap();
assert_eq!(cfg.rules.len(), 1);
assert_eq!(cfg.rules[0].globs, vec!["x.snap"]);
}
#[test]
fn malformed_table_errors() {
let meta = workspace_meta(serde_json::json!([
{ "globs": ["x"], "filterse": "y" }
]));
let err = AffectedConfig::from_metadata(&meta, Path::new("/ws"))
.expect_err("typo'd key must error");
assert!(format!("{err:#}").contains("Cargo.toml"));
}
#[test]
fn invalid_glob_errors() {
let cfg = AffectedConfig {
rules: vec![InputRule {
globs: vec!["a[".to_string()], filterset: "test(/x/)".to_string(),
}],
};
let err = cfg.compile().expect_err("invalid glob must error");
assert!(format!("{err:#}").contains("invalid glob"));
}
#[test]
fn compiled_rule_matches_globs() {
let cfg = AffectedConfig {
rules: vec![InputRule {
globs: vec!["**/*.snap".to_string(), "docs/**/*.md".to_string()],
filterset: "test(/x/)".to_string(),
}],
};
let compiled = cfg.compile().unwrap();
let m = &compiled[0].matcher;
assert!(m.is_match("tests/integration/snapshots/help.snap"));
assert!(m.is_match("docs/content/faq.md"));
assert!(!m.is_match("src/lib.rs"));
assert!(!m.is_match("README.md"));
}
}