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.preferred_label.clone().unwrap_or_default(),
100        _ => {
101            // Try context.key prefix
102            let ctx_key = if key.starts_with("context.") {
103                &key[8..]
104            } else {
105                key
106            };
107            context
108                .get(ctx_key)
109                .map(|v| match v {
110                    serde_json::Value::String(s) => s.clone(),
111                    serde_json::Value::Bool(b) => b.to_string(),
112                    serde_json::Value::Number(n) => n.to_string(),
113                    other => other.to_string(),
114                })
115                .unwrap_or_default()
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::attractor::outcome::Outcome;
124
125    #[test]
126    fn test_parse_empty() {
127        assert_eq!(parse_condition(""), Condition::Always);
128    }
129
130    #[test]
131    fn test_parse_eq() {
132        assert_eq!(
133            parse_condition("outcome=success"),
134            Condition::Eq("outcome".into(), "success".into())
135        );
136    }
137
138    #[test]
139    fn test_parse_neq() {
140        assert_eq!(
141            parse_condition("outcome!=failure"),
142            Condition::Neq("outcome".into(), "failure".into())
143        );
144    }
145
146    #[test]
147    fn test_parse_and() {
148        let cond = parse_condition("outcome=success && context.approved=true");
149        match cond {
150            Condition::And(parts) => {
151                assert_eq!(parts.len(), 2);
152                assert_eq!(parts[0], Condition::Eq("outcome".into(), "success".into()));
153                assert_eq!(
154                    parts[1],
155                    Condition::Eq("context.approved".into(), "true".into())
156                );
157            }
158            _ => panic!("Expected And condition"),
159        }
160    }
161
162    #[test]
163    fn test_evaluate_outcome_eq() {
164        let outcome = Outcome::success();
165        let ctx = HashMap::new();
166        let cond = parse_condition("outcome=success");
167        assert!(evaluate_condition(&cond, &outcome, &ctx));
168    }
169
170    #[test]
171    fn test_evaluate_outcome_neq() {
172        let outcome = Outcome::success();
173        let ctx = HashMap::new();
174        let cond = parse_condition("outcome!=failure");
175        assert!(evaluate_condition(&cond, &outcome, &ctx));
176    }
177
178    #[test]
179    fn test_evaluate_preferred_label() {
180        let outcome = Outcome::success_with_label("approve");
181        let ctx = HashMap::new();
182        let cond = parse_condition("preferred_label=approve");
183        assert!(evaluate_condition(&cond, &outcome, &ctx));
184    }
185
186    #[test]
187    fn test_evaluate_context_value() {
188        let outcome = Outcome::success();
189        let mut ctx = HashMap::new();
190        ctx.insert("test_passed".into(), serde_json::json!("true"));
191        let cond = parse_condition("test_passed=true");
192        assert!(evaluate_condition(&cond, &outcome, &ctx));
193    }
194
195    #[test]
196    fn test_evaluate_context_prefix() {
197        let outcome = Outcome::success();
198        let mut ctx = HashMap::new();
199        ctx.insert("flag".into(), serde_json::json!("yes"));
200        let cond = parse_condition("context.flag=yes");
201        assert!(evaluate_condition(&cond, &outcome, &ctx));
202    }
203
204    #[test]
205    fn test_evaluate_and_all_true() {
206        let outcome = Outcome::success();
207        let mut ctx = HashMap::new();
208        ctx.insert("ready".into(), serde_json::json!("true"));
209        let cond = parse_condition("outcome=success && ready=true");
210        assert!(evaluate_condition(&cond, &outcome, &ctx));
211    }
212
213    #[test]
214    fn test_evaluate_and_one_false() {
215        let outcome = Outcome::success();
216        let ctx = HashMap::new();
217        let cond = parse_condition("outcome=success && missing=true");
218        assert!(!evaluate_condition(&cond, &outcome, &ctx));
219    }
220}