rust_actions/
expr.rs

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}