use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Outcome};
use crate::normalize::Substitution;
pub const DEFAULT_FIXTURE_DIRS: &[&str] =
&["tests/lihaaf/compile_fail", "tests/lihaaf/compile_pass"];
pub const DEFAULT_COMPILE_FAIL_MARKER: &str = "compile_fail";
pub const DEFAULT_EDITION: &str = "2021";
pub const DEFAULT_FIXTURE_TIMEOUT_SECS: u32 = 90;
pub const DEFAULT_PER_FIXTURE_MEMORY_MB: u32 = 1024;
pub const ALLOWED_EDITIONS: &[&str] = &["2015", "2018", "2021", "2024"];
pub const DEFAULT_SUITE_NAME: &str = "default";
#[derive(Debug, Clone, Serialize)]
pub struct Config {
pub dylib_crate: String,
#[serde(default = "empty_toml_table")]
pub raw_metadata: toml::Value,
pub suites: Vec<Suite>,
}
#[derive(Deserialize)]
struct SerdeConfig {
dylib_crate: String,
#[serde(default = "empty_toml_table")]
raw_metadata: toml::Value,
suites: Vec<Suite>,
}
impl<'de> Deserialize<'de> for Config {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = SerdeConfig::deserialize(deserializer)?;
validate_build_targets_for_suites_for_serde(&raw.suites)
.map_err(serde::de::Error::custom)?;
Ok(Config {
dylib_crate: raw.dylib_crate,
raw_metadata: raw.raw_metadata,
suites: raw.suites,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suite {
pub name: String,
pub extern_crates: Vec<String>,
pub fixture_dirs: Vec<PathBuf>,
pub features: Vec<String>,
pub edition: String,
pub dev_deps: Vec<String>,
#[serde(default)]
pub build_targets: BuildTargets,
pub compile_fail_marker: String,
pub fixture_timeout_secs: u32,
pub per_fixture_memory_mb: u32,
pub allow_lints: Vec<String>,
pub extra_substitutions: Vec<Substitution>,
pub strip_lines: Vec<StripPattern>,
pub strip_line_prefixes: Vec<StripPattern>,
}
impl Suite {
pub fn is_default(&self) -> bool {
self.name == DEFAULT_SUITE_NAME
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct StripPattern(String);
impl StripPattern {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for StripPattern {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s.contains('\n') {
return Err(format!(
"\"{s}\" contains a newline character; strip patterns must be single-line."
));
}
if !is_path_like(&s) && !is_banner_shape(&s) {
return Err(format!(
"\"{s}\" is neither path-shaped nor banner-shaped \
(must contain '/', '\\\\', start with a $X placeholder token where X is an \
ASCII uppercase letter, OR match the banner allowlist — \
see docs/spec/lihaaf-v0.1.md §6.6). \
Patterns starting with '$' must have an ASCII uppercase letter immediately after, \
regardless of path separators — '$lowercase/path' is rejected. \
Bare placeholder patterns are full-string anchored: \
'$DIR-', '$RUST.', '$A!' are rejected; '$DIR/x' is accepted \
via the path-separator branch. \
Strip patterns target path-shaped environment noise OR known banner shapes only."
));
}
Ok(StripPattern(s))
}
}
impl From<StripPattern> for String {
fn from(p: StripPattern) -> String {
p.0
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "Vec<String>", into = "Vec<String>")]
pub struct BuildTargets(Vec<String>);
impl BuildTargets {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn as_slice(&self) -> &[String] {
&self.0
}
}
impl TryFrom<Vec<String>> for BuildTargets {
type Error = String;
fn try_from(targets: Vec<String>) -> Result<Self, Self::Error> {
let mut seen = std::collections::BTreeSet::new();
for target in &targets {
if target != "tests" {
return Err(format!(
"build_targets entry \"{target}\" is not supported. \
v0.1.0 accepts only \"tests\"."
));
}
if !seen.insert(target.as_str()) {
return Err(format!(
"build_targets contains duplicate target \"{target}\". \
Each build target may appear at most once."
));
}
}
Ok(BuildTargets(targets))
}
}
impl From<BuildTargets> for Vec<String> {
fn from(targets: BuildTargets) -> Vec<String> {
targets.0
}
}
fn empty_toml_table() -> toml::Value {
toml::Value::Table(toml::map::Map::new())
}
#[derive(Debug, Default, Deserialize)]
struct RawMetadata {
dylib_crate: Option<String>,
extern_crates: Option<Vec<String>>,
fixture_dirs: Option<Vec<String>>,
features: Option<Vec<String>>,
edition: Option<String>,
dev_deps: Option<Vec<String>>,
build_targets: Option<Vec<String>>,
compile_fail_marker: Option<String>,
fixture_timeout_secs: Option<u32>,
per_fixture_memory_mb: Option<u32>,
allow_lints: Option<Vec<String>>,
extra_substitutions: Option<Vec<RawSubstitution>>,
strip_lines: Option<Vec<String>>,
strip_line_prefixes: Option<Vec<String>>,
#[serde(default)]
suite: Vec<RawSuite>,
}
#[derive(Debug, Default, Deserialize)]
struct RawSuite {
name: Option<String>,
extern_crates: Option<Vec<String>>,
fixture_dirs: Option<Vec<String>>,
features: Option<Vec<String>>,
edition: Option<String>,
dev_deps: Option<Vec<String>>,
build_targets: Option<Vec<String>>,
compile_fail_marker: Option<String>,
fixture_timeout_secs: Option<u32>,
per_fixture_memory_mb: Option<u32>,
allow_lints: Option<Vec<String>>,
extra_substitutions: Option<Vec<RawSubstitution>>,
strip_lines: Option<Vec<String>>,
strip_line_prefixes: Option<Vec<String>>,
dylib_crate: Option<String>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub(crate) struct RawSubstitution {
pub(crate) from: Option<String>,
pub(crate) to: Option<String>,
}
impl TryFrom<RawSubstitution> for Substitution {
type Error = String;
fn try_from(raw: RawSubstitution) -> Result<Self, Self::Error> {
let from = raw.from.ok_or_else(|| {
"extra_substitutions entry is missing the required `from` key. \
Every entry must specify which substring to match."
.to_string()
})?;
let to = raw.to.ok_or_else(|| {
"extra_substitutions entry is missing the required `to` key. \
Use an empty string `to = \"\"` to strip the match."
.to_string()
})?;
if from.is_empty() {
return Err("extra_substitutions entry `from` is empty. \
An empty `from` would match the start of every byte and rewrite arbitrary \
content. extra_substitutions is for path-shaped substitution only. \
See docs/spec/lihaaf-v0.1.md §6.6."
.to_string());
}
if !is_path_like(&from) {
return Err(format!(
"extra_substitutions `from` = \"{from}\" is not path-like \
(must contain '/', '\\\\', or be a bare $X placeholder token \
where X is an ASCII uppercase letter). \
Patterns starting with '$' must have an ASCII uppercase letter \
immediately after, regardless of path separators — \
'$lowercase/path' is rejected. \
Bare placeholder patterns are full-string anchored: \
'$DIR-', '$RUST.', '$A!' are rejected; '$DIR/x' is accepted \
via the path-separator branch. \
extra_substitutions is for path-shaped substitution only, \
not arbitrary text rewriting. See docs/spec/lihaaf-v0.1.md §6.6.",
));
}
if to.contains('\n') {
return Err("extra_substitutions `to` contains a newline character; \
replacements must be single-line. \
A multi-line `to` would inject blank lines into normalized \
stderr and break snapshot determinism."
.to_string());
}
Ok(Substitution { from, to })
}
}
pub fn load(manifest_path: &Path) -> Result<Config, Error> {
let bytes = std::fs::read_to_string(manifest_path).map_err(|e| {
Error::io(
e,
"reading consumer Cargo.toml",
Some(manifest_path.to_path_buf()),
)
})?;
parse(&bytes, manifest_path)
}
pub fn parse(toml_text: &str, manifest_path: &Path) -> Result<Config, Error> {
let value: toml::Value =
toml::from_str(toml_text).map_err(|e: toml::de::Error| Error::TomlParse {
path: manifest_path.to_path_buf(),
message: e.to_string(),
})?;
let raw_metadata_value = value
.get("package")
.and_then(|v| v.get("metadata"))
.and_then(|v| v.get("lihaaf"))
.cloned()
.ok_or_else(|| {
Error::Session(Outcome::ConfigInvalid {
message: missing_metadata_message(),
})
})?;
let raw: RawMetadata =
raw_metadata_value
.clone()
.try_into()
.map_err(|e: toml::de::Error| {
Error::Session(Outcome::ConfigInvalid {
message: format!(
"[package.metadata.lihaaf] could not be parsed.\n {e}\nWhy this matters: the harness needs typed values to dispatch fixtures."
),
})
})?;
let dylib_crate = raw.dylib_crate.clone().unwrap_or_default();
if dylib_crate.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format_invalid_key(
"dylib_crate",
"a non-empty workspace-member crate name",
"lihaaf needs to know which crate to build as the dylib",
),
}));
}
validate_dylib_crate(&dylib_crate)?;
let default_suite = build_default_suite(&dylib_crate, &raw)?;
let mut suites = Vec::with_capacity(1 + raw.suite.len());
suites.push(default_suite);
for (idx, raw_suite) in raw.suite.into_iter().enumerate() {
let suite = {
let default = &suites[0];
finalize_named_suite(&dylib_crate, default, idx, raw_suite)?
};
suites.push(suite);
}
validate_unique_suite_names(&suites)?;
validate_disjoint_fixture_dirs(manifest_path, &suites)?;
validate_build_targets_for_suites(&suites)?;
Ok(Config {
dylib_crate,
raw_metadata: raw_metadata_value,
suites,
})
}
fn build_default_suite(dylib_crate: &str, raw: &RawMetadata) -> Result<Suite, Error> {
let extern_crates = raw.extern_crates.clone().unwrap_or_default();
if extern_crates.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format_invalid_key(
"extern_crates",
"a non-empty array of crate names; the first must equal `dylib_crate`",
"every fixture compiles with one --extern <name>=<path> per entry",
),
}));
}
if extern_crates[0] != dylib_crate {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"extern_crates[0] (\"{}\") must equal dylib_crate (\"{}\").\nWhy this matters: the dylib's `--extern` flag is the link the fixture takes back to the consumer crate.",
extern_crates[0], dylib_crate
),
}));
}
let fixture_dirs: Vec<PathBuf> = raw
.fixture_dirs
.clone()
.unwrap_or_else(|| DEFAULT_FIXTURE_DIRS.iter().map(|s| s.to_string()).collect())
.into_iter()
.map(PathBuf::from)
.collect();
let edition = raw
.edition
.clone()
.unwrap_or_else(|| DEFAULT_EDITION.to_string());
validate_edition(DEFAULT_SUITE_NAME, &edition)?;
let fixture_timeout_secs = raw
.fixture_timeout_secs
.unwrap_or(DEFAULT_FIXTURE_TIMEOUT_SECS);
if fixture_timeout_secs == 0 {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format_invalid_key(
"fixture_timeout_secs",
"a positive integer (seconds of wall-clock per fixture)",
"a zero timeout would kill every fixture immediately",
),
}));
}
let per_fixture_memory_mb = raw
.per_fixture_memory_mb
.unwrap_or(DEFAULT_PER_FIXTURE_MEMORY_MB);
if per_fixture_memory_mb == 0 {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format_invalid_key(
"per_fixture_memory_mb",
"a positive integer (megabytes per fixture)",
"a zero ceiling would kill every fixture instantly",
),
}));
}
let allow_lints = raw.allow_lints.clone().unwrap_or_default();
validate_allow_lints(DEFAULT_SUITE_NAME, &allow_lints)?;
let features = raw.features.clone().unwrap_or_default();
validate_features(DEFAULT_SUITE_NAME, &features)?;
let extra_substitutions = finalize_substitutions(
DEFAULT_SUITE_NAME,
raw.extra_substitutions.clone().unwrap_or_default(),
)?;
validate_extra_substitutions(DEFAULT_SUITE_NAME, &extra_substitutions)?;
let strip_lines_raw = raw.strip_lines.clone().unwrap_or_default();
validate_strip_patterns(DEFAULT_SUITE_NAME, "strip_lines", &strip_lines_raw)?;
let strip_lines: Vec<StripPattern> = strip_lines_raw.into_iter().map(StripPattern).collect();
let strip_line_prefixes_raw = raw.strip_line_prefixes.clone().unwrap_or_default();
validate_strip_patterns(
DEFAULT_SUITE_NAME,
"strip_line_prefixes",
&strip_line_prefixes_raw,
)?;
let strip_line_prefixes: Vec<StripPattern> = strip_line_prefixes_raw
.into_iter()
.map(StripPattern)
.collect();
Ok(Suite {
name: DEFAULT_SUITE_NAME.to_string(),
extern_crates,
fixture_dirs,
features,
edition,
dev_deps: raw.dev_deps.clone().unwrap_or_default(),
build_targets: raw
.build_targets
.clone()
.unwrap_or_default()
.try_into()
.map_err(|message| Error::Session(Outcome::ConfigInvalid { message }))?,
compile_fail_marker: raw
.compile_fail_marker
.clone()
.unwrap_or_else(|| DEFAULT_COMPILE_FAIL_MARKER.to_string()),
fixture_timeout_secs,
per_fixture_memory_mb,
allow_lints,
extra_substitutions,
strip_lines,
strip_line_prefixes,
})
}
fn finalize_named_suite(
dylib_crate: &str,
default_suite: &Suite,
index: usize,
raw: RawSuite,
) -> Result<Suite, Error> {
if raw.dylib_crate.is_some() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"[[package.metadata.lihaaf.suite]] entry #{index} sets `dylib_crate`, which is not a per-suite key.\nWhy this matters: lihaaf builds one consumer crate per session; the suite system varies the FEATURE SET passed to that crate, not the crate identity."
),
}));
}
let name = raw.name.unwrap_or_default();
validate_named_suite_name(index, &name)?;
let extern_crates = raw
.extern_crates
.unwrap_or_else(|| default_suite.extern_crates.clone());
if extern_crates.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"suite \"{name}\".extern_crates is empty.\nWhy this matters: every fixture needs at least one --extern flag (the dylib_crate)."
),
}));
}
if extern_crates[0] != dylib_crate {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"suite \"{name}\".extern_crates[0] (\"{}\") must equal dylib_crate (\"{}\").\nWhy this matters: the dylib's `--extern` flag is the link the fixture takes back to the consumer crate.",
extern_crates[0], dylib_crate
),
}));
}
let raw_dirs = raw.fixture_dirs.ok_or_else(|| {
Error::Session(Outcome::ConfigInvalid {
message: format!(
"suite \"{name}\".fixture_dirs is required.\nWhy this matters: a named suite must declare its own fixture directories so its snapshot files don't collide with another suite's."
),
})
})?;
if raw_dirs.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"suite \"{name}\".fixture_dirs is an empty array.\nWhy this matters: a named suite that runs zero fixtures contributes no signal."
),
}));
}
let fixture_dirs: Vec<PathBuf> = raw_dirs.into_iter().map(PathBuf::from).collect();
let edition = raw.edition.unwrap_or_else(|| default_suite.edition.clone());
validate_edition(&name, &edition)?;
let fixture_timeout_secs = raw
.fixture_timeout_secs
.unwrap_or(default_suite.fixture_timeout_secs);
if fixture_timeout_secs == 0 {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"suite \"{name}\".fixture_timeout_secs must be a positive integer.\nWhy this matters: a zero timeout would kill every fixture immediately."
),
}));
}
let per_fixture_memory_mb = raw
.per_fixture_memory_mb
.unwrap_or(default_suite.per_fixture_memory_mb);
if per_fixture_memory_mb == 0 {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"suite \"{name}\".per_fixture_memory_mb must be a positive integer.\nWhy this matters: a zero ceiling would kill every fixture instantly."
),
}));
}
let allow_lints = raw
.allow_lints
.unwrap_or_else(|| default_suite.allow_lints.clone());
validate_allow_lints(&name, &allow_lints)?;
let features = raw.features.unwrap_or_default();
validate_features(&name, &features)?;
let extra_substitutions =
finalize_substitutions(&name, raw.extra_substitutions.unwrap_or_default())?;
validate_extra_substitutions(&name, &extra_substitutions)?;
let strip_lines_raw = raw.strip_lines.unwrap_or_default();
validate_strip_patterns(&name, "strip_lines", &strip_lines_raw)?;
let strip_lines: Vec<StripPattern> = strip_lines_raw.into_iter().map(StripPattern).collect();
let strip_line_prefixes_raw = raw.strip_line_prefixes.unwrap_or_default();
validate_strip_patterns(&name, "strip_line_prefixes", &strip_line_prefixes_raw)?;
let strip_line_prefixes: Vec<StripPattern> = strip_line_prefixes_raw
.into_iter()
.map(StripPattern)
.collect();
Ok(Suite {
name,
extern_crates,
fixture_dirs,
features,
edition,
dev_deps: raw
.dev_deps
.unwrap_or_else(|| default_suite.dev_deps.clone()),
build_targets: raw
.build_targets
.unwrap_or_default()
.try_into()
.map_err(|message| Error::Session(Outcome::ConfigInvalid { message }))?,
compile_fail_marker: raw
.compile_fail_marker
.unwrap_or_else(|| default_suite.compile_fail_marker.clone()),
fixture_timeout_secs,
per_fixture_memory_mb,
allow_lints,
extra_substitutions,
strip_lines,
strip_line_prefixes,
})
}
fn validate_named_suite_name(index: usize, name: &str) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"[[package.metadata.lihaaf.suite]] entry #{index} is missing the required `name` key.\nWhy this matters: lihaaf addresses suites by name on the CLI (`--suite NAME`) and in per-suite manifest paths."
),
}));
}
if name == DEFAULT_SUITE_NAME {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"[[package.metadata.lihaaf.suite]] name \"{DEFAULT_SUITE_NAME}\" is reserved for the implicit suite built from the top-level [package.metadata.lihaaf] table.\nWhy this matters: a CLI invocation `--suite default` would be ambiguous if a named suite also claimed the name."
),
}));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"[[package.metadata.lihaaf.suite]] name \"{name}\" must contain only ASCII alphanumeric characters, hyphens, or underscores.\nWhy this matters: the suite name is used in filesystem paths (`target/lihaaf/manifest-<name>.json`, `target/lihaaf-build-<name>/`) and on the CLI."
),
}));
}
Ok(())
}
fn validate_edition(suite_label: &str, edition: &str) -> Result<(), Error> {
if !ALLOWED_EDITIONS.contains(&edition) {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.edition \"{edition}\" is not in the allowed set ({}).\nWhy this matters: rustc's `--edition` accepts only those values.",
ALLOWED_EDITIONS.join(", ")
),
}));
}
Ok(())
}
fn validate_build_targets_for_suites(suites: &[Suite]) -> Result<(), Error> {
validate_build_targets_for_suites_for_serde(suites)
.map_err(|message| Error::Session(Outcome::ConfigInvalid { message }))
}
fn validate_build_targets_for_suites_for_serde(suites: &[Suite]) -> Result<(), String> {
for suite in suites {
if !suite.build_targets.is_empty() && suite.dev_deps.is_empty() {
return Err(format!(
"suite \"{}\" sets build_targets = {:?} but has no final dev_deps.\n\
Why this matters: build_targets = [\"tests\"] asks lihaaf to collect \
fixture-only dependency artifacts, but there is no dependency graph to collect.",
suite.name,
suite.build_targets.as_slice()
));
}
}
Ok(())
}
fn validate_allow_lints(suite_label: &str, lints: &[String]) -> Result<(), Error> {
for lint in lints {
if lint.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.allow_lints contains an empty string.\n\
Why this matters: an empty string is not a valid lint name and would produce an unrecognized flag on the rustc argv."
),
}));
}
if lint.contains('\0') {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.allow_lints entry contains a NUL byte.\n\
Why this matters: an interior NUL byte cannot appear in a POSIX argv token; \
spawn would reject the argv and the failure would surface as WORKER_CRASHED \
instead of an actionable CONFIG_INVALID."
),
}));
}
if lint.starts_with('-') {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.allow_lints entry \"{lint}\" starts with `-`.\n\
Why this matters: lihaaf supplies the `-A` prefix itself; including it in the entry would produce `-A -A <lint>` on the rustc argv."
),
}));
}
if lint
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '\'' || c == '\\')
{
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.allow_lints entry \"{lint}\" contains whitespace, quotes, or a backslash.\n\
Why this matters: each entry must be a single argv token; whitespace or shell-meta characters would either break argv tokenization or smuggle extra flags past rustc's argument parser."
),
}));
}
}
Ok(())
}
fn validate_features(suite_label: &str, features: &[String]) -> Result<(), Error> {
for feature in features {
if feature.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.features contains an empty string.\n\
Why this matters: each entry must be a single argv token forwarded as \
`--features` to cargo and `--cfg feature=\"...\"` to rustc; an empty \
string is not a valid feature name."
),
}));
}
if feature.contains('\0') {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.features entry contains a NUL byte.\n\
Why this matters: each entry must be a single argv token forwarded as \
`--features` to cargo and `--cfg feature=\"...\"` to rustc; an interior \
NUL byte cannot appear in a POSIX argv token and spawn would reject it, \
surfacing as WORKER_CRASHED instead of an actionable CONFIG_INVALID."
),
}));
}
}
Ok(())
}
fn is_path_like(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 2 {
return false;
}
if s.contains('\n') {
return false;
}
if bytes[0] == b'$' && !bytes[1].is_ascii_uppercase() {
return false;
}
if s.contains('/') || s.contains('\\') {
return true;
}
is_complete_placeholder_token(s)
}
fn is_complete_placeholder_token(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 2 {
return false;
}
if bytes[0] != b'$' {
return false;
}
if !bytes[1].is_ascii_uppercase() {
return false;
}
for &b in &bytes[2..] {
if !(b.is_ascii_alphanumeric() || b == b'_') {
return false;
}
}
true
}
const BANNER_ANTI_PREFIXES: &[&str] = &[
"expected ",
"found ",
"the trait ",
"the type ",
"cannot find ",
"mismatched types",
"consider ",
"help: ",
"warning: ",
"error[",
" ", ];
const BANNER_PREFIXES: &[&str] = &[
"For more information about this error",
"error: aborting due to ",
"note: this error originates from ",
"info: ",
"linker version: ",
];
const STRUCTURAL_BANNER_MARKERS: &[&str] = &[
"deprecated",
"deprecation",
"Please update",
"actions to use",
"EOL",
"end-of-life",
];
fn is_banner_shape(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 20 {
return false;
}
if s.contains('\n') {
return false;
}
if bytes[0] == b' ' || bytes[0] == b'\t' {
return false;
}
if bytes[0] == b'^' || bytes[0] == b'=' || bytes[0] == b'|' {
return false;
}
if BANNER_ANTI_PREFIXES.iter().any(|p| s.starts_with(*p)) {
return false;
}
if BANNER_PREFIXES.iter().any(|p| s.starts_with(*p)) {
return true;
}
if bytes.len() >= 40
&& bytes[0].is_ascii_uppercase()
&& s.contains(' ')
&& STRUCTURAL_BANNER_MARKERS.iter().any(|m| s.contains(*m))
{
return true;
}
false
}
fn validate_extra_substitutions(suite_label: &str, subs: &[Substitution]) -> Result<(), Error> {
for (i, sub) in subs.iter().enumerate() {
if sub.from.is_empty() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.extra_substitutions[{i}].from is empty.\n\
Why this matters: an empty `from` would match the start of every byte and rewrite arbitrary content.\n\
extra_substitutions is for path-shaped substitution only. See docs/spec/lihaaf-v0.1.md §6.6."
),
}));
}
if !is_path_like(&sub.from) {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.extra_substitutions[{i}].from = \"{from}\" is not path-like \
(must contain '/', '\\\\', or be a bare $X placeholder token, \
where X is an ASCII uppercase letter). \
Patterns starting with '$' must have an ASCII uppercase letter \
immediately after, regardless of path separators — '$lowercase/path' is rejected. \
Bare placeholder patterns are full-string anchored: '$DIR-', '$RUST.', '$A!' \
are rejected; '$DIR/x' is accepted via the path-separator branch. \
extra_substitutions is for path-shaped substitution only, \
not arbitrary text rewriting. See docs/spec/lihaaf-v0.1.md §6.6.",
from = sub.from,
),
}));
}
if sub.to.contains('\n') {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.extra_substitutions[{i}].to contains a newline character; \
replacements must be single-line.\n\
Why this matters: a multi-line `to` would inject blank lines into normalized stderr and break snapshot determinism."
),
}));
}
}
Ok(())
}
fn validate_strip_patterns(
suite_label: &str,
key_label: &str,
patterns: &[String],
) -> Result<(), Error> {
for (i, pat) in patterns.iter().enumerate() {
if pat.contains('\n') {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.{key_label}[{i}] contains a newline character; \
strip patterns must be single-line."
),
}));
}
if !is_path_like(pat) && !is_banner_shape(pat) {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.{key_label}[{i}] = \"{pat}\" is neither path-shaped nor banner-shaped \
(must contain '/', '\\\\', start with a $X placeholder token where X is an \
ASCII uppercase letter, OR match the banner allowlist — see docs/spec/lihaaf-v0.1.md §6.6). \
Patterns starting with '$' must have an ASCII uppercase letter immediately after, \
regardless of path separators — '$lowercase/path' is rejected. \
Bare placeholder patterns are full-string anchored: '$DIR-', '$RUST.', '$A!' \
are rejected; '$DIR/x' is accepted via the path-separator branch. \
Strip patterns target path-shaped environment noise OR known banner shapes only."
),
}));
}
}
Ok(())
}
fn finalize_substitutions(
suite_label: &str,
raw: Vec<RawSubstitution>,
) -> Result<Vec<Substitution>, Error> {
let mut out = Vec::with_capacity(raw.len());
for (i, entry) in raw.into_iter().enumerate() {
let from = entry.from.ok_or_else(|| {
Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.extra_substitutions[{i}] is missing the required `from` key.\n\
Why this matters: every entry must specify which substring to match."
),
})
})?;
let to = entry.to.ok_or_else(|| {
Error::Session(Outcome::ConfigInvalid {
message: format!(
"{suite_label}.extra_substitutions[{i}] is missing the required `to` key.\n\
Why this matters: every entry must specify the replacement string \
(use an empty string `to = \"\"` to strip the match)."
),
})
})?;
out.push(Substitution { from, to });
}
Ok(out)
}
fn validate_dylib_crate(dylib_crate: &str) -> Result<(), Error> {
if dylib_crate.contains('\0') {
return Err(Error::Session(Outcome::ConfigInvalid {
message: "[package.metadata.lihaaf].dylib_crate contains a NUL byte.\n\
Why this matters: the crate name is forwarded as a `-p` argv token to cargo; \
an interior NUL byte cannot appear in a POSIX argv token and spawn would reject \
it, surfacing as WORKER_CRASHED instead of an actionable CONFIG_INVALID."
.to_string(),
}));
}
Ok(())
}
fn validate_unique_suite_names(suites: &[Suite]) -> Result<(), Error> {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for s in suites {
if !seen.insert(s.name.as_str()) {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"duplicate suite name \"{}\".\nWhy this matters: suite names are how the CLI selects which suite to run.",
s.name
),
}));
}
}
Ok(())
}
fn validate_disjoint_fixture_dirs(manifest_path: &Path, suites: &[Suite]) -> Result<(), Error> {
let crate_root = derive_manifest_root(manifest_path);
let mut seen: Vec<(&str, PathBuf, PathBuf)> = Vec::new();
for suite in suites {
for dir in &suite.fixture_dirs {
let key = fixture_dir_key(&crate_root, dir);
for (other_suite, other_dir, other_key) in &seen {
if *other_key == key {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"fixture_dirs path \"{}\" in suite \"{}\" resolves to the same directory as \"{}\" in suite \"{other_suite}\".\nWhy this matters: snapshot files (.stderr) live next to the .rs fixtures; two suites sharing a directory would write conflicting snapshots.",
dir.display(),
suite.name,
other_dir.display()
),
}));
}
}
seen.push((suite.name.as_str(), dir.clone(), key));
}
}
Ok(())
}
fn derive_manifest_root(manifest_path: &Path) -> PathBuf {
match manifest_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn fixture_dir_key(crate_root: &Path, dir: &Path) -> PathBuf {
let joined = if dir.is_absolute() {
dir.to_path_buf()
} else {
crate_root.join(dir)
};
lexical_normalize(&joined)
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push(component.as_os_str());
}
}
Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
out.push(component.as_os_str());
}
}
}
if out.as_os_str().is_empty() {
PathBuf::from(".")
} else {
out
}
}
fn format_invalid_key(key: &str, expected: &str, why: &str) -> String {
format!("[package.metadata.lihaaf].{key} must be {expected}.\nWhy this matters: {why}.")
}
fn missing_metadata_message() -> String {
"lihaaf needs `[package.metadata.lihaaf]` to know what to build.\n\
Add the table to your Cargo.toml. See the lihaaf README for the\n\
minimum required keys."
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(toml_text: &str) -> Result<Config, Error> {
parse(toml_text, Path::new("Cargo.toml"))
}
fn unwrap_invalid(err: Error) -> String {
match err {
Error::Session(Outcome::ConfigInvalid { message }) => message,
other => panic!("expected ConfigInvalid, got {other:?}"),
}
}
fn assert_parse_rejects_with(toml: &str, expected_substrings: &[&str]) {
let err = parse_str(toml).unwrap_err();
let msg = unwrap_invalid(err);
for expected in expected_substrings {
assert!(
msg.contains(expected),
"error message `{msg}` did not contain expected substring `{expected}`",
);
}
}
#[test]
fn missing_table_is_session_outcome_with_exact_message() {
assert_parse_rejects_with(
r#"
[package]
name = "x"
version = "0.1.0"
"#,
&["`[package.metadata.lihaaf]`", "minimum required keys"],
);
}
#[test]
fn missing_dylib_crate_is_invalid() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
extern_crates = ["foo"]
"#,
&["dylib_crate"],
);
}
#[test]
fn extern_crates_first_must_equal_dylib() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["other"]
"#,
&["extern_crates[0]"],
);
}
#[test]
fn defaults_apply_to_optional_keys_and_yield_one_default_suite() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
"#,
)
.unwrap();
assert_eq!(cfg.dylib_crate, "consumer");
assert_eq!(cfg.suites.len(), 1);
let s = &cfg.suites[0];
assert!(s.is_default());
assert_eq!(s.name, DEFAULT_SUITE_NAME);
assert_eq!(s.edition, "2021");
assert_eq!(s.compile_fail_marker, "compile_fail");
assert_eq!(s.fixture_timeout_secs, 90);
assert_eq!(s.per_fixture_memory_mb, 1024);
assert_eq!(s.fixture_dirs.len(), 2);
assert!(s.features.is_empty());
assert!(s.dev_deps.is_empty());
}
#[test]
fn edition_must_be_in_allowed_set() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
edition = "2026"
"#,
&["edition", "2024"],
);
}
#[test]
fn zero_timeout_is_invalid() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
fixture_timeout_secs = 0
"#,
&["fixture_timeout_secs"],
);
}
#[test]
fn zero_memory_ceiling_is_invalid() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
per_fixture_memory_mb = 0
"#,
&["per_fixture_memory_mb"],
);
}
#[test]
fn raw_metadata_is_preserved_verbatim() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer", "consumer-macros"]
features = ["testing"]
dev_deps = ["serde", "serde_json"]
"#,
)
.unwrap();
let table = cfg.raw_metadata.as_table().unwrap();
assert!(table.contains_key("dylib_crate"));
assert!(table.contains_key("extern_crates"));
assert!(table.contains_key("features"));
assert!(table.contains_key("dev_deps"));
}
#[test]
fn named_suite_inherits_unspecified_keys_from_default() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer", "consumer-macros"]
edition = "2024"
dev_deps = ["serde"]
compile_fail_marker = "compile_fail"
fixture_timeout_secs = 120
per_fixture_memory_mb = 2048
allow_lints = ["dead_code"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
features = ["spatial"]
fixture_dirs = ["tests/lihaaf/compile_pass_spatial"]
"#,
)
.unwrap();
assert_eq!(cfg.suites.len(), 2);
let spatial = &cfg.suites[1];
assert_eq!(spatial.name, "spatial");
assert_eq!(spatial.features, vec!["spatial".to_string()]);
assert_eq!(spatial.edition, "2024");
assert_eq!(spatial.dev_deps, vec!["serde".to_string()]);
assert_eq!(spatial.compile_fail_marker, "compile_fail");
assert_eq!(spatial.fixture_timeout_secs, 120);
assert_eq!(spatial.per_fixture_memory_mb, 2048);
assert_eq!(
spatial.extern_crates,
vec!["consumer".to_string(), "consumer-macros".to_string()]
);
assert_eq!(spatial.allow_lints, vec!["dead_code".to_string()]);
}
#[test]
fn named_suite_features_do_not_inherit_from_default() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
features = ["testing"]
[[package.metadata.lihaaf.suite]]
name = "isolated"
fixture_dirs = ["tests/lihaaf/iso"]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].features, vec!["testing".to_string()]);
assert!(cfg.suites[1].features.is_empty());
}
#[test]
fn named_suite_can_override_features() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
features = ["testing"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
features = ["spatial"]
fixture_dirs = ["tests/lihaaf/compile_pass_spatial"]
"#,
)
.unwrap();
assert_eq!(cfg.suites[1].features, vec!["spatial".to_string()]);
}
#[test]
fn named_suite_dylib_crate_is_rejected() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
dylib_crate = "other"
fixture_dirs = ["tests/lihaaf/spatial"]
"#,
&["dylib_crate", "not a per-suite key"],
);
}
#[test]
fn named_suite_default_is_reserved() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "default"
fixture_dirs = ["tests/lihaaf/default_extra"]
"#,
&["\"default\"", "reserved"],
);
}
#[test]
fn named_suite_missing_name_is_rejected() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
fixture_dirs = ["tests/lihaaf/x"]
"#,
&["entry #0", "name"],
);
}
#[test]
fn named_suite_invalid_chars_in_name_rejected() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "with space"
fixture_dirs = ["tests/lihaaf/space"]
"#,
&["ASCII alphanumeric"],
);
}
#[test]
fn named_suite_missing_fixture_dirs_is_rejected() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
features = ["spatial"]
"#,
&["fixture_dirs", "required"],
);
}
#[test]
fn named_suite_empty_fixture_dirs_is_rejected() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
fixture_dirs = []
"#,
&["empty array"],
);
}
#[test]
fn duplicate_suite_names_rejected() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
fixture_dirs = ["tests/lihaaf/a"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
fixture_dirs = ["tests/lihaaf/b"]
"#,
&["duplicate suite name", "\"spatial\""],
);
}
#[test]
fn fixture_dirs_must_be_disjoint_across_suites() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
fixture_dirs = ["tests/lihaaf/shared"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
fixture_dirs = ["tests/lihaaf/shared"]
"#,
&["shared", "default", "spatial"],
);
}
#[test]
fn fixture_dirs_must_be_disjoint_after_dot_normalization() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
fixture_dirs = ["tests/lihaaf/shared"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
fixture_dirs = ["./tests/lihaaf/shared"]
"#,
&["resolves to the same directory", "default", "spatial"],
);
}
#[test]
fn fixture_dirs_must_be_disjoint_after_absolute_resolution() {
let root = std::env::current_dir().unwrap();
let abs = root.join("tests/lihaaf/shared");
let toml = format!(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
fixture_dirs = ["tests/lihaaf/shared"]
[[package.metadata.lihaaf.suite]]
name = "spatial"
fixture_dirs = ['{}']
"#,
abs.display()
);
let err = parse(&toml, &root.join("Cargo.toml")).unwrap_err();
let msg = unwrap_invalid(err);
assert!(msg.contains("resolves to the same directory"));
assert!(msg.contains("default"));
assert!(msg.contains("spatial"));
}
#[test]
fn fixture_dirs_must_be_disjoint_between_two_named_suites() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "alpha"
fixture_dirs = ["tests/lihaaf/x"]
[[package.metadata.lihaaf.suite]]
name = "beta"
fixture_dirs = ["tests/lihaaf/x"]
"#,
&["alpha", "beta"],
);
}
#[test]
fn declared_suite_order_is_preserved() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "second"
fixture_dirs = ["tests/lihaaf/b"]
[[package.metadata.lihaaf.suite]]
name = "first"
fixture_dirs = ["tests/lihaaf/a"]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].name, DEFAULT_SUITE_NAME);
assert_eq!(cfg.suites[1].name, "second");
assert_eq!(cfg.suites[2].name, "first");
}
#[test]
fn allow_lints_default_is_empty() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
"#,
)
.unwrap();
assert!(
cfg.suites[0].allow_lints.is_empty(),
"allow_lints must default to an empty vec when the key is absent"
);
}
#[test]
fn allow_lints_accepts_simple_lint_names() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["unexpected_cfgs", "dead_code"]
"#,
)
.unwrap();
assert_eq!(
cfg.suites[0].allow_lints,
vec!["unexpected_cfgs".to_string(), "dead_code".to_string()]
);
}
#[test]
fn allow_lints_accepts_clippy_namespaced_lints() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["clippy::needless_collect"]
"#,
)
.unwrap();
assert_eq!(
cfg.suites[0].allow_lints,
vec!["clippy::needless_collect".to_string()]
);
}
#[test]
fn allow_lints_rejects_empty_string() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = [""]
"#,
&["allow_lints", "empty string"],
);
}
#[test]
fn allow_lints_rejects_leading_dash() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["-A unexpected_cfgs"]
"#,
&["allow_lints", "starts with `-`"],
);
}
#[test]
fn allow_lints_rejects_whitespace() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["dead code"]
"#,
&["allow_lints", "whitespace"],
);
}
#[test]
fn allow_lints_rejects_quote_and_backslash() {
for (toml_value, bad_label) in &[
(r#"'a"b'"#, r#"a"b"#), (r#""a'b""#, "a'b"), (r#"'a\b'"#, r"a\b"), ] {
let toml = format!(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = [{toml_value}]
"#,
);
let err = parse_str(&toml).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("allow_lints"),
"error for entry `{bad_label}` did not mention `allow_lints`: {msg}"
);
assert!(
msg.contains("whitespace"),
"error for entry `{bad_label}` did not mention `whitespace`: {msg}"
);
}
}
#[test]
fn allow_lints_rejects_nul_byte() {
let lints = vec![format!("bad{}lint", '\u{0}')];
let err = validate_allow_lints("default", &lints).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("allow_lints"),
"error did not mention `allow_lints`: {msg}"
);
assert!(msg.contains("NUL"), "error did not mention `NUL`: {msg}");
}
#[test]
fn features_rejects_nul_byte() {
let features = vec![format!("bad{}feat", '\u{0}')];
let err = validate_features("default", &features).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("features"),
"error did not mention `features`: {msg}"
);
assert!(msg.contains("NUL"), "error did not mention `NUL`: {msg}");
}
#[test]
fn features_rejects_empty_string() {
assert_parse_rejects_with(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
features = [""]
"#,
&["features", "empty string"],
);
}
#[test]
fn dylib_crate_rejects_nul_byte() {
let name = format!("con{}sumer", '\u{0}');
let err = validate_dylib_crate(&name).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("dylib_crate"),
"error did not mention `dylib_crate`: {msg}"
);
assert!(msg.contains("NUL"), "error did not mention `NUL`: {msg}");
}
#[test]
fn allow_lints_named_suite_overrides_default() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["dead_code"]
[[package.metadata.lihaaf.suite]]
name = "extra"
fixture_dirs = ["tests/lihaaf/extra"]
allow_lints = ["unused"]
"#,
)
.unwrap();
assert_eq!(
cfg.suites[1].allow_lints,
vec!["unused".to_string()],
"named suite allow_lints must replace the default, not merge"
);
assert_eq!(cfg.suites[0].allow_lints, vec!["dead_code".to_string()]);
}
#[test]
fn allow_lints_named_suite_empty_array_overrides_to_empty() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["dead_code"]
[[package.metadata.lihaaf.suite]]
name = "strict"
fixture_dirs = ["tests/lihaaf/strict"]
allow_lints = []
"#,
)
.unwrap();
assert!(
cfg.suites[1].allow_lints.is_empty(),
"explicit empty allow_lints on named suite must override to empty"
);
}
#[test]
fn raw_metadata_preserves_allow_lints() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
allow_lints = ["unused_imports", "dead_code"]
"#,
)
.unwrap();
let table = cfg.raw_metadata.as_table().unwrap();
assert!(
table.contains_key("allow_lints"),
"raw_metadata must preserve the allow_lints key verbatim for the manifest snapshot"
);
let lints = table["allow_lints"].as_array().unwrap();
let lint_strs: Vec<&str> = lints.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(lint_strs, vec!["unused_imports", "dead_code"]);
}
#[test]
fn build_targets_default_and_named_omission_parse_as_empty() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
[[package.metadata.lihaaf.suite]]
name = "named"
fixture_dirs = ["tests/lihaaf/named"]
"#,
)
.unwrap();
assert!(cfg.suites[0].build_targets.is_empty());
assert!(cfg.suites[1].build_targets.is_empty());
}
#[test]
fn build_targets_uses_replacement_semantics_while_dev_deps_inherit() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
dev_deps = ["serde"]
build_targets = ["tests"]
[[package.metadata.lihaaf.suite]]
name = "named"
fixture_dirs = ["tests/lihaaf/named"]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].build_targets.as_slice(), ["tests"]);
assert!(cfg.suites[1].build_targets.is_empty());
assert_eq!(cfg.suites[1].dev_deps, vec!["serde"]);
}
#[test]
fn build_targets_validation_runs_after_dev_dep_inheritance() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
dev_deps = ["serde"]
[[package.metadata.lihaaf.suite]]
name = "named"
fixture_dirs = ["tests/lihaaf/named"]
build_targets = ["tests"]
"#,
)
.unwrap();
assert_eq!(cfg.suites[1].build_targets.as_slice(), ["tests"]);
assert_eq!(cfg.suites[1].dev_deps, vec!["serde"]);
}
#[test]
fn build_targets_reject_empty_dev_deps_unknown_and_duplicates() {
let empty_deps = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
build_targets = ["tests"]
"#,
)
.unwrap_err();
assert!(unwrap_invalid(empty_deps).contains("dev_deps"));
let unknown = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
dev_deps = ["serde"]
build_targets = ["benches"]
"#,
)
.unwrap_err();
assert!(unwrap_invalid(unknown).contains("tests"));
let duplicate = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
dev_deps = ["serde"]
build_targets = ["tests", "tests"]
"#,
)
.unwrap_err();
assert!(unwrap_invalid(duplicate).contains("duplicate"));
}
#[test]
fn extra_substitutions_per_suite_replace_not_merge() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{ from = "/default/path", to = "$WORKSPACE/d" },
]
[[package.metadata.lihaaf.suite]]
name = "extra"
fixture_dirs = ["tests/lihaaf/extra"]
extra_substitutions = [
{ from = "/named/path", to = "$WORKSPACE/n" },
]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].extra_substitutions.len(), 1);
assert_eq!(cfg.suites[0].extra_substitutions[0].from, "/default/path");
assert_eq!(cfg.suites[1].extra_substitutions.len(), 1);
assert_eq!(cfg.suites[1].extra_substitutions[0].from, "/named/path");
}
#[test]
fn extra_substitutions_omitted_on_named_suite_is_empty() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{ from = "/default/path", to = "$WORKSPACE/d" },
]
[[package.metadata.lihaaf.suite]]
name = "isolated"
fixture_dirs = ["tests/lihaaf/iso"]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].extra_substitutions.len(), 1);
assert!(
cfg.suites[1].extra_substitutions.is_empty(),
"named suite that omits extra_substitutions must get [], not inherit",
);
}
#[test]
fn extra_substitutions_manifest_snapshot_round_trips() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{ from = "/nix/store/abc", to = "$RUST/lib" },
]
strip_lines = ["error: aborting due to 1 previous error"]
strip_line_prefixes = ["For more information about this error"]
"#,
)
.unwrap();
let table = cfg.raw_metadata.as_table().unwrap();
assert!(
table.contains_key("extra_substitutions"),
"raw_metadata must preserve extra_substitutions verbatim",
);
assert!(table.contains_key("strip_lines"));
assert!(table.contains_key("strip_line_prefixes"));
}
#[test]
fn strip_patterns_per_suite_replace_not_merge() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_lines = ["/default/strip"]
strip_line_prefixes = ["/default/prefix/"]
[[package.metadata.lihaaf.suite]]
name = "extra"
fixture_dirs = ["tests/lihaaf/extra"]
strip_lines = ["/named/strip"]
strip_line_prefixes = ["/named/prefix/"]
"#,
)
.unwrap();
assert_eq!(
cfg.suites[0]
.strip_lines
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>(),
vec!["/default/strip"]
);
assert_eq!(
cfg.suites[0]
.strip_line_prefixes
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>(),
vec!["/default/prefix/"]
);
assert_eq!(
cfg.suites[1]
.strip_lines
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>(),
vec!["/named/strip"]
);
assert_eq!(
cfg.suites[1]
.strip_line_prefixes
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>(),
vec!["/named/prefix/"]
);
}
#[test]
fn strip_patterns_omitted_on_named_suite_is_empty() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_lines = ["/default/strip"]
strip_line_prefixes = ["/default/prefix/"]
[[package.metadata.lihaaf.suite]]
name = "isolated"
fixture_dirs = ["tests/lihaaf/iso"]
"#,
)
.unwrap();
assert!(cfg.suites[1].strip_lines.is_empty());
assert!(cfg.suites[1].strip_line_prefixes.is_empty());
}
#[test]
fn is_path_like_accepts_absolute_unix_path() {
assert!(is_path_like("/nix/store/abc123"));
assert!(is_path_like("/build/sandbox"));
}
#[test]
fn is_path_like_accepts_absolute_windows_path() {
assert!(is_path_like(r"C:\Users\runner\.cargo"));
assert!(is_path_like(r"D:\build\target"));
}
#[test]
fn is_path_like_accepts_relative_path_with_separator() {
assert!(is_path_like("target/release"));
assert!(is_path_like(r"src\compat"));
}
#[test]
fn is_path_like_accepts_path_segment() {
assert!(is_path_like("nix/store"));
assert!(is_path_like("vendor/cargo-cache"));
}
#[test]
fn is_path_like_accepts_builtin_placeholder_bare() {
for placeholder in &[
"$DIR",
"$WORKSPACE",
"$RUST",
"$CARGO",
"$TYPEID",
"$LONGTYPE_FILE",
] {
assert!(
is_path_like(placeholder),
"expected built-in placeholder {placeholder} to pass is_path_like",
);
}
}
#[test]
fn is_path_like_accepts_builtin_placeholder_with_suffix() {
assert!(is_path_like("$DIR/test.rs"));
assert!(is_path_like("$RUST/lib/rustlib"));
assert!(is_path_like("$CARGO/registry/src"));
}
#[test]
fn is_path_like_accepts_adopter_placeholder() {
assert!(is_path_like("$NIX_STORE"));
assert!(is_path_like("$SANDBOX_ROOT"));
assert!(is_path_like("$VENDOR_CACHE_2026"));
}
#[test]
fn is_path_like_accepts_adopter_placeholder_with_suffix() {
assert!(is_path_like("$NIX_STORE/rust"));
assert!(is_path_like("$SANDBOX_ROOT/target/release"));
}
#[test]
fn is_path_like_accepts_interior_lowercase_dollar_within_path() {
assert!(is_path_like("/path/$nix/sub"));
assert!(is_path_like("/some/$cache/dir"));
assert!(is_path_like("$WORKSPACE/$tmp/run"));
}
#[test]
fn is_path_like_rejects_diagnostic_text_plain() {
for s in &["error", "warning", "help", "note", "error:", "E0277", ":"] {
assert!(
!is_path_like(s),
"is_path_like must reject diagnostic-text plain pattern {s:?}",
);
}
}
#[test]
fn is_path_like_rejects_round2_block1_surface() {
for s in &[
" |",
"For more information about this error",
"expected due to this",
] {
assert!(!is_path_like(s), "Class-B surface {s:?} must be rejected");
}
}
#[test]
fn is_path_like_rejects_round2_block2_surface() {
for s in &["error[", "warning[", "error: aborting due to"] {
assert!(!is_path_like(s), "Class-C surface {s:?} must be rejected");
}
}
#[test]
fn is_path_like_rejects_length_one_separator() {
assert!(!is_path_like("/"));
assert!(!is_path_like(r"\"));
}
#[test]
fn is_path_like_rejects_length_one_dollar() {
assert!(!is_path_like("$"));
}
#[test]
fn is_path_like_rejects_bare_dollar_patterns() {
for s in &["$ ", "$1", "$lowercase", "$ NAME", "$_"] {
assert!(
!is_path_like(s),
"bare `$` pattern {s:?} must be rejected by leading-`$` guard",
);
}
}
#[test]
fn is_path_like_rejects_lowercase_dollar_with_separator() {
for s in &[
"$nix/path",
"$lowercase/anything",
"$_path/x",
"$1/path",
"$ /space-then-slash",
r"$nix\path",
] {
assert!(
!is_path_like(s),
"lowercase `$` + separator {s:?} must be rejected by rule-3 leading-`$` guard",
);
}
}
#[test]
fn is_path_like_rejects_placeholder_with_trailing_junk() {
for s in &["$DIR-", "$A!", "$RUST.", "$DIR ", "$WORKSPACE,"] {
assert!(
!is_path_like(s),
"placeholder with trailing junk {s:?} must be rejected: \
rule (4c) is full-string anchored",
);
}
assert!(is_path_like("$DIR/x"));
assert!(
!is_complete_placeholder_token("$DIR/x"),
"(4c) alone must reject `$DIR/x`; that pattern passes is_path_like via rule 4(a)",
);
}
#[test]
fn is_path_like_rejects_empty_and_whitespace() {
assert!(!is_path_like(""));
assert!(!is_path_like(" "));
assert!(!is_path_like("\t\t"));
}
#[test]
fn is_path_like_rejects_embedded_markers_no_path() {
assert!(!is_path_like("errored"));
assert!(!is_path_like("aborted"));
}
#[test]
fn is_path_like_rejects_markers_with_trailing_colon() {
assert!(!is_path_like("error:"));
assert!(!is_path_like("note:"));
assert!(!is_path_like("help:"));
}
#[test]
fn is_path_like_rejects_newline_bearing() {
assert!(!is_path_like("a\nb"));
assert!(!is_path_like("$DIR\n"));
}
#[test]
fn is_banner_shape_accepts_explain_footer() {
assert!(is_banner_shape(
"For more information about this error, try `rustc --explain E0277`.",
));
assert!(is_banner_shape(
"For more information about this error, try `rustc --explain E0001`.",
));
assert!(is_banner_shape(
"For more information about this error, try `rustc --explain E9999`.",
));
assert!(is_banner_shape(
"For more information about this error in the documentation.",
));
}
#[test]
fn is_banner_shape_accepts_macro_origin_trailer() {
assert!(is_banner_shape(
"note: this error originates from the macro `m` in the crate `c` \
(in Nightly builds, run with -Z macro-backtrace for more info)",
));
assert!(is_banner_shape(
"note: this error originates from the attribute macro `derive_more::Display`",
));
}
#[test]
fn is_banner_shape_accepts_error_count_summary() {
assert!(is_banner_shape("error: aborting due to 1 previous error"));
assert!(is_banner_shape("error: aborting due to 42 previous errors"));
assert!(is_banner_shape(
"error: aborting due to 1 previous error; 2 warnings emitted",
));
}
#[test]
fn is_banner_shape_accepts_vendored_toolchain_info() {
assert!(is_banner_shape(
"info: using rustc from /opt/vendored/rust-1.95.0/bin/rustc",
));
assert!(is_banner_shape(
"info: switching to nightly toolchain to satisfy unstable feature",
));
}
#[test]
fn is_banner_shape_accepts_linker_version() {
assert!(is_banner_shape(
"linker version: GNU ld (GNU Binutils) 2.40",
));
assert!(is_banner_shape("linker version: rust-lld 15.0.7"));
}
#[test]
fn is_banner_shape_accepts_structural_banner_shape() {
assert!(is_banner_shape(
"Node.js 16 actions are deprecated. Please update the following actions to use \
Node.js 20: actions/checkout@v3",
));
assert!(is_banner_shape(
"GitHub Actions deprecation: please migrate to Node.js 20 by end-of-life date",
));
assert!(is_banner_shape(
"Deprecated generator output: Please update the generated API before release",
));
}
#[test]
fn is_banner_shape_rejects_length_floor() {
for s in &["error", "note", ": ", "linker: lld-15.0.7"] {
assert!(
!is_banner_shape(s),
"banner-shape length-floor rejection: {s:?} (len={})",
s.len(),
);
}
}
#[test]
fn is_banner_shape_rejects_whitespace_leading() {
for s in &[" |", " ^^^^^", "\t= note: ..."] {
assert!(
!is_banner_shape(s),
"whitespace-leading {s:?} must be rejected by (A.3)",
);
}
}
#[test]
fn is_banner_shape_rejects_span_context_first_byte() {
for s in &[
"^^^^^^^^^^^^^^^^^^^^^", "= note: some content here for it", "| trait bound goes in here too",
] {
assert!(
!is_banner_shape(s),
"span-context-first-byte {s:?} must be rejected by (A.4)",
);
}
}
#[test]
fn is_banner_shape_rejects_diagnostic_body_anti_prefix() {
for s in &[
"expected one of: cascade, set null",
"found type `u32` but wanted f64",
"the trait bound `X: Y` is not satisfied",
"the type `Foo` cannot be sent across threads",
"cannot find function `foo` in scope",
"mismatched types when expected was right",
"consider importing this trait for the call site",
"help: try adding `as_ref` for the call site",
] {
assert!(
!is_banner_shape(s),
"anti-prefix diagnostic body {s:?} must be rejected by (B)",
);
}
}
#[test]
fn is_banner_shape_rejects_diagnostic_keyword_with_colon() {
assert!(
!is_banner_shape("warning: use of deprecated function `f`"),
"`warning: ...` MUST be rejected even though it contains `deprecated`",
);
assert!(!is_banner_shape(
"error[E0277]: trait not implemented for this type",
));
assert!(!is_banner_shape("error[E0277]"));
}
#[test]
fn is_banner_shape_rejects_lowercase_leading_non_rustc() {
assert!(!is_banner_shape("gitlab ci deprecation banner here"));
}
#[test]
fn is_banner_shape_rejects_uppercase_leading_short() {
assert!(!is_banner_shape("Node 16 deprecated"));
}
#[test]
fn is_banner_shape_rejects_diagnostic_looking_like_banner() {
assert!(
!is_banner_shape("error: cannot find type `Foo` in scope"),
"diagnostic-looking-like-banner must be rejected (defense-in-depth)",
);
}
fn extra_subs_toml(from: &str) -> String {
format!(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{{ from = '{from}', to = "$WORKSPACE/x" }},
]
"#,
)
}
#[test]
fn validate_extra_substitutions_rejects_non_path_from() {
let bad: Vec<&str> = vec![
"error",
"expected due to this",
"error: aborting due to",
"$",
"$lowercase",
"$nix/path",
"$DIR-",
"$RUST.",
"$WORKSPACE,",
" ",
"errored",
"error:",
];
for from in &bad {
let toml = extra_subs_toml(from);
let err = parse_str(&toml).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("not path-like")
|| msg.contains("is_path_like")
|| msg.contains("extra_substitutions"),
"rejection of {from:?} should mention validator: {msg}",
);
}
}
#[test]
fn validate_extra_substitutions_accepts_path_from() {
let good: Vec<&str> = vec![
"/nix/store/abc123",
r"C:\Users\runner\.cargo",
"target/release",
"nix/store",
"$DIR",
"$WORKSPACE",
"$RUST",
"$DIR/test.rs",
"$NIX_STORE",
"$NIX_STORE/rust",
"/path/$nix/sub",
"/some/$cache/dir",
"$WORKSPACE/$tmp/run",
];
for from in &good {
let toml = extra_subs_toml(from);
let cfg = parse_str(&toml).unwrap_or_else(|e| {
panic!("expected {from:?} to pass is_path_like, but got error: {e:?}");
});
assert_eq!(cfg.suites[0].extra_substitutions.len(), 1);
assert_eq!(cfg.suites[0].extra_substitutions[0].from, *from);
}
}
#[test]
fn validate_extra_substitutions_rejects_newline_in_to() {
let toml = r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{ from = "/nix/store", to = "alpha\nbeta" },
]
"#;
let err = parse_str(toml).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("newline") || msg.contains("single-line"),
"newline-in-to error should mention newline: {msg}",
);
}
#[test]
fn validate_extra_substitutions_accepts_compound_to() {
for to_value in &["", "$RUST", "$RUST/lib/rustlib"] {
let toml = format!(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{{ from = "/nix/store", to = "{to_value}" }},
]
"#,
);
let cfg = parse_str(&toml).unwrap_or_else(|e| {
panic!("expected to={to_value:?} to pass, got: {e:?}");
});
assert_eq!(cfg.suites[0].extra_substitutions[0].to, *to_value);
}
}
fn strip_lines_toml(line: &str) -> String {
format!(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_lines = ['{line}']
"#,
)
}
fn strip_prefixes_toml(prefix: &str) -> String {
format!(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_line_prefixes = ['{prefix}']
"#,
)
}
#[test]
fn validate_strip_patterns_accepts_path() {
let good: Vec<&str> = vec![
"/nix/store/abc123",
r"C:\Users\runner\.cargo",
"target/release",
"nix/store",
"$DIR",
"$DIR/test.rs",
"$NIX_STORE",
"$NIX_STORE/rust",
"/path/$nix/sub",
];
for pat in &good {
let toml_strip = strip_lines_toml(pat);
parse_str(&toml_strip).unwrap_or_else(|e| {
panic!("strip_lines accept {pat:?}: {e:?}");
});
let toml_prefix = strip_prefixes_toml(pat);
parse_str(&toml_prefix).unwrap_or_else(|e| {
panic!("strip_line_prefixes accept {pat:?}: {e:?}");
});
}
}
#[test]
fn validate_strip_patterns_accepts_banner() {
let good: Vec<&str> = vec![
"For more information about this error, try x",
"note: this error originates from the macro `m` here",
"error: aborting due to 1 previous error",
"info: using rustc from /opt/vendored/rust-1.95.0/bin/rustc",
"linker version: GNU ld (GNU Binutils) 2.40",
"Node.js 16 actions are deprecated. Please update the actions",
];
for pat in &good {
let toml_strip = strip_lines_toml(pat);
parse_str(&toml_strip).unwrap_or_else(|e| {
panic!("strip_lines accept banner {pat:?}: {e:?}");
});
let toml_prefix = strip_prefixes_toml(pat);
parse_str(&toml_prefix).unwrap_or_else(|e| {
panic!("strip_line_prefixes accept banner {pat:?}: {e:?}");
});
}
}
#[test]
fn validate_strip_patterns_rejects_span_context() {
let bad: Vec<&str> = vec![
" |",
" ^^^^^",
"^^^^^^^^^^^^^^^^^^^^^",
"= note: some content here for it",
];
for pat in &bad {
let toml_strip = strip_lines_toml(pat);
assert!(
parse_str(&toml_strip).is_err(),
"strip_lines must reject span-context {pat:?}",
);
let toml_prefix = strip_prefixes_toml(pat);
assert!(
parse_str(&toml_prefix).is_err(),
"strip_line_prefixes must reject span-context {pat:?}",
);
}
}
#[test]
fn validate_strip_patterns_rejects_diagnostic_keywords() {
let bad: Vec<&str> = vec![
"error",
"warning",
"note",
"error[",
"error: aborting due to",
"error[E0277]: trait not implemented for this type",
];
for pat in &bad {
let toml_strip = strip_lines_toml(pat);
assert!(
parse_str(&toml_strip).is_err(),
"strip_lines must reject diagnostic keyword {pat:?}",
);
let toml_prefix = strip_prefixes_toml(pat);
assert!(
parse_str(&toml_prefix).is_err(),
"strip_line_prefixes must reject diagnostic keyword {pat:?}",
);
}
}
#[test]
fn validate_strip_patterns_rejects_diagnostic_body() {
let bad: Vec<&str> = vec![
"the trait bound `X: Y` is not satisfied",
"cannot find function `foo` in scope",
"mismatched types when comparing two structs",
"consider importing this trait for the call site",
];
for pat in &bad {
let toml_strip = strip_lines_toml(pat);
assert!(
parse_str(&toml_strip).is_err(),
"strip_lines must reject diagnostic body {pat:?}",
);
let toml_prefix = strip_prefixes_toml(pat);
assert!(
parse_str(&toml_prefix).is_err(),
"strip_line_prefixes must reject diagnostic body {pat:?}",
);
}
}
#[test]
fn validate_strip_patterns_rejects_disguised_diagnostic() {
let pat = "error: cannot find type `Foo` in scope";
let toml_strip = strip_lines_toml(pat);
assert!(
parse_str(&toml_strip).is_err(),
"disguised diagnostic must be rejected by strip_lines (defense-in-depth)",
);
let toml_prefix = strip_prefixes_toml(pat);
assert!(
parse_str(&toml_prefix).is_err(),
"disguised diagnostic must be rejected by strip_line_prefixes",
);
}
#[test]
fn validate_strip_patterns_rejects_short_and_dollar() {
let bad: Vec<&str> = vec![
"/",
r"\",
"$",
"$lowercase",
"$1",
"$nix/path",
"$lowercase/anything",
"$DIR-",
"$WORKSPACE,",
" ",
];
for pat in &bad {
let toml_strip = strip_lines_toml(pat);
assert!(
parse_str(&toml_strip).is_err(),
"strip_lines must reject {pat:?}",
);
let toml_prefix = strip_prefixes_toml(pat);
assert!(
parse_str(&toml_prefix).is_err(),
"strip_line_prefixes must reject {pat:?}",
);
}
}
#[test]
fn validate_strip_patterns_rejects_newline_bearing() {
let toml = r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_lines = ["alpha\nbeta"]
"#;
let err = parse_str(toml).unwrap_err();
let msg = unwrap_invalid(err);
assert!(
msg.contains("newline") || msg.contains("single-line"),
"newline in strip_lines must surface as newline error: {msg}",
);
let toml2 = r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_line_prefixes = ["alpha\nbeta"]
"#;
let err2 = parse_str(toml2).unwrap_err();
let msg2 = unwrap_invalid(err2);
assert!(
msg2.contains("newline") || msg2.contains("single-line"),
"newline in strip_line_prefixes must surface as newline error: {msg2}",
);
}
#[test]
fn extra_substitutions_collision_with_builtin_placeholder() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{ from = "$DIR", to = "$NOT_DIR" },
]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].extra_substitutions[0].from, "$DIR");
assert_eq!(cfg.suites[0].extra_substitutions[0].to, "$NOT_DIR");
}
#[test]
fn extra_substitutions_per_suite_override_interaction() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [
{ from = "/default/a", to = "$WORKSPACE/a" },
{ from = "/default/b", to = "$WORKSPACE/b" },
]
[[package.metadata.lihaaf.suite]]
name = "named"
fixture_dirs = ["tests/lihaaf/named"]
extra_substitutions = [
{ from = "/named/x", to = "$WORKSPACE/x" },
]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].extra_substitutions.len(), 2);
assert_eq!(cfg.suites[1].extra_substitutions.len(), 1);
assert_eq!(cfg.suites[1].extra_substitutions[0].from, "/named/x");
}
#[test]
fn strip_patterns_per_suite_override_interaction() {
let cfg = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
strip_lines = [
"/default/path",
"error: aborting due to 1 previous error",
]
[[package.metadata.lihaaf.suite]]
name = "named"
fixture_dirs = ["tests/lihaaf/named"]
strip_lines = [
"/named/path",
]
"#,
)
.unwrap();
assert_eq!(cfg.suites[0].strip_lines.len(), 2);
assert_eq!(cfg.suites[1].strip_lines.len(), 1);
assert_eq!(cfg.suites[1].strip_lines[0].as_str(), "/named/path");
}
fn serde_json_config_with_sub(from: &str, to: &str) -> serde_json::Value {
serde_json::json!({
"dylib_crate": "consumer",
"raw_metadata": {},
"suites": [{
"name": "default",
"extern_crates": ["consumer"],
"fixture_dirs": ["tests/lihaaf/compile_fail"],
"features": [],
"edition": "2021",
"dev_deps": [],
"compile_fail_marker": "compile_fail",
"fixture_timeout_secs": 90,
"per_fixture_memory_mb": 1024,
"allow_lints": [],
"extra_substitutions": [{ "from": from, "to": to }],
"strip_lines": [],
"strip_line_prefixes": []
}]
})
}
fn serde_json_config_with_strip_lines(pattern: &str) -> serde_json::Value {
serde_json::json!({
"dylib_crate": "consumer",
"raw_metadata": {},
"suites": [{
"name": "default",
"extern_crates": ["consumer"],
"fixture_dirs": ["tests/lihaaf/compile_fail"],
"features": [],
"edition": "2021",
"dev_deps": [],
"compile_fail_marker": "compile_fail",
"fixture_timeout_secs": 90,
"per_fixture_memory_mb": 1024,
"allow_lints": [],
"extra_substitutions": [],
"strip_lines": [pattern],
"strip_line_prefixes": []
}]
})
}
fn serde_json_config_with_strip_line_prefixes(pattern: &str) -> serde_json::Value {
serde_json::json!({
"dylib_crate": "consumer",
"raw_metadata": {},
"suites": [{
"name": "default",
"extern_crates": ["consumer"],
"fixture_dirs": ["tests/lihaaf/compile_fail"],
"features": [],
"edition": "2021",
"dev_deps": [],
"compile_fail_marker": "compile_fail",
"fixture_timeout_secs": 90,
"per_fixture_memory_mb": 1024,
"allow_lints": [],
"extra_substitutions": [],
"strip_lines": [],
"strip_line_prefixes": [pattern]
}]
})
}
fn serde_json_config_with_build_targets(
dev_deps: Vec<&str>,
build_targets: Vec<&str>,
) -> serde_json::Value {
serde_json::json!({
"dylib_crate": "consumer",
"raw_metadata": {},
"suites": [{
"name": "default",
"extern_crates": ["consumer"],
"fixture_dirs": ["tests/lihaaf/compile_fail"],
"features": [],
"edition": "2021",
"dev_deps": dev_deps,
"build_targets": build_targets,
"compile_fail_marker": "compile_fail",
"fixture_timeout_secs": 90,
"per_fixture_memory_mb": 1024,
"allow_lints": [],
"extra_substitutions": [],
"strip_lines": [],
"strip_line_prefixes": []
}]
})
}
#[test]
fn serde_deserialize_rejects_invalid_substitution_from() {
let bad_from_values = [
"error",
"error:",
"expected due to this",
" |",
"warning: x",
];
for bad_from in &bad_from_values {
let v = serde_json_config_with_sub(bad_from, "x");
let result = serde_json::from_value::<Config>(v);
assert!(
result.is_err(),
"serde_json::from_value should reject from={bad_from:?} \
but succeeded",
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not path-like")
|| msg.contains("path-shaped")
|| msg.contains("is_path_like")
|| msg.contains("extra_substitutions"),
"error for from={bad_from:?} should mention path validation: {msg}",
);
}
}
#[test]
fn serde_deserialize_rejects_invalid_substitution_to_newline() {
let v = serde_json_config_with_sub("/valid/path", "alpha\nbeta");
let result = serde_json::from_value::<Config>(v);
assert!(
result.is_err(),
"serde_json::from_value should reject to with newline but succeeded",
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("newline") || msg.contains("single-line"),
"error for newline-in-to should mention newline: {msg}",
);
}
#[test]
fn serde_deserialize_rejects_invalid_strip_lines() {
let bad_patterns = ["error", " |", "expected due to this", "warning: x", "foo"];
for bad_pat in &bad_patterns {
let v = serde_json_config_with_strip_lines(bad_pat);
let result = serde_json::from_value::<Config>(v);
assert!(
result.is_err(),
"serde_json::from_value should reject strip_lines={bad_pat:?} \
but succeeded",
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("path-shaped")
|| msg.contains("banner-shaped")
|| msg.contains("neither")
|| msg.contains("not path-like"),
"error for strip_lines={bad_pat:?} should mention validation: {msg}",
);
}
}
#[test]
fn serde_deserialize_rejects_invalid_strip_line_prefixes() {
let bad_patterns = ["error", " |", "expected due to this", "mismatched types"];
for bad_pat in &bad_patterns {
let v = serde_json_config_with_strip_line_prefixes(bad_pat);
let result = serde_json::from_value::<Config>(v);
assert!(
result.is_err(),
"serde_json::from_value should reject strip_line_prefixes={bad_pat:?} \
but succeeded",
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("path-shaped")
|| msg.contains("banner-shaped")
|| msg.contains("neither")
|| msg.contains("not path-like"),
"error for strip_line_prefixes={bad_pat:?} should mention validation: {msg}",
);
}
}
#[test]
fn serde_deserialize_rejects_invalid_build_targets() {
let unknown = serde_json_config_with_build_targets(vec!["serde"], vec!["benches"]);
let result = serde_json::from_value::<Config>(unknown);
assert!(
result.is_err(),
"serde_json::from_value should reject unknown build_targets but succeeded",
);
assert!(result.unwrap_err().to_string().contains("tests"));
let duplicate = serde_json_config_with_build_targets(vec!["serde"], vec!["tests", "tests"]);
let result = serde_json::from_value::<Config>(duplicate);
assert!(
result.is_err(),
"serde_json::from_value should reject duplicate build_targets but succeeded",
);
assert!(result.unwrap_err().to_string().contains("duplicate"));
}
#[test]
fn serde_deserialize_rejects_build_targets_without_dev_deps() {
let v = serde_json_config_with_build_targets(Vec::new(), vec!["tests"]);
let result = serde_json::from_value::<Config>(v);
assert!(
result.is_err(),
"serde_json::from_value should reject build_targets with empty dev_deps but succeeded",
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("build_targets"));
assert!(msg.contains("dev_deps"));
}
#[test]
fn serde_deserialize_accepts_valid_build_targets() {
let v = serde_json_config_with_build_targets(vec!["serde"], vec!["tests"]);
let cfg = serde_json::from_value::<Config>(v).unwrap_or_else(|e| {
panic!("serde_json::from_value should accept valid build_targets but failed: {e}")
});
assert_eq!(cfg.suites[0].build_targets.as_slice(), ["tests"]);
}
#[test]
fn serde_deserialize_accepts_valid_substitution() {
let v = serde_json_config_with_sub("/path/$DIR", "$RUST/lib");
let cfg = serde_json::from_value::<Config>(v).unwrap_or_else(|e| {
panic!("serde_json::from_value should accept valid substitution but failed: {e}")
});
assert_eq!(cfg.suites[0].extra_substitutions.len(), 1);
assert_eq!(cfg.suites[0].extra_substitutions[0].from, "/path/$DIR");
assert_eq!(cfg.suites[0].extra_substitutions[0].to, "$RUST/lib");
}
#[test]
fn serde_deserialize_accepts_valid_strip_pattern() {
let path_pattern = "/build/sandbox/cc-wrapper";
let v = serde_json_config_with_strip_lines(path_pattern);
let cfg = serde_json::from_value::<Config>(v).unwrap_or_else(|e| {
panic!("serde_json::from_value should accept valid strip_lines but failed: {e}")
});
assert_eq!(cfg.suites[0].strip_lines.len(), 1);
assert_eq!(cfg.suites[0].strip_lines[0].as_str(), path_pattern);
let banner_pattern = "error: aborting due to 1 previous error";
let v2 = serde_json_config_with_strip_line_prefixes(banner_pattern);
let cfg2 = serde_json::from_value::<Config>(v2).unwrap_or_else(|e| {
panic!("serde_json::from_value should accept valid strip_line_prefixes but failed: {e}")
});
assert_eq!(cfg2.suites[0].strip_line_prefixes.len(), 1);
assert_eq!(
cfg2.suites[0].strip_line_prefixes[0].as_str(),
banner_pattern
);
}
fn serde_json_config_with_sub_from_only(from: &str) -> serde_json::Value {
serde_json::json!({
"dylib_crate": "consumer",
"raw_metadata": {},
"suites": [{
"name": "default",
"extern_crates": ["consumer"],
"fixture_dirs": ["tests/lihaaf/compile_fail"],
"features": [],
"edition": "2021",
"dev_deps": [],
"compile_fail_marker": "compile_fail",
"fixture_timeout_secs": 90,
"per_fixture_memory_mb": 1024,
"allow_lints": [],
"extra_substitutions": [{ "from": from }],
"strip_lines": [],
"strip_line_prefixes": []
}]
})
}
#[test]
fn serde_and_toml_paths_agree_on_first_error_for_multiply_invalid() {
let toml_err = parse_str(
r#"
[package.metadata.lihaaf]
dylib_crate = "consumer"
extern_crates = ["consumer"]
extra_substitutions = [{ from = "error" }]
"#,
)
.unwrap_err();
let toml_msg = unwrap_invalid(toml_err);
let v = serde_json_config_with_sub_from_only("error");
let serde_result = serde_json::from_value::<Config>(v);
assert!(
serde_result.is_err(),
"serde_json::from_value should reject missing `to` but succeeded",
);
let serde_msg = serde_result.unwrap_err().to_string();
let to_missing_markers = ["to", "missing"];
for marker in &to_missing_markers {
assert!(
toml_msg.contains(marker),
"TOML-path error should reference `{marker}` but got: {toml_msg}",
);
assert!(
serde_msg.contains(marker),
"serde-path error should reference `{marker}` but got: {serde_msg}",
);
}
assert!(
!toml_msg.contains("not path-like"),
"TOML path fired is_path_like before reporting missing `to`: {toml_msg}",
);
assert!(
!serde_msg.contains("not path-like"),
"serde path fired is_path_like before reporting missing `to`: {serde_msg}",
);
}
}