1use crate::outputs::StepOutputs;
2use crate::{Error, Result};
3use regex::Regex;
4use serde_json::Value;
5use std::collections::HashMap;
6
7pub struct ExprContext {
8 pub env: HashMap<String, String>,
9 pub steps: HashMap<String, StepOutputs>,
10 pub background: HashMap<String, StepOutputs>,
11 pub containers: HashMap<String, ContainerInfo>,
12 pub outputs: Option<StepOutputs>,
13}
14
15#[derive(Debug, Clone)]
16pub struct ContainerInfo {
17 pub url: String,
18 pub host: String,
19 pub port: u16,
20}
21
22impl ExprContext {
23 pub fn new() -> Self {
24 Self {
25 env: HashMap::new(),
26 steps: HashMap::new(),
27 background: HashMap::new(),
28 containers: HashMap::new(),
29 outputs: None,
30 }
31 }
32
33 pub fn with_outputs(&self, outputs: StepOutputs) -> Self {
34 Self {
35 env: self.env.clone(),
36 steps: self.steps.clone(),
37 background: self.background.clone(),
38 containers: self.containers.clone(),
39 outputs: Some(outputs),
40 }
41 }
42}
43
44impl Default for ExprContext {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50pub fn evaluate(input: &str, ctx: &ExprContext) -> Result<String> {
51 let re = Regex::new(r"\$\{\{\s*(.+?)\s*\}\}").unwrap();
52
53 let mut result = input.to_string();
54 for cap in re.captures_iter(input) {
55 let full_match = &cap[0];
56 let expr = &cap[1];
57 let value = evaluate_expr(expr, ctx)?;
58 result = result.replace(full_match, &value);
59 }
60
61 Ok(result)
62}
63
64pub fn evaluate_value(value: &Value, ctx: &ExprContext) -> Result<Value> {
65 match value {
66 Value::String(s) => {
67 let evaluated = evaluate(s, ctx)?;
68 Ok(Value::String(evaluated))
69 }
70 Value::Object(map) => {
71 let mut new_map = serde_json::Map::new();
72 for (k, v) in map {
73 new_map.insert(k.clone(), evaluate_value(v, ctx)?);
74 }
75 Ok(Value::Object(new_map))
76 }
77 Value::Array(arr) => {
78 let new_arr: Result<Vec<_>> = arr.iter().map(|v| evaluate_value(v, ctx)).collect();
79 Ok(Value::Array(new_arr?))
80 }
81 _ => Ok(value.clone()),
82 }
83}
84
85pub fn evaluate_assertion(assertion: &str, ctx: &ExprContext) -> Result<bool> {
86 let re = Regex::new(r"\$\{\{\s*(.+?)\s*\}\}").unwrap();
87
88 if let Some(cap) = re.captures(assertion) {
89 let expr = &cap[1];
90 evaluate_bool_expr(expr, ctx)
91 } else {
92 Err(Error::Expression(format!(
93 "Invalid assertion format: {}",
94 assertion
95 )))
96 }
97}
98
99fn evaluate_bool_expr(expr: &str, ctx: &ExprContext) -> Result<bool> {
100 let ops = [" contains ", "==", "!=", ">=", "<=", ">", "<"];
101
102 for op in ops {
103 if let Some(pos) = find_operator(expr, op) {
104 let left = expr[..pos].trim();
105 let right = expr[pos + op.len()..].trim();
106
107 let left_val = evaluate_operand(left, ctx)?;
108 let right_val = evaluate_operand(right, ctx)?;
109
110 return Ok(compare_values(&left_val, &right_val, op.trim()));
111 }
112 }
113
114 Err(Error::Expression(format!(
115 "No comparison operator found in expression: {}",
116 expr
117 )))
118}
119
120fn find_operator(expr: &str, op: &str) -> Option<usize> {
121 let mut depth = 0;
122 let mut in_string = false;
123 let mut string_char = ' ';
124 let chars: Vec<char> = expr.chars().collect();
125
126 for i in 0..chars.len() {
127 let c = chars[i];
128
129 if in_string {
130 if c == string_char && (i == 0 || chars[i - 1] != '\\') {
131 in_string = false;
132 }
133 continue;
134 }
135
136 if c == '"' || c == '\'' {
137 in_string = true;
138 string_char = c;
139 continue;
140 }
141
142 if c == '{' || c == '[' {
143 depth += 1;
144 } else if c == '}' || c == ']' {
145 depth -= 1;
146 }
147
148 if depth == 0 && i + op.len() <= expr.len() {
149 if &expr[i..i + op.len()] == op {
150 return Some(i);
151 }
152 }
153 }
154 None
155}
156
157fn evaluate_operand(operand: &str, ctx: &ExprContext) -> Result<Value> {
158 let operand = operand.trim();
159
160 if operand.starts_with('{') || operand.starts_with('[') {
161 serde_json::from_str(operand)
162 .map_err(|e| Error::Expression(format!("Invalid JSON: {}", e)))
163 } else if operand.starts_with('"') {
164 Ok(Value::String(operand[1..operand.len() - 1].to_string()))
165 } else if operand.starts_with('\'') {
166 Ok(Value::String(operand[1..operand.len() - 1].to_string()))
167 } else if operand == "true" {
168 Ok(Value::Bool(true))
169 } else if operand == "false" {
170 Ok(Value::Bool(false))
171 } else if operand == "null" {
172 Ok(Value::Null)
173 } else if let Ok(num) = operand.parse::<i64>() {
174 Ok(Value::Number(num.into()))
175 } else if let Ok(num) = operand.parse::<f64>() {
176 Ok(serde_json::Number::from_f64(num)
177 .map(Value::Number)
178 .unwrap_or(Value::Null))
179 } else {
180 evaluate_expr_value(operand, ctx)
181 }
182}
183
184fn evaluate_expr_value(expr: &str, ctx: &ExprContext) -> Result<Value> {
185 let parts: Vec<&str> = expr.split('.').collect();
186
187 match parts.as_slice() {
188 ["outputs"] => ctx
189 .outputs
190 .as_ref()
191 .map(|o| o.to_value())
192 .ok_or_else(|| Error::Expression("No outputs context available".to_string())),
193
194 ["outputs", field] => ctx
195 .outputs
196 .as_ref()
197 .and_then(|o| o.get(field).cloned())
198 .ok_or_else(|| Error::Expression(format!("Output not found: {}", field))),
199
200 ["outputs", rest @ ..] => {
201 let field = rest[0];
202 let remaining: Vec<&str> = rest[1..].to_vec();
203 let base = ctx
204 .outputs
205 .as_ref()
206 .and_then(|o| o.get(field).cloned())
207 .ok_or_else(|| Error::Expression(format!("Output not found: {}", field)))?;
208 navigate_value(&base, &remaining)
209 }
210
211 ["env", var_name] => ctx
212 .env
213 .get(*var_name)
214 .map(|s| Value::String(s.clone()))
215 .ok_or_else(|| Error::EnvVar((*var_name).to_string())),
216
217 ["steps", step_id, "outputs"] => ctx
218 .steps
219 .get(*step_id)
220 .map(|o| o.to_value())
221 .ok_or_else(|| Error::Expression(format!("Step not found: {}", step_id))),
222
223 ["steps", step_id, "outputs", field] => ctx
224 .steps
225 .get(*step_id)
226 .and_then(|o| o.get(field).cloned())
227 .ok_or_else(|| {
228 Error::Expression(format!("Step output not found: {}.{}", step_id, field))
229 }),
230
231 ["containers", name, prop] => {
232 let container = ctx
233 .containers
234 .get(*name)
235 .ok_or_else(|| Error::Expression(format!("Container not found: {}", name)))?;
236 match *prop {
237 "url" => Ok(Value::String(container.url.clone())),
238 "host" => Ok(Value::String(container.host.clone())),
239 "port" => Ok(Value::Number(container.port.into())),
240 _ => Err(Error::Expression(format!(
241 "Unknown container property: {}",
242 prop
243 ))),
244 }
245 }
246
247 _ => Err(Error::Expression(format!("Unknown expression: {}", expr))),
248 }
249}
250
251fn navigate_value(value: &Value, path: &[&str]) -> Result<Value> {
252 if path.is_empty() {
253 return Ok(value.clone());
254 }
255
256 match value {
257 Value::Object(map) => {
258 let field = path[0];
259 let next = map
260 .get(field)
261 .ok_or_else(|| Error::Expression(format!("Field not found: {}", field)))?;
262 navigate_value(next, &path[1..])
263 }
264 Value::Array(arr) => {
265 let index: usize = path[0]
266 .parse()
267 .map_err(|_| Error::Expression(format!("Invalid array index: {}", path[0])))?;
268 let next = arr
269 .get(index)
270 .ok_or_else(|| Error::Expression(format!("Array index out of bounds: {}", index)))?;
271 navigate_value(next, &path[1..])
272 }
273 _ => Err(Error::Expression(format!(
274 "Cannot navigate into non-object/array value"
275 ))),
276 }
277}
278
279fn compare_values(left: &Value, right: &Value, op: &str) -> bool {
280 match op {
281 "==" => left == right,
282 "!=" => left != right,
283 "contains" => value_contains(left, right),
284 ">" => compare_numeric(left, right, |a, b| a > b),
285 "<" => compare_numeric(left, right, |a, b| a < b),
286 ">=" => compare_numeric(left, right, |a, b| a >= b),
287 "<=" => compare_numeric(left, right, |a, b| a <= b),
288 _ => false,
289 }
290}
291
292fn compare_numeric<F>(left: &Value, right: &Value, cmp: F) -> bool
293where
294 F: Fn(f64, f64) -> bool,
295{
296 match (value_to_f64(left), value_to_f64(right)) {
297 (Some(l), Some(r)) => cmp(l, r),
298 _ => false,
299 }
300}
301
302fn value_to_f64(value: &Value) -> Option<f64> {
303 match value {
304 Value::Number(n) => n.as_f64(),
305 Value::String(s) => s.parse().ok(),
306 _ => None,
307 }
308}
309
310fn value_contains(haystack: &Value, needle: &Value) -> bool {
311 match (haystack, needle) {
312 (Value::Object(h), Value::Object(n)) => n.iter().all(|(k, v)| {
313 h.get(k).map_or(false, |hv| {
314 if v.is_object() || v.is_array() {
315 value_contains(hv, v)
316 } else {
317 hv == v
318 }
319 })
320 }),
321
322 (Value::Array(h), Value::Array(n)) => n.iter().all(|needle_item| {
323 h.iter().any(|hay_item| {
324 if needle_item.is_object() {
325 value_contains(hay_item, needle_item)
326 } else {
327 hay_item == needle_item
328 }
329 })
330 }),
331
332 (Value::Array(h), needle) => h.iter().any(|item| {
333 if needle.is_object() {
334 value_contains(item, needle)
335 } else {
336 item == needle
337 }
338 }),
339
340 (Value::String(h), Value::String(n)) => h.contains(n.as_str()),
341
342 _ => false,
343 }
344}
345
346fn evaluate_expr(expr: &str, ctx: &ExprContext) -> Result<String> {
347 let parts: Vec<&str> = expr.split('.').collect();
348
349 match parts.as_slice() {
350 ["env", var_name] => ctx
351 .env
352 .get(*var_name)
353 .cloned()
354 .ok_or_else(|| Error::EnvVar((*var_name).to_string())),
355
356 ["steps", step_id, "outputs", field] => ctx
357 .steps
358 .get(*step_id)
359 .and_then(|outputs| outputs.get_string(field))
360 .ok_or_else(|| {
361 Error::Expression(format!("Step output not found: {}.{}", step_id, field))
362 }),
363
364 ["background", step_id, "outputs", field] => ctx
365 .background
366 .get(*step_id)
367 .and_then(|outputs| outputs.get_string(field))
368 .ok_or_else(|| {
369 Error::Expression(format!(
370 "Background output not found: {}.{}",
371 step_id, field
372 ))
373 }),
374
375 ["containers", name, "url"] => ctx
376 .containers
377 .get(*name)
378 .map(|c| c.url.clone())
379 .ok_or_else(|| Error::Expression(format!("Container not found: {}", name))),
380
381 ["containers", name, "host"] => ctx
382 .containers
383 .get(*name)
384 .map(|c| c.host.clone())
385 .ok_or_else(|| Error::Expression(format!("Container not found: {}", name))),
386
387 ["containers", name, "port"] => ctx
388 .containers
389 .get(*name)
390 .map(|c| c.port.to_string())
391 .ok_or_else(|| Error::Expression(format!("Container not found: {}", name))),
392
393 _ => Err(Error::Expression(format!("Unknown expression: {}", expr))),
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_evaluate_env() {
403 let mut ctx = ExprContext::new();
404 ctx.env.insert("DB_URL".to_string(), "postgres://localhost".to_string());
405
406 let result = evaluate("${{ env.DB_URL }}", &ctx).unwrap();
407 assert_eq!(result, "postgres://localhost");
408 }
409
410 #[test]
411 fn test_evaluate_step_output() {
412 let mut ctx = ExprContext::new();
413 let mut outputs = StepOutputs::new();
414 outputs.insert("id", "user-123");
415 ctx.steps.insert("user".to_string(), outputs);
416
417 let result = evaluate("User ID: ${{ steps.user.outputs.id }}", &ctx).unwrap();
418 assert_eq!(result, "User ID: user-123");
419 }
420
421 #[test]
422 fn test_evaluate_container() {
423 let mut ctx = ExprContext::new();
424 ctx.containers.insert(
425 "postgres".to_string(),
426 ContainerInfo {
427 url: "postgres://localhost:5432".to_string(),
428 host: "localhost".to_string(),
429 port: 5432,
430 },
431 );
432
433 let result = evaluate("${{ containers.postgres.url }}", &ctx).unwrap();
434 assert_eq!(result, "postgres://localhost:5432");
435 }
436}