Skip to main content

prov_views/
filter.rs

1//! `where:` — the conditions a document must meet to be in a view.
2//!
3//! Scope ([`under`](crate::ViewSpec::under)) says *where* a view looks; this
4//! says *which of what it finds* belongs. The two are separate because they
5//! fail differently: an anchor that names nothing is a broken view, while a
6//! condition that matches nothing is an ordinary empty answer.
7//!
8//! # A closed vocabulary, deliberately
9//!
10//! Two predicates — [`Has`](Condition::Has) and [`Equals`](Condition::Equals) —
11//! and three combinators. That is enough to express the real filtering that
12//! exists today (a publishing audience: *this document declares an `audience`,
13//! and it is `public`*) and it is deliberately nowhere near an expression
14//! language.
15//!
16//! Formulas are the point of no return for a view format: once views with
17//! arbitrary expressions exist in the wild, the expression grammar is
18//! load-bearing forever and every reader of the format has to implement it. A
19//! closed set of named predicates can grow one member at a time, each with a
20//! reason; a grammar cannot be taken back. So the rule for adding to this enum
21//! is a concrete lens that cannot otherwise be said — not a shape that seems
22//! likely to be wanted.
23//!
24//! # The spelling
25//!
26//! ```yaml
27//! where:
28//!   has: people                    # present, and not empty
29//!   equals: { audience: public }   # carries this value
30//! ```
31//!
32//! A mapping with several keys is an implicit **and** — every condition must
33//! hold. So is a list given to `has:`, and so is a multi-key `equals:`. The
34//! other two combinators are explicit:
35//!
36//! ```yaml
37//! where:
38//!   any-of:
39//!     - equals: { audience: public }
40//!     - equals: { audience: friends }
41//!   not:
42//!     has: draft
43//! ```
44
45use prov_graph::meta::{Mapping, Value};
46
47use crate::spec::scalar_texts;
48
49/// The keys valid inside a `where:` block.
50pub const CONDITION_KEYS: &[&str] = &["has", "equals", "not", "any-of", "all-of"];
51
52/// A condition a document's metadata must satisfy.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Condition {
55    /// The field is present and carries at least one non-empty value.
56    ///
57    /// "Present" means *usable*, not merely written: a `people:` with an empty
58    /// string under it has nothing to group or display, and a view that
59    /// included it would be showing a row it cannot say anything about.
60    Has(String),
61    /// The field carries this value — any element of it, for a sequence, so
62    /// `equals: { people: Ada }` matches a document listing several people.
63    Equals {
64        /// The field to read.
65        field: String,
66        /// The value it must carry, compared as text.
67        value: String,
68    },
69    /// The inverse of the condition it wraps.
70    Not(Box<Condition>),
71    /// Every condition must hold. An empty list holds vacuously, which is what
72    /// makes it the identity a multi-key mapping folds into.
73    AllOf(Vec<Condition>),
74    /// At least one condition must hold. An empty list holds for nothing, so a
75    /// `any-of: []` selects nothing rather than everything — the reading that
76    /// cannot silently publish a whole workspace.
77    AnyOf(Vec<Condition>),
78}
79
80impl Condition {
81    /// Whether `meta` satisfies this condition.
82    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    /// Read a `where:` block.
95    ///
96    /// Returns `None` for a value that is not a mapping, or one that yields no
97    /// condition at all — an empty `where:` is not a filter that excludes
98    /// everything, it is a view that did not say anything, and treating it as
99    /// the former would hide a whole workspace behind a typo.
100    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            // A single condition is written back as itself rather than wrapped,
118            // so a round trip does not accrete `all-of` layers.
119            1 => conditions.pop(),
120            _ => Some(Condition::AllOf(conditions)),
121        }
122    }
123
124    /// The mapping this condition writes back as.
125    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
156/// A field name, or a list of them — the `has:` shapes.
157fn 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
169/// The `field: value` pairs of an `equals:` mapping.
170fn 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            // Compared as text, the same way a group key is derived, so
178            // `equals: { rating: 5 }` matches whether the frontmatter wrote
179            // `5` or `"5"` — a view must not depend on which of those a format
180            // happened to round-trip.
181            let value = scalar_texts(v).into_iter().next()?;
182            Some(Condition::Equals { field, value })
183        })
184        .collect()
185}
186
187/// A list of sub-conditions under `any-of`/`all-of`.
188fn 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    /// A view must not depend on whether a format round-tripped `5` as a number
244    /// or a string.
245    #[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    /// Several keys in one mapping are an implicit and — the shape a publishing
253    /// audience actually takes.
254    #[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    /// An empty `any-of` selects nothing. The other reading — that it holds
283    /// vacuously — would publish a whole workspace on a typo.
284    #[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}