capsule_runtime/plan/
condition.rs1use 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}