use cargo_metadata::TargetKind;
use serde_json::Value;
use crate::error::{EachError, InvalidFilterExpressionError};
use crate::workspace::{Member, parse_target_kind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Predicate {
And(Vec<Self>),
Or(Vec<Self>),
Not(Box<Self>),
HasTargetKind(TargetKind),
Publishable,
HasFeature(String),
DependsOn(String),
MetadataPresent(String),
MetadataEquals {
key: String,
value: String,
},
}
impl Predicate {
pub(crate) fn parse(spec: &str) -> Result<Self, EachError> {
ExpressionParser::new(spec)?.parse()
}
fn parse_atom(spec: &str) -> Result<Self, EachError> {
match spec {
"lib" => Ok(Self::HasTargetKind(TargetKind::Lib)),
"bin" => Ok(Self::HasTargetKind(TargetKind::Bin)),
"publishable" => Ok(Self::Publishable),
_ => {
if let Some(kind) = spec.strip_prefix("target-kind:") {
let Some(kind) = parse_target_kind(kind) else {
return Err(invalid(
spec,
"unknown target kind; expected one of: lib, rlib, dylib, cdylib, staticlib, proc-macro, bin, example, test, bench, custom-build",
));
};
Ok(Self::HasTargetKind(kind))
} else if let Some(name) = spec.strip_prefix("feature:") {
if name.is_empty() {
return Err(invalid(spec, "empty feature name"));
}
Ok(Self::HasFeature(name.to_owned()))
} else if let Some(name) = spec.strip_prefix("dep:") {
if name.is_empty() {
return Err(invalid(spec, "empty dependency name"));
}
Ok(Self::DependsOn(name.to_owned()))
} else if let Some(rest) = spec.strip_prefix("metadata:") {
parse_metadata(spec, rest)
} else {
Err(invalid(
spec,
"expected one of: lib, bin, target-kind:<kind>, publishable, feature:<name>, dep:<name>, metadata:<key>[=<value>]",
))
}
}
}
}
#[must_use]
pub(crate) fn matches(&self, member: &Member) -> bool {
match self {
Self::And(predicates) => predicates.iter().all(|predicate| predicate.matches(member)),
Self::Or(predicates) => predicates.iter().any(|predicate| predicate.matches(member)),
Self::Not(predicate) => !predicate.matches(member),
Self::HasTargetKind(kind) => member.targets.iter().any(|target| target.kinds.contains(kind)),
Self::Publishable => member.publishable,
Self::HasFeature(name) => member.features.contains(name),
Self::DependsOn(name) => member.dependencies.contains(name),
Self::MetadataPresent(key) => lookup(&member.metadata, key).is_some(),
Self::MetadataEquals { key, value } => lookup(&member.metadata, key).is_some_and(|v| value_equals(v, value)),
}
}
}
fn parse_metadata(spec: &str, rest: &str) -> Result<Predicate, EachError> {
if let Some((key, value)) = rest.split_once('=') {
validate_key(spec, key)?;
Ok(Predicate::MetadataEquals {
key: key.to_owned(),
value: parse_metadata_value(spec, value)?,
})
} else {
validate_key(spec, rest)?;
Ok(Predicate::MetadataPresent(rest.to_owned()))
}
}
fn parse_metadata_value(spec: &str, value: &str) -> Result<String, EachError> {
if !value.starts_with('"') {
if value.contains('"') {
return Err(invalid(spec, "double quotes must surround the complete metadata value"));
}
return Ok(value.to_owned());
}
let Some(inner) = value.strip_prefix('"').and_then(|value| value.strip_suffix('"')) else {
return Err(invalid(spec, "unclosed double-quoted metadata value"));
};
let mut parsed = String::with_capacity(inner.len());
let mut characters = inner.chars();
while let Some(character) = characters.next() {
if character != '\\' {
parsed.push(character);
continue;
}
match characters.next() {
Some(escaped @ ('"' | '\\')) => parsed.push(escaped),
Some(other) => {
return Err(invalid(
spec,
&format!("unsupported escape `\\{other}` in metadata value; expected `\\\"` or `\\\\`"),
));
}
None => return Err(invalid(spec, "trailing `\\` in metadata value")),
}
}
Ok(parsed)
}
fn validate_key(spec: &str, key: &str) -> Result<(), EachError> {
if key.is_empty() {
return Err(invalid(spec, "empty metadata key"));
}
if key.split('.').any(str::is_empty) {
return Err(invalid(spec, "metadata key must be a dotted path with non-empty segments"));
}
Ok(())
}
fn invalid(spec: &str, reason: &str) -> EachError {
InvalidFilterExpressionError::new(spec.to_owned(), reason.to_owned()).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Token<'a> {
Atom(&'a str),
And,
Or,
Not,
LeftParen,
RightParen,
}
impl Token<'_> {
fn display(self) -> &'static str {
match self {
Self::Atom(_) => "predicate",
Self::And => "`and`",
Self::Or => "`or`",
Self::Not => "`not`",
Self::LeftParen => "`(`",
Self::RightParen => "`)`",
}
}
}
struct ExpressionParser<'a> {
spec: &'a str,
tokens: Vec<Token<'a>>,
position: usize,
nesting: usize,
}
impl<'a> ExpressionParser<'a> {
const MAX_NESTING: usize = 64;
fn new(spec: &'a str) -> Result<Self, EachError> {
Ok(Self {
spec,
tokens: tokenize(spec)?,
position: 0,
nesting: 0,
})
}
fn parse(mut self) -> Result<Predicate, EachError> {
if self.tokens.is_empty() {
return Err(invalid(self.spec, "empty expression"));
}
if self.tokens.iter().all(|token| matches!(token, Token::Atom(_))) {
return Predicate::parse_atom(self.spec.trim());
}
let expression = self.parse_or()?;
if let Some(token) = self.peek() {
return Err(invalid(
self.spec,
&format!("unexpected {}; expected `and`, `or`, or end of expression", token.display()),
));
}
Ok(expression)
}
fn parse_or(&mut self) -> Result<Predicate, EachError> {
let mut operands = vec![self.parse_and()?];
while self.peek() == Some(Token::Or) {
self.advance();
operands.push(self.parse_and()?);
}
Ok(if operands.len() == 1 {
operands.pop().expect("length checked above to contain exactly one operand")
} else {
Predicate::Or(operands)
})
}
fn parse_and(&mut self) -> Result<Predicate, EachError> {
let mut operands = vec![self.parse_unary()?];
while self.peek() == Some(Token::And) {
self.advance();
operands.push(self.parse_unary()?);
}
Ok(if operands.len() == 1 {
operands.pop().expect("length checked above to contain exactly one operand")
} else {
Predicate::And(operands)
})
}
fn parse_unary(&mut self) -> Result<Predicate, EachError> {
match self.peek() {
Some(Token::Atom(atom)) => {
self.advance();
Predicate::parse_atom(atom)
}
Some(Token::Not) => {
self.advance();
self.enter_nesting()?;
let operand = self.parse_unary();
self.nesting -= 1;
Ok(Predicate::Not(Box::new(operand?)))
}
Some(Token::LeftParen) => {
self.advance();
self.enter_nesting()?;
let expression = self.parse_or();
self.nesting -= 1;
let expression = expression?;
if self.peek() != Some(Token::RightParen) {
return Err(invalid(self.spec, "unclosed `(`"));
}
self.advance();
Ok(expression)
}
Some(token) => Err(invalid(
self.spec,
&format!("unexpected {}; expected a predicate, `not`, or `(`", token.display()),
)),
None => Err(invalid(
self.spec,
"unexpected end of expression; expected a predicate, `not`, or `(`",
)),
}
}
fn peek(&self) -> Option<Token<'a>> {
self.tokens.get(self.position).copied()
}
fn advance(&mut self) {
self.position += 1;
}
fn enter_nesting(&mut self) -> Result<(), EachError> {
if self.nesting >= Self::MAX_NESTING {
return Err(invalid(self.spec, "expression nesting exceeds 64 levels"));
}
self.nesting += 1;
Ok(())
}
}
fn tokenize(spec: &str) -> Result<Vec<Token<'_>>, EachError> {
let mut tokens = Vec::new();
let mut atom_start = None;
let mut characters = spec.char_indices();
while let Some((index, character)) = characters.next() {
if character == '"' {
if atom_start.is_none() {
atom_start = Some(index);
}
let mut closed = false;
while let Some((_, quoted_character)) = characters.next() {
if quoted_character == '\\' {
characters.next();
} else if quoted_character == '"' {
closed = true;
break;
}
}
if !closed {
return Err(invalid(spec, "unclosed double quote"));
}
} else if character.is_whitespace() || matches!(character, '(' | ')') {
if let Some(start) = atom_start.take() {
tokens.push(classify_token(&spec[start..index]));
}
match character {
'(' => tokens.push(Token::LeftParen),
')' => tokens.push(Token::RightParen),
_ => {}
}
} else if atom_start.is_none() {
atom_start = Some(index);
}
}
if let Some(start) = atom_start {
tokens.push(classify_token(&spec[start..]));
}
Ok(tokens)
}
fn classify_token(token: &str) -> Token<'_> {
match token {
"and" => Token::And,
"or" => Token::Or,
"not" => Token::Not,
atom => Token::Atom(atom),
}
}
fn lookup<'v>(metadata: &'v Value, dotted_key: &str) -> Option<&'v Value> {
let mut node = metadata;
for segment in dotted_key.split('.') {
node = node.get(segment)?;
}
Some(node)
}
fn value_equals(node: &Value, expected: &str) -> bool {
let rendered = match node {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => return false,
};
match (rendered.parse::<f64>(), expected.parse::<f64>()) {
#[expect(clippy::float_cmp, reason = "comparing exact parsed values of short decimal literals")]
(Ok(a), Ok(b)) => a == b || rendered == expected,
_ => rendered == expected,
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::collections::BTreeSet;
use std::path::PathBuf;
use serde_json::json;
use super::*;
use crate::workspace::MemberTarget;
fn member_with(deps: &[&str], metadata: Value, has_lib: bool, has_bin: bool) -> Member {
let mut targets = Vec::new();
if has_lib {
targets.push(MemberTarget {
name: "m".to_owned(),
kinds: std::iter::once(TargetKind::Lib).collect(),
required_features: BTreeSet::new(),
});
}
if has_bin {
targets.push(MemberTarget {
name: "m".to_owned(),
kinds: std::iter::once(TargetKind::Bin).collect(),
required_features: BTreeSet::new(),
});
}
Member {
name: "m".to_owned(),
version: "0.1.0".to_owned(),
rust_version: Some("1.70.0".parse().expect("valid Rust version")),
manifest_path: PathBuf::from("/ws/m/Cargo.toml"),
publishable: true,
features: BTreeSet::new(),
targets,
dependencies: deps.iter().map(|s| (*s).to_owned()).collect::<BTreeSet<_>>(),
metadata,
}
}
#[test]
fn parses_kind_predicates() {
assert_eq!(Predicate::parse("lib").expect("lib"), Predicate::HasTargetKind(TargetKind::Lib));
assert_eq!(Predicate::parse("bin").expect("bin"), Predicate::HasTargetKind(TargetKind::Bin));
assert_eq!(
Predicate::parse("target-kind:proc-macro").expect("target kind"),
Predicate::HasTargetKind(TargetKind::ProcMacro)
);
assert_eq!(Predicate::parse("publishable").expect("publishable"), Predicate::Publishable);
assert_eq!(
Predicate::parse("feature:loom").expect("feature"),
Predicate::HasFeature("loom".to_owned())
);
}
#[test]
fn parses_dep_predicate() {
assert_eq!(Predicate::parse("dep:loom").expect("dep"), Predicate::DependsOn("loom".to_owned()));
}
#[test]
fn parses_metadata_predicates() {
assert_eq!(
Predicate::parse("metadata:coverage-gate.min-lines-percent").expect("present"),
Predicate::MetadataPresent("coverage-gate.min-lines-percent".to_owned())
);
assert_eq!(
Predicate::parse("metadata:coverage-gate.min-lines-percent=0").expect("equals"),
Predicate::MetadataEquals {
key: "coverage-gate.min-lines-percent".to_owned(),
value: "0".to_owned()
}
);
}
#[test]
fn parses_boolean_operators_with_conventional_precedence() {
assert_eq!(
Predicate::parse("lib or bin and not publishable").expect("expression"),
Predicate::Or(vec![
Predicate::HasTargetKind(TargetKind::Lib),
Predicate::And(vec![
Predicate::HasTargetKind(TargetKind::Bin),
Predicate::Not(Box::new(Predicate::Publishable)),
]),
])
);
}
#[test]
fn parentheses_override_boolean_precedence() {
assert_eq!(
Predicate::parse("(lib or bin) and publishable").expect("expression"),
Predicate::And(vec![
Predicate::Or(vec![
Predicate::HasTargetKind(TargetKind::Lib),
Predicate::HasTargetKind(TargetKind::Bin),
]),
Predicate::Publishable,
])
);
}
#[test]
fn atomic_metadata_value_may_contain_spaces() {
assert_eq!(
Predicate::parse("metadata:role=script only").expect("atomic predicate"),
Predicate::MetadataEquals {
key: "role".to_owned(),
value: "script only".to_owned(),
}
);
}
#[test]
fn quoted_metadata_value_may_contain_boolean_syntax() {
assert_eq!(
Predicate::parse(r#"metadata:role="research and (development)" and lib"#).expect("expression"),
Predicate::And(vec![
Predicate::MetadataEquals {
key: "role".to_owned(),
value: "research and (development)".to_owned(),
},
Predicate::HasTargetKind(TargetKind::Lib),
])
);
assert_eq!(
Predicate::parse(r#"metadata:role="quoted \"value\" and \\ path""#).expect("quoted atom"),
Predicate::MetadataEquals {
key: "role".to_owned(),
value: "quoted \"value\" and \\ path".to_owned(),
}
);
}
#[test]
fn rejects_malformed_boolean_expressions() {
for expression in [
"",
"lib and",
"or lib",
"lib bin",
"(lib or bin",
"lib or )",
"lib not bin",
"()",
"lib AND bin",
r#""lib""#,
r#"metadata:role=value"quoted""#,
r#"metadata:role=""suffix"#,
r#"metadata:role="unclosed"#,
r#"metadata:role="unsupported\q""#,
] {
Predicate::parse(expression).expect_err(expression);
}
let unexpected = Predicate::parse("lib or )").expect_err("unexpected closing parenthesis");
assert!(
unexpected
.to_string()
.contains("unexpected `)`; expected a predicate, `not`, or `(`"),
"diagnostic must identify the unexpected token: {unexpected}"
);
let trailing = Predicate::parse("lib not bin").expect_err("unexpected trailing operator");
assert!(
trailing
.to_string()
.contains("unexpected `not`; expected `and`, `or`, or end of expression"),
"diagnostic must identify the trailing token: {trailing}"
);
}
#[test]
fn malformed_filter_errors_are_exact() {
let cases = [
("", "empty expression"),
(
"nonsense",
"expected one of: lib, bin, target-kind:<kind>, publishable, feature:<name>, dep:<name>, metadata:<key>[=<value>]",
),
(
"target-kind:future",
"unknown target kind; expected one of: lib, rlib, dylib, cdylib, staticlib, proc-macro, bin, example, test, bench, custom-build",
),
("feature:", "empty feature name"),
("dep:", "empty dependency name"),
("metadata:", "empty metadata key"),
("metadata:a..b", "metadata key must be a dotted path with non-empty segments"),
(r#"metadata:role=value"suffix"#, "unclosed double quote"),
(r#"metadata:role="unclosed"#, "unclosed double quote"),
(
r#"metadata:role="bad\q""#,
r#"unsupported escape `\q` in metadata value; expected `\"` or `\\`"#,
),
("lib and", "unexpected end of expression; expected a predicate, `not`, or `(`"),
("(lib", "unclosed `(`"),
];
for (expression, reason) in cases {
let error = Predicate::parse(expression).expect_err(expression);
assert_eq!(error.to_string(), format!("invalid filter expression `{expression}`: {reason}"));
}
}
#[test]
fn metadata_quote_boundaries_and_escapes_are_exact() {
assert_eq!(
Predicate::parse(r#"metadata:role="a \"quoted\" \\ path""#).expect("quoted value"),
Predicate::MetadataEquals {
key: "role".to_owned(),
value: "a \"quoted\" \\ path".to_owned(),
}
);
let trailing_quote = parse_metadata_value("metadata:role=value\"", r#"value""#).expect_err("quote only at the end is invalid");
assert_eq!(
trailing_quote.to_string(),
r#"invalid filter expression `metadata:role=value"`: double quotes must surround the complete metadata value"#
);
let trailing_escape = parse_metadata_value("metadata:role", r#""trailing\""#).expect_err("a quoted value cannot end in an escape");
assert_eq!(
trailing_escape.to_string(),
r"invalid filter expression `metadata:role`: trailing `\` in metadata value"
);
let unclosed = parse_metadata_value("metadata:role=\"value", "\"value").expect_err("a leading quote requires a closing quote");
assert_eq!(
unclosed.to_string(),
"invalid filter expression `metadata:role=\"value`: unclosed double-quoted metadata value"
);
}
#[test]
fn expression_nesting_accepts_64_levels_but_rejects_65() {
let maximum = format!("{}lib{}", "(".repeat(64), ")".repeat(64));
assert_eq!(
Predicate::parse(&maximum).expect("64 levels are supported"),
Predicate::HasTargetKind(TargetKind::Lib)
);
let too_deep = format!("{}lib{}", "(".repeat(65), ")".repeat(65));
let error = Predicate::parse(&too_deep).expect_err("65 levels exceed the limit");
assert!(
error.to_string().contains("expression nesting exceeds 64 levels"),
"diagnostic must identify the nesting limit: {error}"
);
let maximum_negation = format!("{}lib", "not ".repeat(64));
Predicate::parse(&maximum_negation).expect("64 negations are supported");
let too_many_negations = format!("{}lib", "not ".repeat(65));
Predicate::parse(&too_many_negations).expect_err("65 negations exceed the limit");
}
#[test]
fn mixed_parenthesis_and_negation_depth_uses_one_level_per_operator() {
let accepted = format!("{}{}lib{}", "(".repeat(32), "not ".repeat(32), ")".repeat(32));
Predicate::parse(&accepted).expect("32 parentheses plus 32 negations are within the limit");
let rejected = format!("{}{}lib{}", "(".repeat(33), "not ".repeat(32), ")".repeat(33));
assert_eq!(
Predicate::parse(&rejected)
.expect_err("65 total nesting levels must fail")
.to_string(),
format!("invalid filter expression `{rejected}`: expression nesting exceeds 64 levels")
);
let sequential = format!(
"{}lib{} and {}bin{}",
"(".repeat(64),
")".repeat(64),
"(".repeat(64),
")".repeat(64)
);
Predicate::parse(&sequential).expect("nesting from one operand must not leak into the next");
let sequential_negations = format!("{}lib and {}bin", "not ".repeat(64), "not ".repeat(64));
Predicate::parse(&sequential_negations).expect("negation nesting from one operand must not leak into the next");
}
#[test]
fn tokenizer_keeps_boolean_syntax_inside_escaped_quotes() {
assert_eq!(
tokenize(r#"metadata:role="left \" and right" or bin"#).expect("tokenize"),
[Token::Atom(r#"metadata:role="left \" and right""#), Token::Or, Token::Atom("bin"),]
);
assert_eq!(
tokenize("not(lib)").expect("tokenize adjacent punctuation"),
[Token::Not, Token::LeftParen, Token::Atom("lib"), Token::RightParen]
);
assert_eq!(
tokenize(r#""quoted atom" and lib"#).expect("tokenize leading quote"),
[Token::Atom(r#""quoted atom""#), Token::And, Token::Atom("lib")]
);
}
#[test]
fn token_diagnostics_name_every_token_kind() {
assert_eq!(Token::Atom("lib").display(), "predicate");
assert_eq!(Token::And.display(), "`and`");
assert_eq!(Token::Or.display(), "`or`");
assert_eq!(Token::Not.display(), "`not`");
assert_eq!(Token::LeftParen.display(), "`(`");
assert_eq!(Token::RightParen.display(), "`)`");
}
#[test]
fn rejects_trailing_escape_in_quoted_metadata_value() {
parse_metadata_value("metadata:role", r#""trailing\""#).expect_err("trailing escape must error");
}
#[test]
fn rejects_unknown_and_empty() {
Predicate::parse("nonsense").expect_err("unknown predicate must error");
Predicate::parse("dep:").expect_err("empty dependency name must error");
Predicate::parse("feature:").expect_err("empty feature name must error");
Predicate::parse("target-kind:no-such-kind").expect_err("unknown target kind must error");
Predicate::parse("metadata:").expect_err("empty metadata key must error");
}
#[test]
fn rejects_metadata_keys_with_empty_segments() {
Predicate::parse("metadata:a..b").expect_err("double dot must error");
Predicate::parse("metadata:.role").expect_err("leading dot must error");
Predicate::parse("metadata:role.").expect_err("trailing dot must error");
Predicate::parse("metadata:a..b=1").expect_err("double dot with value must error");
Predicate::parse("metadata:.role=1").expect_err("leading dot with value must error");
}
#[test]
fn kind_predicates_match() {
let mut m = member_with(&[], Value::Null, true, false);
m.targets.push(MemberTarget {
name: "macros".to_owned(),
kinds: std::iter::once(TargetKind::ProcMacro).collect(),
required_features: BTreeSet::new(),
});
assert!(Predicate::HasTargetKind(TargetKind::Lib).matches(&m));
assert!(!Predicate::HasTargetKind(TargetKind::Bin).matches(&m));
assert!(Predicate::HasTargetKind(TargetKind::ProcMacro).matches(&m));
assert!(!Predicate::HasTargetKind(TargetKind::Test).matches(&m));
}
#[test]
fn package_fact_predicates_match() {
let mut m = member_with(&[], Value::Null, false, false);
m.publishable = false;
m.features.insert("loom".to_owned());
assert!(!Predicate::Publishable.matches(&m));
assert!(Predicate::HasFeature("loom".to_owned()).matches(&m));
assert!(!Predicate::HasFeature("serde".to_owned()).matches(&m));
}
#[test]
fn boolean_expression_matches_member() {
let mut m = member_with(&[], Value::Null, true, false);
m.features.insert("loom".to_owned());
assert!(
Predicate::parse("publishable and (bin or feature:loom)")
.expect("expression")
.matches(&m)
);
assert!(
!Predicate::parse("not lib or metadata:role=script-only")
.expect("expression")
.matches(&m)
);
}
#[test]
fn dep_predicate_matches() {
let m = member_with(&["loom", "serde"], Value::Null, true, false);
assert!(Predicate::DependsOn("loom".to_owned()).matches(&m));
assert!(!Predicate::DependsOn("tokio".to_owned()).matches(&m));
}
#[test]
fn metadata_present_and_equals() {
let m = member_with(&[], json!({ "coverage-gate": { "min-lines-percent": 0 } }), true, false);
assert!(Predicate::parse("metadata:coverage-gate.min-lines-percent").expect("p").matches(&m));
assert!(
Predicate::parse("metadata:coverage-gate.min-lines-percent=0")
.expect("p")
.matches(&m)
);
assert!(
Predicate::parse("metadata:coverage-gate.min-lines-percent=0.0")
.expect("p")
.matches(&m)
);
assert!(
!Predicate::parse("metadata:coverage-gate.min-lines-percent=50")
.expect("p")
.matches(&m)
);
assert!(!Predicate::parse("metadata:missing.key").expect("p").matches(&m));
}
#[test]
fn metadata_string_equals() {
let m = member_with(&[], json!({ "role": "script-only" }), true, false);
assert!(Predicate::parse("metadata:role=script-only").expect("p").matches(&m));
assert!(!Predicate::parse("metadata:role=library").expect("p").matches(&m));
}
#[test]
fn metadata_nan_uses_string_equality() {
let m = member_with(&[], json!({ "marker": "NaN" }), true, false);
assert!(Predicate::parse("metadata:marker=NaN").expect("p").matches(&m));
assert!(!Predicate::parse("metadata:marker=nan").expect("p").matches(&m));
}
#[test]
fn metadata_bool_equals() {
let m = member_with(&[], json!({ "flag": true }), true, false);
assert!(Predicate::parse("metadata:flag=true").expect("p").matches(&m));
assert!(!Predicate::parse("metadata:flag=false").expect("p").matches(&m));
}
#[test]
fn metadata_non_scalar_never_equals() {
let m = member_with(&[], json!({ "obj": { "a": 1 }, "arr": [1, 2] }), true, false);
assert!(Predicate::parse("metadata:obj").expect("p").matches(&m));
assert!(!Predicate::parse("metadata:obj=x").expect("p").matches(&m));
assert!(!Predicate::parse("metadata:arr=x").expect("p").matches(&m));
}
#[test]
fn rejects_empty_metadata_key_before_equals() {
Predicate::parse("metadata:=value").expect_err("empty key with value must error");
}
}