Skip to main content

scud/attractor/
conditions.rs

1//! Condition expression parser and evaluator.
2//!
3//! Minimal boolean language for edge conditions:
4//! - `key=value` — equality check
5//! - `key!=value` — inequality check
6//! - `expr && expr` — conjunction
7//!
8//! Keys can be:
9//! - `outcome` — matches the outcome status string
10//! - `preferred_label` — matches the preferred label from outcome
11//! - `context.key` — looks up key in the execution context
12//! - Unqualified keys fall back to context lookup
13
14use std::collections::HashMap;
15
16use super::outcome::Outcome;
17
18/// A parsed condition expression.
19#[derive(Debug, Clone, PartialEq)]
20pub enum Condition {
21    /// Always true (empty condition).
22    Always,
23    /// key = value
24    Eq(String, String),
25    /// key != value
26    Neq(String, String),
27    /// All conditions must be true.
28    And(Vec<Condition>),
29}
30
31/// Parse a condition string into a Condition.
32pub fn parse_condition(input: &str) -> Condition {
33    let input = input.trim();
34    if input.is_empty() {
35        return Condition::Always;
36    }
37
38    // Split on &&
39    let parts: Vec<&str> = input.split("&&").collect();
40    if parts.len() > 1 {
41        let conditions: Vec<Condition> = parts
42            .into_iter()
43            .map(|p| parse_single_condition(p.trim()))
44            .collect();
45        return Condition::And(conditions);
46    }
47
48    parse_single_condition(input)
49}
50
51fn parse_single_condition(input: &str) -> Condition {
52    let input = input.trim();
53    if input.is_empty() {
54        return Condition::Always;
55    }
56
57    // Check for != first (before =)
58    if let Some(pos) = input.find("!=") {
59        let key = input[..pos].trim().to_string();
60        let value = input[pos + 2..].trim().to_string();
61        return Condition::Neq(key, value);
62    }
63
64    // Check for =
65    if let Some(pos) = input.find('=') {
66        let key = input[..pos].trim().to_string();
67        let value = input[pos + 1..].trim().to_string();
68        return Condition::Eq(key, value);
69    }
70
71    // Bare word — treat as key=true
72    Condition::Eq(input.to_string(), "true".to_string())
73}
74
75/// Evaluate a condition against an outcome and context.
76pub fn evaluate_condition(
77    condition: &Condition,
78    outcome: &Outcome,
79    context: &HashMap<String, serde_json::Value>,
80) -> bool {
81    match condition {
82        Condition::Always => true,
83        Condition::Eq(key, value) => resolve_value(key, outcome, context) == *value,
84        Condition::Neq(key, value) => resolve_value(key, outcome, context) != *value,
85        Condition::And(conditions) => conditions
86            .iter()
87            .all(|c| evaluate_condition(c, outcome, context)),
88    }
89}
90
91/// Resolve a key to a string value from the outcome or context.
92fn resolve_value(
93    key: &str,
94    outcome: &Outcome,
95    context: &HashMap<String, serde_json::Value>,
96) -> String {
97    match key {
98        "outcome" => outcome.status.as_str().to_string(),
99        "preferred_label" => outcome
100            .preferred_label
101            .clone()
102            .unwrap_or_default(),
103        _ => {
104            // Try context.key prefix
105            let ctx_key = if key.starts_with("context.") {
106                &key[8..]
107            } else {
108                key
109            };
110            context
111                .get(ctx_key)
112                .map(|v| match v {
113                    serde_json::Value::String(s) => s.clone(),
114                    serde_json::Value::Bool(b) => b.to_string(),
115                    serde_json::Value::Number(n) => n.to_string(),
116                    other => other.to_string(),
117                })
118                .unwrap_or_default()
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::attractor::outcome::Outcome;
127
128    #[test]
129    fn test_parse_empty() {
130        assert_eq!(parse_condition(""), Condition::Always);
131    }
132
133    #[test]
134    fn test_parse_eq() {
135        assert_eq!(
136            parse_condition("outcome=success"),
137            Condition::Eq("outcome".into(), "success".into())
138        );
139    }
140
141    #[test]
142    fn test_parse_neq() {
143        assert_eq!(
144            parse_condition("outcome!=failure"),
145            Condition::Neq("outcome".into(), "failure".into())
146        );
147    }
148
149    #[test]
150    fn test_parse_and() {
151        let cond = parse_condition("outcome=success && context.approved=true");
152        match cond {
153            Condition::And(parts) => {
154                assert_eq!(parts.len(), 2);
155                assert_eq!(
156                    parts[0],
157                    Condition::Eq("outcome".into(), "success".into())
158                );
159                assert_eq!(
160                    parts[1],
161                    Condition::Eq("context.approved".into(), "true".into())
162                );
163            }
164            _ => panic!("Expected And condition"),
165        }
166    }
167
168    #[test]
169    fn test_evaluate_outcome_eq() {
170        let outcome = Outcome::success();
171        let ctx = HashMap::new();
172        let cond = parse_condition("outcome=success");
173        assert!(evaluate_condition(&cond, &outcome, &ctx));
174    }
175
176    #[test]
177    fn test_evaluate_outcome_neq() {
178        let outcome = Outcome::success();
179        let ctx = HashMap::new();
180        let cond = parse_condition("outcome!=failure");
181        assert!(evaluate_condition(&cond, &outcome, &ctx));
182    }
183
184    #[test]
185    fn test_evaluate_preferred_label() {
186        let outcome = Outcome::success_with_label("approve");
187        let ctx = HashMap::new();
188        let cond = parse_condition("preferred_label=approve");
189        assert!(evaluate_condition(&cond, &outcome, &ctx));
190    }
191
192    #[test]
193    fn test_evaluate_context_value() {
194        let outcome = Outcome::success();
195        let mut ctx = HashMap::new();
196        ctx.insert("test_passed".into(), serde_json::json!("true"));
197        let cond = parse_condition("test_passed=true");
198        assert!(evaluate_condition(&cond, &outcome, &ctx));
199    }
200
201    #[test]
202    fn test_evaluate_context_prefix() {
203        let outcome = Outcome::success();
204        let mut ctx = HashMap::new();
205        ctx.insert("flag".into(), serde_json::json!("yes"));
206        let cond = parse_condition("context.flag=yes");
207        assert!(evaluate_condition(&cond, &outcome, &ctx));
208    }
209
210    #[test]
211    fn test_evaluate_and_all_true() {
212        let outcome = Outcome::success();
213        let mut ctx = HashMap::new();
214        ctx.insert("ready".into(), serde_json::json!("true"));
215        let cond = parse_condition("outcome=success && ready=true");
216        assert!(evaluate_condition(&cond, &outcome, &ctx));
217    }
218
219    #[test]
220    fn test_evaluate_and_one_false() {
221        let outcome = Outcome::success();
222        let ctx = HashMap::new();
223        let cond = parse_condition("outcome=success && missing=true");
224        assert!(!evaluate_condition(&cond, &outcome, &ctx));
225    }
226}