use prov_graph::meta::{Mapping, Value};
use crate::spec::scalar_texts;
pub const CONDITION_KEYS: &[&str] = &["has", "equals", "not", "any-of", "all-of"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Condition {
Has(String),
Equals {
field: String,
value: String,
},
Not(Box<Condition>),
AllOf(Vec<Condition>),
AnyOf(Vec<Condition>),
}
impl Condition {
pub fn matches(&self, meta: &Value) -> bool {
match self {
Condition::Has(field) => meta.get(field).is_some_and(|v| !scalar_texts(v).is_empty()),
Condition::Equals { field, value } => meta
.get(field)
.is_some_and(|v| scalar_texts(v).iter().any(|t| t == value)),
Condition::Not(inner) => !inner.matches(meta),
Condition::AllOf(all) => all.iter().all(|c| c.matches(meta)),
Condition::AnyOf(any) => any.iter().any(|c| c.matches(meta)),
}
}
pub fn parse(value: &Value) -> Option<Self> {
let map = value.as_mapping()?;
let mut conditions = Vec::new();
for (key, value) in map {
match key.as_str() {
"has" => conditions.extend(fields_of(value).into_iter().map(Condition::Has)),
"equals" => conditions.extend(equalities_of(value)),
"not" => {
conditions.extend(Condition::parse(value).map(|c| Condition::Not(c.into())))
}
"any-of" => conditions.extend(branch(value, Condition::AnyOf)),
"all-of" => conditions.extend(branch(value, Condition::AllOf)),
_ => {}
}
}
match conditions.len() {
0 => None,
1 => conditions.pop(),
_ => Some(Condition::AllOf(conditions)),
}
}
pub fn to_value(&self) -> Value {
let mut map = Mapping::new();
match self {
Condition::Has(field) => {
map.insert("has".into(), Value::String(field.clone()));
}
Condition::Equals { field, value } => {
let mut pairs = Mapping::new();
pairs.insert(field.clone(), Value::String(value.clone()));
map.insert("equals".into(), Value::Mapping(pairs));
}
Condition::Not(inner) => {
map.insert("not".into(), inner.to_value());
}
Condition::AllOf(all) => {
map.insert(
"all-of".into(),
Value::Sequence(all.iter().map(Condition::to_value).collect()),
);
}
Condition::AnyOf(any) => {
map.insert(
"any-of".into(),
Value::Sequence(any.iter().map(Condition::to_value).collect()),
);
}
}
Value::Mapping(map)
}
}
fn fields_of(value: &Value) -> Vec<String> {
match value {
Value::String(s) => non_empty(s).into_iter().collect(),
Value::Sequence(items) => items
.iter()
.filter_map(Value::as_str)
.filter_map(non_empty)
.collect(),
_ => Vec::new(),
}
}
fn equalities_of(value: &Value) -> Vec<Condition> {
let Some(map) = value.as_mapping() else {
return Vec::new();
};
map.iter()
.filter_map(|(field, v)| {
let field = non_empty(field)?;
let value = scalar_texts(v).into_iter().next()?;
Some(Condition::Equals { field, value })
})
.collect()
}
fn branch(value: &Value, build: fn(Vec<Condition>) -> Condition) -> Option<Condition> {
let items = value.as_sequence()?;
let parsed: Vec<Condition> = items.iter().filter_map(Condition::parse).collect();
(!parsed.is_empty()).then(|| build(parsed))
}
fn non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(pairs: &[(&str, Value)]) -> Value {
let mut map = Mapping::new();
for (k, v) in pairs {
map.insert((*k).into(), v.clone());
}
Value::Mapping(map)
}
fn text(s: &str) -> Value {
Value::String(s.to_string())
}
fn seq(items: &[&str]) -> Value {
Value::Sequence(items.iter().map(|s| text(s)).collect())
}
fn parse(yaml_ish: &Value) -> Condition {
Condition::parse(yaml_ish).expect("a condition")
}
#[test]
fn has_means_present_and_not_empty() {
let c = parse(&doc(&[("has", text("people"))]));
assert!(c.matches(&doc(&[("people", text("Ada"))])));
assert!(c.matches(&doc(&[("people", seq(&["Ada"]))])));
assert!(!c.matches(&doc(&[])));
assert!(
!c.matches(&doc(&[("people", text(" "))])),
"written but unusable"
);
assert!(!c.matches(&doc(&[("people", Value::Sequence(vec![]))])));
}
#[test]
fn equals_matches_any_element_of_a_sequence() {
let c = parse(&doc(&[("equals", doc(&[("people", text("Grace"))]))]));
assert!(c.matches(&doc(&[("people", seq(&["Ada", "Grace"]))])));
assert!(!c.matches(&doc(&[("people", seq(&["Ada"]))])));
}
#[test]
fn equals_compares_as_text_across_scalar_kinds() {
let c = parse(&doc(&[("equals", doc(&[("rating", Value::Int(5))]))]));
assert!(c.matches(&doc(&[("rating", Value::Int(5))])));
assert!(c.matches(&doc(&[("rating", text("5"))])));
}
#[test]
fn a_multi_key_block_is_an_implicit_and() {
let c = parse(&doc(&[
("has", text("audience")),
("equals", doc(&[("audience", text("public"))])),
]));
assert!(c.matches(&doc(&[("audience", text("public"))])));
assert!(!c.matches(&doc(&[("audience", text("private"))])));
assert!(!c.matches(&doc(&[])));
}
#[test]
fn any_of_and_not_combine() {
let c = parse(&doc(&[(
"any-of",
Value::Sequence(vec![
doc(&[("equals", doc(&[("audience", text("public"))]))]),
doc(&[("equals", doc(&[("audience", text("friends"))]))]),
]),
)]));
assert!(c.matches(&doc(&[("audience", text("friends"))])));
assert!(!c.matches(&doc(&[("audience", text("private"))])));
let c = parse(&doc(&[("not", doc(&[("has", text("draft"))]))]));
assert!(c.matches(&doc(&[])));
assert!(!c.matches(&doc(&[("draft", Value::Bool(true))])));
}
#[test]
fn an_empty_where_is_not_a_filter_and_an_empty_any_of_selects_nothing() {
assert!(Condition::parse(&doc(&[])).is_none());
assert!(Condition::parse(&text("people")).is_none());
assert!(
Condition::parse(&doc(&[("any-of", Value::Sequence(vec![]))])).is_none(),
"nothing to combine is not a condition; the linter reports the shape"
);
assert!(!Condition::AnyOf(Vec::new()).matches(&doc(&[])));
assert!(Condition::AllOf(Vec::new()).matches(&doc(&[])));
}
#[test]
fn conditions_round_trip() {
for condition in [
Condition::Has("people".into()),
Condition::Equals {
field: "audience".into(),
value: "public".into(),
},
Condition::Not(Box::new(Condition::Has("draft".into()))),
Condition::AllOf(vec![
Condition::Has("audience".into()),
Condition::Equals {
field: "audience".into(),
value: "public".into(),
},
]),
Condition::AnyOf(vec![
Condition::Equals {
field: "audience".into(),
value: "public".into(),
},
Condition::Equals {
field: "audience".into(),
value: "friends".into(),
},
]),
] {
let back = Condition::parse(&condition.to_value()).expect("re-reads");
assert_eq!(back, condition);
}
}
}