use proc_macro2::Span; use std::{ collections::HashSet, env, fs, path::{Path, PathBuf}, sync::OnceLock, }; use syn::LitStr; use toml::Value; pub(crate) fn sanitize_group_literal(literal: &LitStr) -> Result { normalize_group_name(&literal.value()).map_err(|msg| syn::Error::new(literal.span(), msg)) } pub(crate) fn ensure_group_known( groups: &NextestGroups, group: &str, span: Span, ) -> Result<(), syn::Error> { if groups.names.contains(group) { Ok(()) } else { Err(syn::Error::new( span, format!( "unknown test group `{}`; define it under [test-groups] in {}", group, groups.source ), )) } } pub(crate) struct NextestGroups { names: HashSet, source: String, } static NEXTEST_GROUPS: OnceLock> = OnceLock::new(); fn normalize_group_name(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err("test_group requires a non-empty filter group name"); } let mut sanitized = String::with_capacity(trimmed.len()); for ch in trimmed.chars() { match ch { 'a'..='z' | '0'..='9' => sanitized.push(ch), 'A'..='Z' => sanitized.push(ch.to_ascii_lowercase()), '_' => sanitized.push('_'), '-' => sanitized.push('_'), _ => { return Err( "filter group names may only contain ASCII letters, digits, '_' or '-'", ); } } } Ok(sanitized) } pub(crate) fn configured_test_groups() -> Result<&'static NextestGroups, String> { match NEXTEST_GROUPS.get_or_init(load_nextest_groups) { Ok(groups) => Ok(groups), Err(err) => Err(err.clone()), } } fn load_nextest_groups() -> Result { let path = resolve_nextest_config_path()?; let contents = fs::read_to_string(&path) .map_err(|err| format!("failed to read {}: {err}", path.display()))?; let parsed: Value = toml::from_str(&contents) .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; let table = parsed .get("test-groups") .and_then(Value::as_table) .ok_or_else(|| format!("missing [test-groups] table in {}", path.display()))?; let mut names = HashSet::with_capacity(table.len()); for key in table.keys() { let normalized = normalize_group_name(key).map_err(|msg| { format!( "invalid test group name `{}` in {}: {}", key, path.display(), msg ) })?; if !names.insert(normalized.clone()) { return Err(format!( "duplicate normalized test group `{}` in {}", normalized, path.display() )); } } if names.is_empty() { return Err(format!( "no entries defined under [test-groups] in {}", path.display() )); } Ok(NextestGroups { names, source: path.display().to_string(), }) } fn resolve_nextest_config_path() -> Result { if let Ok(value) = env::var("COMMONWARE_NEXTEST_CONFIG") { let explicit = PathBuf::from(&value); if explicit.is_file() { return Ok(explicit); } else { return Err(format!( "COMMONWARE_NEXTEST_CONFIG points to `{}` but the file was not found", explicit.display() )); } } let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); for current in manifest_dir.ancestors() { let candidate = current.join(".config").join("nextest.toml"); if candidate.is_file() { return Ok(candidate); } } Err(format!( "unable to locate .config/nextest.toml relative to {} (set COMMONWARE_NEXTEST_CONFIG to override)", manifest_dir.display() )) }