Skip to main content

capsule_runtime/plan/
condition.rs

1use std::collections::HashMap;
2
3use super::{StepResult, StepStatus};
4
5pub(crate) fn evaluate(
6    condition: Option<&str>,
7    results: &HashMap<String, StepResult>,
8) -> Result<bool, String> {
9    let Some(condition) = condition.map(str::trim).filter(|value| !value.is_empty()) else {
10        return Ok(true);
11    };
12
13    eval_or(condition, results)
14}
15
16fn eval_or(expr: &str, results: &HashMap<String, StepResult>) -> Result<bool, String> {
17    let parts = split_top_level(expr, "||");
18    if parts.len() > 1 {
19        for part in parts {
20            if eval_and(part, results)? {
21                return Ok(true);
22            }
23        }
24        return Ok(false);
25    }
26
27    eval_and(expr, results)
28}
29
30fn eval_and(expr: &str, results: &HashMap<String, StepResult>) -> Result<bool, String> {
31    let parts = split_top_level(expr, "&&");
32    if parts.len() > 1 {
33        for part in parts {
34            if !eval_comparison(part, results)? {
35                return Ok(false);
36            }
37        }
38        return Ok(true);
39    }
40
41    eval_comparison(expr, results)
42}
43
44fn eval_comparison(expr: &str, results: &HashMap<String, StepResult>) -> Result<bool, String> {
45    for op in ["==", "!=", ">", "<"] {
46        if let Some((left, right)) = split_once_operator(expr, op) {
47            let left = value_of(left.trim(), results)?;
48            let right = value_of(right.trim(), results)?;
49            return match op {
50                "==" => Ok(left == right),
51                "!=" => Ok(left != right),
52                ">" => Ok(left > right),
53                "<" => Ok(left < right),
54                _ => unreachable!(),
55            };
56        }
57    }
58
59    Err(format!("unsupported condition expression '{expr}'"))
60}
61
62fn split_once_operator<'a>(expr: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
63    let mut in_quote = None;
64    let bytes = expr.as_bytes();
65    let op_bytes = op.as_bytes();
66    let mut i = 0;
67
68    while i + op_bytes.len() <= bytes.len() {
69        let ch = bytes[i] as char;
70        match (ch, in_quote) {
71            ('"' | '\'', None) => in_quote = Some(ch),
72            (quote, Some(open)) if quote == open => in_quote = None,
73            _ => {}
74        }
75
76        if in_quote.is_none() && &bytes[i..i + op_bytes.len()] == op_bytes {
77            return Some((&expr[..i], &expr[i + op_bytes.len()..]));
78        }
79        i += 1;
80    }
81
82    None
83}
84
85fn split_top_level<'a>(expr: &'a str, delimiter: &str) -> Vec<&'a str> {
86    let mut parts = Vec::new();
87    let mut in_quote = None;
88    let bytes = expr.as_bytes();
89    let delimiter_bytes = delimiter.as_bytes();
90    let mut start = 0;
91    let mut i = 0;
92
93    while i + delimiter_bytes.len() <= bytes.len() {
94        let ch = bytes[i] as char;
95        match (ch, in_quote) {
96            ('"' | '\'', None) => in_quote = Some(ch),
97            (quote, Some(open)) if quote == open => in_quote = None,
98            _ => {}
99        }
100
101        if in_quote.is_none() && &bytes[i..i + delimiter_bytes.len()] == delimiter_bytes {
102            parts.push(expr[start..i].trim());
103            i += delimiter_bytes.len();
104            start = i;
105            continue;
106        }
107        i += 1;
108    }
109
110    parts.push(expr[start..].trim());
111    parts
112}
113
114fn value_of(token: &str, results: &HashMap<String, StepResult>) -> Result<String, String> {
115    if let Some(value) = quoted_literal(token) {
116        return Ok(value.to_string());
117    }
118
119    resolve_reference(token, results)
120}
121
122fn quoted_literal(token: &str) -> Option<&str> {
123    let bytes = token.as_bytes();
124    if bytes.len() < 2 {
125        return None;
126    }
127    let first = bytes[0] as char;
128    let last = bytes[bytes.len() - 1] as char;
129    ((first == '"' || first == '\'') && first == last).then_some(&token[1..token.len() - 1])
130}
131
132pub(crate) fn resolve_reference(
133    reference: &str,
134    results: &HashMap<String, StepResult>,
135) -> Result<String, String> {
136    let Some(reference) = reference.strip_prefix('$') else {
137        return Err(format!("unsupported condition operand '{reference}'"));
138    };
139    let Some((step_id, field)) = reference.rsplit_once('.') else {
140        return Err(format!("invalid reference '${reference}'"));
141    };
142    let Some(result) = results.get(step_id) else {
143        return Err(format!("reference '${reference}' is not ready"));
144    };
145
146    match field {
147        "output" => result
148            .output
149            .clone()
150            .ok_or_else(|| format!("reference '${reference}' has no output")),
151        "status" => Ok(result.status.as_str().to_string()),
152        _ => Err(format!("unsupported reference field '{field}'")),
153    }
154}
155
156impl StepStatus {
157    pub(crate) fn as_str(&self) -> &'static str {
158        match self {
159            StepStatus::Success => "success",
160            StepStatus::Failed => "failed",
161            StepStatus::Skipped => "skipped",
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn result(status: StepStatus, output: Option<&str>) -> StepResult {
171        StepResult {
172            step_id: "a".to_string(),
173            status,
174            output: output.map(str::to_string),
175            error: None,
176        }
177    }
178
179    #[test]
180    fn evaluates_status_and_output_checks() {
181        let mut results = HashMap::new();
182        results.insert("a".to_string(), result(StepStatus::Success, Some("42")));
183
184        assert!(evaluate(Some("$a.status == 'success'"), &results).unwrap());
185        assert!(evaluate(
186            Some("$a.output == \"42\" && $a.status != 'failed'"),
187            &results
188        )
189        .unwrap());
190        assert!(!evaluate(
191            Some("$a.output == 'nope' || $a.status == 'failed'"),
192            &results
193        )
194        .unwrap());
195    }
196}