Skip to main content

openvet_policy/
expr.rs

1//! Requirement expression: parse and evaluate.
2//!
3//! Grammar:
4//! ```text
5//! expr         := implies_expr
6//! implies_expr := or_expr ('implies' implies_expr)?
7//! or_expr      := and_expr ('or' and_expr)*
8//! and_expr     := not_expr ('and' not_expr)*
9//! not_expr     := 'not' not_expr | atom
10//! atom         := claim | '(' expr ')'
11//! claim        := [a-zA-Z_][a-zA-Z0-9_-]*
12//! ```
13//!
14//! Standard precedence (`not` > `and` > `or` > `implies`); `implies`
15//! is right-associative, so `a implies b implies c` parses as
16//! `a implies (b implies c)`. Reserved words are literally `and`,
17//! `or`, `not`, `implies` (not case-sensitive).
18//!
19//! `implies` is syntactic sugar: `a implies b` parses to the same
20//! AST as `(not a) or b`, so vacuous truth (`False implies _ == True`)
21//! and the Kleene truth table for implication fall out of the
22//! existing `Or` and `Not` evaluation.
23//!
24//! [`Expr::And`] and [`Expr::Or`] are n-ary. The parser collects
25//! same-operator chains into a single `Vec<Expr>` via the
26//! [`Expr::and`] / [`Expr::or`] smart constructors, which splice
27//! same-op children — so `a and b and c`, `(a and b) and c`, and
28//! the `Or` produced by `a implies b or c` all flatten to one
29//! level. Associativity makes the shape change invisible to
30//! evaluation, but diagnostic tree rendering is one level shallower
31//! per same-op chain.
32//!
33//! Evaluation is Kleene 3-valued logic over [`Tri`]:
34//!
35//! - `False` short-circuits `and`.
36//! - `True` short-circuits `or`.
37//! - `Unknown` propagates when neither short-circuit fires and the
38//!   result isn't determined.
39
40use crate::error::PolicyError;
41
42/// A parsed requirement expression over claim names.
43///
44/// `And` and `Or` are n-ary: a chain of same-operator children is
45/// kept flat in a single `Vec<Expr>` rather than nested left-folded
46/// binary nodes. Construct via [`Expr::and`] / [`Expr::or`] /
47/// [`Expr::not`] — those smart constructors splice same-operator
48/// children so `a and b and c` parses to one `And([a, b, c])` and
49/// `(a or b) or c` parses to one `Or([a, b, c])`. The shape change
50/// is invisible to evaluation (`or` and `and` are associative) but
51/// makes diagnostic tree rendering one level shallower per same-op
52/// chain.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Expr {
55    /// Reference to a single claim by name.
56    Claim(String),
57    /// Logical negation.
58    Not(Box<Expr>),
59    /// Logical conjunction over one-or-more children.
60    And(Vec<Expr>),
61    /// Logical disjunction over one-or-more children.
62    Or(Vec<Expr>),
63}
64
65impl Expr {
66    /// Build an [`Expr::And`] from `parts`, splicing any child that
67    /// is itself an `And` so the result is a flat one-level chain.
68    ///
69    /// A single-element vec collapses to its lone child unwrapped.
70    /// Empty vec is the identity (vacuously `True`); the parser
71    /// never produces this, but the constructor is defined for it.
72    pub fn and(parts: Vec<Expr>) -> Expr {
73        let mut flat = Vec::with_capacity(parts.len());
74        for p in parts {
75            match p {
76                Expr::And(children) => flat.extend(children),
77                other => flat.push(other),
78            }
79        }
80        if flat.len() == 1 {
81            flat.into_iter().next().unwrap()
82        } else {
83            Expr::And(flat)
84        }
85    }
86
87    /// Build an [`Expr::Or`] from `parts`, splicing any child that
88    /// is itself an `Or` so the result is a flat one-level chain.
89    ///
90    /// A single-element vec collapses to its lone child unwrapped.
91    /// Empty vec is the identity (vacuously `False`); the parser
92    /// never produces this, but the constructor is defined for it.
93    pub fn or(parts: Vec<Expr>) -> Expr {
94        let mut flat = Vec::with_capacity(parts.len());
95        for p in parts {
96            match p {
97                Expr::Or(children) => flat.extend(children),
98                other => flat.push(other),
99            }
100        }
101        if flat.len() == 1 {
102            flat.into_iter().next().unwrap()
103        } else {
104            Expr::Or(flat)
105        }
106    }
107
108    /// Wrap `inner` in an [`Expr::Not`].
109    ///
110    /// Named to match [`Expr::and`] / [`Expr::or`] rather than via
111    /// `std::ops::Not` — the constructor triad reads better as
112    /// `Expr::and` / `Expr::or` / `Expr::not` than as a mix of
113    /// `Expr::and` and `!expr`.
114    #[allow(clippy::should_implement_trait)]
115    pub fn not(inner: Expr) -> Expr {
116        Expr::Not(Box::new(inner))
117    }
118}
119
120/// Kleene 3-valued logic state.
121///
122/// `Unknown` propagates when a claim's value can't be decided, so
123/// "audit didn't assert this claim" and "audit asserted this claim
124/// false" stay distinct end to end.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum Tri {
127    /// Claim asserted true.
128    True,
129    /// Claim asserted false.
130    False,
131    /// Claim not asserted (or expression result undetermined).
132    Unknown,
133}
134
135impl std::ops::Not for Tri {
136    type Output = Tri;
137    fn not(self) -> Self {
138        match self {
139            Tri::True => Tri::False,
140            Tri::False => Tri::True,
141            Tri::Unknown => Tri::Unknown,
142        }
143    }
144}
145
146/// Evaluate `expr` against a claim-lookup function returning each
147/// claim's tri-state.
148///
149/// Short-circuits on `False` for `and` and on `True` for `or`.
150/// `not Unknown` stays `Unknown`.
151pub fn evaluate<F>(expr: &Expr, lookup: &F) -> Tri
152where
153    F: Fn(&str) -> Tri,
154{
155    match expr {
156        Expr::Claim(name) => lookup(name),
157        Expr::Not(inner) => !evaluate(inner, lookup),
158        Expr::And(children) => {
159            // Empty AND is vacuously true; short-circuit on any False;
160            // True if every child is True; Unknown otherwise.
161            let mut all_true = true;
162            for c in children {
163                match evaluate(c, lookup) {
164                    Tri::False => return Tri::False,
165                    Tri::True => {}
166                    Tri::Unknown => all_true = false,
167                }
168            }
169            if all_true { Tri::True } else { Tri::Unknown }
170        }
171        Expr::Or(children) => {
172            // Empty OR is vacuously false; short-circuit on any True;
173            // False if every child is False; Unknown otherwise.
174            let mut all_false = true;
175            for c in children {
176                match evaluate(c, lookup) {
177                    Tri::True => return Tri::True,
178                    Tri::False => {}
179                    Tri::Unknown => all_false = false,
180                }
181            }
182            if all_false { Tri::False } else { Tri::Unknown }
183        }
184    }
185}
186
187// ──────────────────────────────────────────────────────────────────
188// Tokenizer
189// ──────────────────────────────────────────────────────────────────
190
191#[derive(Debug, PartialEq, Eq)]
192enum Token {
193    Ident(String),
194    And,
195    Or,
196    Not,
197    Implies,
198    LParen,
199    RParen,
200}
201
202fn tokenize(input: &str) -> Result<Vec<Token>, PolicyError> {
203    let mut out = Vec::new();
204    let mut chars = input.chars().peekable();
205    while let Some(&c) = chars.peek() {
206        if c.is_whitespace() {
207            chars.next();
208            continue;
209        }
210        if c == '(' {
211            chars.next();
212            out.push(Token::LParen);
213            continue;
214        }
215        if c == ')' {
216            chars.next();
217            out.push(Token::RParen);
218            continue;
219        }
220        if is_ident_start(c) {
221            let mut s = String::new();
222            while let Some(&c) = chars.peek() {
223                if is_ident_continue(c) {
224                    s.push(c);
225                    chars.next();
226                } else {
227                    break;
228                }
229            }
230            out.push(match s.to_ascii_lowercase().as_str() {
231                "and" => Token::And,
232                "or" => Token::Or,
233                "not" => Token::Not,
234                "implies" => Token::Implies,
235                _ => Token::Ident(s),
236            });
237            continue;
238        }
239        return Err(PolicyError::ExprParse(format!(
240            "unexpected character {c:?} in expression"
241        )));
242    }
243    Ok(out)
244}
245
246fn is_ident_start(c: char) -> bool {
247    c.is_ascii_alphabetic() || c == '_'
248}
249
250fn is_ident_continue(c: char) -> bool {
251    c.is_ascii_alphanumeric() || c == '_' || c == '-'
252}
253
254// ──────────────────────────────────────────────────────────────────
255// Recursive-descent parser
256// ──────────────────────────────────────────────────────────────────
257
258struct Parser {
259    tokens: std::vec::IntoIter<Token>,
260    peeked: Option<Token>,
261}
262
263impl Parser {
264    fn new(tokens: Vec<Token>) -> Self {
265        Self {
266            tokens: tokens.into_iter(),
267            peeked: None,
268        }
269    }
270
271    fn peek(&mut self) -> Option<&Token> {
272        if self.peeked.is_none() {
273            self.peeked = self.tokens.next();
274        }
275        self.peeked.as_ref()
276    }
277
278    fn consume(&mut self) -> Option<Token> {
279        if let Some(t) = self.peeked.take() {
280            return Some(t);
281        }
282        self.tokens.next()
283    }
284
285    fn parse_expr(&mut self) -> Result<Expr, PolicyError> {
286        self.parse_implies()
287    }
288
289    fn parse_implies(&mut self) -> Result<Expr, PolicyError> {
290        let left = self.parse_or()?;
291        if matches!(self.peek(), Some(Token::Implies)) {
292            self.consume();
293            // Right-associative: recurse into parse_implies for the RHS.
294            // `Expr::or` splices when `right` is itself an Or, so
295            // `a implies b or c` flattens to `Or([Not(a), b, c])`.
296            let right = self.parse_implies()?;
297            return Ok(Expr::or(vec![Expr::not(left), right]));
298        }
299        Ok(left)
300    }
301
302    fn parse_or(&mut self) -> Result<Expr, PolicyError> {
303        let mut parts = vec![self.parse_and()?];
304        while matches!(self.peek(), Some(Token::Or)) {
305            self.consume();
306            parts.push(self.parse_and()?);
307        }
308        Ok(Expr::or(parts))
309    }
310
311    fn parse_and(&mut self) -> Result<Expr, PolicyError> {
312        let mut parts = vec![self.parse_not()?];
313        while matches!(self.peek(), Some(Token::And)) {
314            self.consume();
315            parts.push(self.parse_not()?);
316        }
317        Ok(Expr::and(parts))
318    }
319
320    fn parse_not(&mut self) -> Result<Expr, PolicyError> {
321        if matches!(self.peek(), Some(Token::Not)) {
322            self.consume();
323            let inner = self.parse_not()?;
324            return Ok(Expr::not(inner));
325        }
326        self.parse_atom()
327    }
328
329    fn parse_atom(&mut self) -> Result<Expr, PolicyError> {
330        match self.consume() {
331            Some(Token::Ident(s)) => Ok(Expr::Claim(s)),
332            Some(Token::LParen) => {
333                let inner = self.parse_expr()?;
334                match self.consume() {
335                    Some(Token::RParen) => Ok(inner),
336                    _ => Err(PolicyError::ExprParse("missing closing ')'".into())),
337                }
338            }
339            Some(t) => Err(PolicyError::ExprParse(format!(
340                "unexpected token {t:?}; expected claim or '('"
341            ))),
342            None => Err(PolicyError::ExprParse(
343                "unexpected end of expression".into(),
344            )),
345        }
346    }
347}
348
349/// Parse a requirement-expression string into an [`Expr`].
350///
351/// See the module docs for the grammar. Errors with
352/// [`PolicyError::ExprParse`] on malformed input or trailing
353/// tokens.
354pub fn parse(input: &str) -> Result<Expr, PolicyError> {
355    let tokens = tokenize(input)?;
356    let mut p = Parser::new(tokens);
357    let expr = p.parse_expr()?;
358    if p.peek().is_some() {
359        return Err(PolicyError::ExprParse(format!(
360            "trailing tokens after expression: {:?}",
361            p.consume()
362        )));
363    }
364    Ok(expr)
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn ev(expr: &str, claims: &[(&str, bool)]) -> Tri {
372        let e = parse(expr).unwrap();
373        let lookup = |name: &str| match claims.iter().find(|(n, _)| *n == name) {
374            Some((_, true)) => Tri::True,
375            Some((_, false)) => Tri::False,
376            None => Tri::Unknown,
377        };
378        evaluate(&e, &lookup)
379    }
380
381    #[test]
382    fn single_claim() {
383        assert_eq!(ev("safe-to-deploy", &[("safe-to-deploy", true)]), Tri::True);
384        assert_eq!(
385            ev("safe-to-deploy", &[("safe-to-deploy", false)]),
386            Tri::False
387        );
388        assert_eq!(ev("safe-to-deploy", &[]), Tri::Unknown);
389    }
390
391    #[test]
392    fn and_basic() {
393        assert_eq!(ev("a and b", &[("a", true), ("b", true)]), Tri::True);
394        assert_eq!(ev("a and b", &[("a", true), ("b", false)]), Tri::False);
395        assert_eq!(ev("a and b", &[("a", true)]), Tri::Unknown);
396    }
397
398    #[test]
399    fn and_short_circuits_on_false() {
400        // `b` is unknown but irrelevant since `a` is false.
401        assert_eq!(ev("a and b", &[("a", false)]), Tri::False);
402    }
403
404    #[test]
405    fn or_basic() {
406        assert_eq!(ev("a or b", &[("a", false), ("b", true)]), Tri::True);
407        assert_eq!(ev("a or b", &[("a", false), ("b", false)]), Tri::False);
408        assert_eq!(ev("a or b", &[("a", false)]), Tri::Unknown);
409    }
410
411    #[test]
412    fn or_short_circuits_on_true() {
413        assert_eq!(ev("a or b", &[("a", true)]), Tri::True);
414    }
415
416    #[test]
417    fn not_inverts() {
418        assert_eq!(ev("not a", &[("a", true)]), Tri::False);
419        assert_eq!(ev("not a", &[("a", false)]), Tri::True);
420        assert_eq!(ev("not a", &[]), Tri::Unknown);
421    }
422
423    #[test]
424    fn precedence_not_binds_tightest() {
425        // `not a and b` parses as `(not a) and b`, not `not (a and b)`.
426        assert_eq!(ev("not a and b", &[("a", false), ("b", true)]), Tri::True);
427        assert_eq!(ev("not a and b", &[("a", true), ("b", true)]), Tri::False);
428    }
429
430    #[test]
431    fn precedence_and_binds_tighter_than_or() {
432        // `a or b and c` parses as `a or (b and c)`.
433        assert_eq!(
434            ev("a or b and c", &[("a", false), ("b", true), ("c", true)]),
435            Tri::True
436        );
437        assert_eq!(
438            ev("a or b and c", &[("a", false), ("b", true), ("c", false)]),
439            Tri::False
440        );
441    }
442
443    #[test]
444    fn parens_override_precedence() {
445        // `(a or b) and c` — without parens this'd be `a or (b and c)`.
446        assert_eq!(
447            ev("(a or b) and c", &[("a", true), ("c", false)]),
448            Tri::False
449        );
450    }
451
452    #[test]
453    fn nested_not_and_or() {
454        assert_eq!(
455            ev(
456                "not (a and b) or c",
457                &[("a", true), ("b", true), ("c", false)]
458            ),
459            Tri::False
460        );
461        assert_eq!(
462            ev("not (a and b) or c", &[("a", true), ("b", false)]),
463            Tri::True
464        );
465    }
466
467    #[test]
468    fn case_insensitive_keywords() {
469        assert_eq!(ev("a AND b", &[("a", true), ("b", true)]), Tri::True);
470        assert_eq!(ev("NOT a", &[("a", true)]), Tri::False);
471        assert_eq!(ev("a IMPLIES b", &[("a", true), ("b", true)]), Tri::True);
472    }
473
474    #[test]
475    fn implies_truth_table() {
476        // Classical cases.
477        assert_eq!(ev("a implies b", &[("a", true), ("b", true)]), Tri::True);
478        assert_eq!(ev("a implies b", &[("a", true), ("b", false)]), Tri::False);
479        // Vacuous truth: false antecedent → true regardless of consequent.
480        assert_eq!(ev("a implies b", &[("a", false), ("b", false)]), Tri::True);
481        assert_eq!(ev("a implies b", &[("a", false)]), Tri::True);
482        // Kleene: true antecedent, unknown consequent → unknown.
483        assert_eq!(ev("a implies b", &[("a", true)]), Tri::Unknown);
484        // Kleene: unknown antecedent, true consequent → true
485        // (the `or` short-circuits regardless of the negated unknown).
486        assert_eq!(ev("a implies b", &[("b", true)]), Tri::True);
487        // Kleene: unknown antecedent, false consequent → unknown.
488        assert_eq!(ev("a implies b", &[("b", false)]), Tri::Unknown);
489    }
490
491    #[test]
492    fn implies_lower_than_or_and_and() {
493        // `a implies b and c` parses as `a implies (b and c)`.
494        assert_eq!(
495            ev(
496                "a implies b and c",
497                &[("a", true), ("b", true), ("c", false)]
498            ),
499            Tri::False
500        );
501        // `a or b implies c` parses as `(a or b) implies c`.
502        // With a=true, antecedent is true; consequent c=false → false.
503        assert_eq!(
504            ev(
505                "a or b implies c",
506                &[("a", true), ("b", false), ("c", false)]
507            ),
508            Tri::False
509        );
510        // With a=false, b=false: antecedent false → vacuously true.
511        assert_eq!(
512            ev(
513                "a or b implies c",
514                &[("a", false), ("b", false), ("c", false)]
515            ),
516            Tri::True
517        );
518    }
519
520    #[test]
521    fn implies_right_associative() {
522        // `a implies b implies c` parses as `a implies (b implies c)`.
523        // Left-assoc would mean `(a implies b) implies c`; pick a witness
524        // where the two associations disagree:
525        // a=true, b=false, c=false.
526        //   right-assoc: a implies (b implies c) = T → (F → F) = T → T = T
527        //   left-assoc:  (a implies b) implies c = (T → F) → F = F → F = T
528        // Same here. Try a=false, b=true, c=false:
529        //   right-assoc: F → (T → F) = F → F = T
530        //   left-assoc:  (F → T) → F = T → F = F
531        assert_eq!(
532            ev(
533                "a implies b implies c",
534                &[("a", false), ("b", true), ("c", false)]
535            ),
536            Tri::True
537        );
538    }
539
540    #[test]
541    fn implies_desugars_to_or_not() {
542        // `a implies b` should parse to the same AST as `(not a) or b`.
543        let lhs = parse("a implies b").unwrap();
544        let rhs = parse("(not a) or b").unwrap();
545        assert_eq!(lhs, rhs);
546    }
547
548    #[test]
549    fn nary_and_chain_flattens() {
550        // `a and b and c` parses to one n-ary And, not a left-folded
551        // pair of binary Ands.
552        let e = parse("a and b and c").unwrap();
553        let Expr::And(children) = e else {
554            panic!("expected top-level And");
555        };
556        assert_eq!(children.len(), 3);
557        assert_eq!(children[0], Expr::Claim("a".into()));
558        assert_eq!(children[1], Expr::Claim("b".into()));
559        assert_eq!(children[2], Expr::Claim("c".into()));
560    }
561
562    #[test]
563    fn nary_or_chain_flattens() {
564        let e = parse("a or b or c").unwrap();
565        let Expr::Or(children) = e else {
566            panic!("expected top-level Or");
567        };
568        assert_eq!(children.len(), 3);
569    }
570
571    #[test]
572    fn parens_with_same_op_get_spliced() {
573        // `(a or b) or c` should flatten to one Or, not nest.
574        let e = parse("(a or b) or c").unwrap();
575        let Expr::Or(children) = e else {
576            panic!("expected flat Or");
577        };
578        assert_eq!(children.len(), 3);
579
580        // `a and (b and c)` same — parens can't change associativity,
581        // so the shape collapses.
582        let e = parse("a and (b and c)").unwrap();
583        let Expr::And(children) = e else {
584            panic!("expected flat And");
585        };
586        assert_eq!(children.len(), 3);
587    }
588
589    #[test]
590    fn implies_with_or_rhs_splices_into_one_or() {
591        // `a implies b or c` desugars to `or(not(a), or(b, c))` and
592        // then the smart constructor splices the inner Or, giving
593        // `Or([Not(a), b, c])` — three flat children.
594        let e = parse("a implies b or c").unwrap();
595        let Expr::Or(children) = e else {
596            panic!("expected top-level Or");
597        };
598        assert_eq!(children.len(), 3);
599        assert_eq!(children[0], Expr::not(Expr::Claim("a".into())));
600        assert_eq!(children[1], Expr::Claim("b".into()));
601        assert_eq!(children[2], Expr::Claim("c".into()));
602    }
603
604    #[test]
605    fn mixed_op_nesting_is_preserved() {
606        // `a and (b or c) and d`: the middle child is an Or, not an
607        // And, so it stays nested under the outer And.
608        let e = parse("a and (b or c) and d").unwrap();
609        let Expr::And(children) = e else {
610            panic!("expected top-level And");
611        };
612        assert_eq!(children.len(), 3);
613        assert!(matches!(&children[1], Expr::Or(inner) if inner.len() == 2));
614    }
615
616    #[test]
617    fn single_element_collapses() {
618        // `Expr::and(vec![x])` returns `x` directly, not `And([x])`.
619        let x = Expr::Claim("x".into());
620        assert_eq!(Expr::and(vec![x.clone()]), x);
621        assert_eq!(Expr::or(vec![x.clone()]), x);
622    }
623
624    #[test]
625    fn parse_errors() {
626        assert!(parse("a and").is_err());
627        assert!(parse("(a or b").is_err());
628        assert!(parse("and a").is_err());
629        assert!(parse("a b").is_err()); // trailing token
630        assert!(parse("").is_err());
631        assert!(parse("a #").is_err()); // unknown character
632        assert!(parse("a implies").is_err()); // missing consequent
633        assert!(parse("implies b").is_err()); // missing antecedent
634    }
635}