1use prov_graph::meta::{Mapping, Value};
46
47use crate::spec::scalar_texts;
48
49pub const CONDITION_KEYS: &[&str] = &["has", "equals", "not", "any-of", "all-of"];
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Condition {
55 Has(String),
61 Equals {
64 field: String,
66 value: String,
68 },
69 Not(Box<Condition>),
71 AllOf(Vec<Condition>),
74 AnyOf(Vec<Condition>),
78}
79
80impl Condition {
81 pub fn matches(&self, meta: &Value) -> bool {
83 match self {
84 Condition::Has(field) => meta.get(field).is_some_and(|v| !scalar_texts(v).is_empty()),
85 Condition::Equals { field, value } => meta
86 .get(field)
87 .is_some_and(|v| scalar_texts(v).iter().any(|t| t == value)),
88 Condition::Not(inner) => !inner.matches(meta),
89 Condition::AllOf(all) => all.iter().all(|c| c.matches(meta)),
90 Condition::AnyOf(any) => any.iter().any(|c| c.matches(meta)),
91 }
92 }
93
94 pub fn parse(value: &Value) -> Option<Self> {
101 let map = value.as_mapping()?;
102 let mut conditions = Vec::new();
103 for (key, value) in map {
104 match key.as_str() {
105 "has" => conditions.extend(fields_of(value).into_iter().map(Condition::Has)),
106 "equals" => conditions.extend(equalities_of(value)),
107 "not" => {
108 conditions.extend(Condition::parse(value).map(|c| Condition::Not(c.into())))
109 }
110 "any-of" => conditions.extend(branch(value, Condition::AnyOf)),
111 "all-of" => conditions.extend(branch(value, Condition::AllOf)),
112 _ => {}
113 }
114 }
115 match conditions.len() {
116 0 => None,
117 1 => conditions.pop(),
120 _ => Some(Condition::AllOf(conditions)),
121 }
122 }
123
124 pub fn to_value(&self) -> Value {
126 let mut map = Mapping::new();
127 match self {
128 Condition::Has(field) => {
129 map.insert("has".into(), Value::String(field.clone()));
130 }
131 Condition::Equals { field, value } => {
132 let mut pairs = Mapping::new();
133 pairs.insert(field.clone(), Value::String(value.clone()));
134 map.insert("equals".into(), Value::Mapping(pairs));
135 }
136 Condition::Not(inner) => {
137 map.insert("not".into(), inner.to_value());
138 }
139 Condition::AllOf(all) => {
140 map.insert(
141 "all-of".into(),
142 Value::Sequence(all.iter().map(Condition::to_value).collect()),
143 );
144 }
145 Condition::AnyOf(any) => {
146 map.insert(
147 "any-of".into(),
148 Value::Sequence(any.iter().map(Condition::to_value).collect()),
149 );
150 }
151 }
152 Value::Mapping(map)
153 }
154}
155
156fn fields_of(value: &Value) -> Vec<String> {
158 match value {
159 Value::String(s) => non_empty(s).into_iter().collect(),
160 Value::Sequence(items) => items
161 .iter()
162 .filter_map(Value::as_str)
163 .filter_map(non_empty)
164 .collect(),
165 _ => Vec::new(),
166 }
167}
168
169fn equalities_of(value: &Value) -> Vec<Condition> {
171 let Some(map) = value.as_mapping() else {
172 return Vec::new();
173 };
174 map.iter()
175 .filter_map(|(field, v)| {
176 let field = non_empty(field)?;
177 let value = scalar_texts(v).into_iter().next()?;
182 Some(Condition::Equals { field, value })
183 })
184 .collect()
185}
186
187fn branch(value: &Value, build: fn(Vec<Condition>) -> Condition) -> Option<Condition> {
189 let items = value.as_sequence()?;
190 let parsed: Vec<Condition> = items.iter().filter_map(Condition::parse).collect();
191 (!parsed.is_empty()).then(|| build(parsed))
192}
193
194fn non_empty(text: &str) -> Option<String> {
195 let trimmed = text.trim();
196 (!trimmed.is_empty()).then(|| trimmed.to_string())
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn doc(pairs: &[(&str, Value)]) -> Value {
204 let mut map = Mapping::new();
205 for (k, v) in pairs {
206 map.insert((*k).into(), v.clone());
207 }
208 Value::Mapping(map)
209 }
210
211 fn text(s: &str) -> Value {
212 Value::String(s.to_string())
213 }
214
215 fn seq(items: &[&str]) -> Value {
216 Value::Sequence(items.iter().map(|s| text(s)).collect())
217 }
218
219 fn parse(yaml_ish: &Value) -> Condition {
220 Condition::parse(yaml_ish).expect("a condition")
221 }
222
223 #[test]
224 fn has_means_present_and_not_empty() {
225 let c = parse(&doc(&[("has", text("people"))]));
226 assert!(c.matches(&doc(&[("people", text("Ada"))])));
227 assert!(c.matches(&doc(&[("people", seq(&["Ada"]))])));
228 assert!(!c.matches(&doc(&[])));
229 assert!(
230 !c.matches(&doc(&[("people", text(" "))])),
231 "written but unusable"
232 );
233 assert!(!c.matches(&doc(&[("people", Value::Sequence(vec![]))])));
234 }
235
236 #[test]
237 fn equals_matches_any_element_of_a_sequence() {
238 let c = parse(&doc(&[("equals", doc(&[("people", text("Grace"))]))]));
239 assert!(c.matches(&doc(&[("people", seq(&["Ada", "Grace"]))])));
240 assert!(!c.matches(&doc(&[("people", seq(&["Ada"]))])));
241 }
242
243 #[test]
246 fn equals_compares_as_text_across_scalar_kinds() {
247 let c = parse(&doc(&[("equals", doc(&[("rating", Value::Int(5))]))]));
248 assert!(c.matches(&doc(&[("rating", Value::Int(5))])));
249 assert!(c.matches(&doc(&[("rating", text("5"))])));
250 }
251
252 #[test]
255 fn a_multi_key_block_is_an_implicit_and() {
256 let c = parse(&doc(&[
257 ("has", text("audience")),
258 ("equals", doc(&[("audience", text("public"))])),
259 ]));
260 assert!(c.matches(&doc(&[("audience", text("public"))])));
261 assert!(!c.matches(&doc(&[("audience", text("private"))])));
262 assert!(!c.matches(&doc(&[])));
263 }
264
265 #[test]
266 fn any_of_and_not_combine() {
267 let c = parse(&doc(&[(
268 "any-of",
269 Value::Sequence(vec![
270 doc(&[("equals", doc(&[("audience", text("public"))]))]),
271 doc(&[("equals", doc(&[("audience", text("friends"))]))]),
272 ]),
273 )]));
274 assert!(c.matches(&doc(&[("audience", text("friends"))])));
275 assert!(!c.matches(&doc(&[("audience", text("private"))])));
276
277 let c = parse(&doc(&[("not", doc(&[("has", text("draft"))]))]));
278 assert!(c.matches(&doc(&[])));
279 assert!(!c.matches(&doc(&[("draft", Value::Bool(true))])));
280 }
281
282 #[test]
285 fn an_empty_where_is_not_a_filter_and_an_empty_any_of_selects_nothing() {
286 assert!(Condition::parse(&doc(&[])).is_none());
287 assert!(Condition::parse(&text("people")).is_none());
288 assert!(
289 Condition::parse(&doc(&[("any-of", Value::Sequence(vec![]))])).is_none(),
290 "nothing to combine is not a condition; the linter reports the shape"
291 );
292 assert!(!Condition::AnyOf(Vec::new()).matches(&doc(&[])));
293 assert!(Condition::AllOf(Vec::new()).matches(&doc(&[])));
294 }
295
296 #[test]
297 fn conditions_round_trip() {
298 for condition in [
299 Condition::Has("people".into()),
300 Condition::Equals {
301 field: "audience".into(),
302 value: "public".into(),
303 },
304 Condition::Not(Box::new(Condition::Has("draft".into()))),
305 Condition::AllOf(vec![
306 Condition::Has("audience".into()),
307 Condition::Equals {
308 field: "audience".into(),
309 value: "public".into(),
310 },
311 ]),
312 Condition::AnyOf(vec![
313 Condition::Equals {
314 field: "audience".into(),
315 value: "public".into(),
316 },
317 Condition::Equals {
318 field: "audience".into(),
319 value: "friends".into(),
320 },
321 ]),
322 ] {
323 let back = Condition::parse(&condition.to_value()).expect("re-reads");
324 assert_eq!(back, condition);
325 }
326 }
327}