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    pub needs: HashMap<String, JobOutputs>,
14    pub matrix: HashMap<String, Value>,
15    pub jobs: HashMap<String, JobOutputs>,
16}
17
18#[derive(Debug, Clone, Default)]
19pub struct JobOutputs {
20    pub outputs: HashMap<String, Value>,
21}
22
23impl JobOutputs {
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    pub fn get(&self, key: &str) -> Option<&Value> {
29        self.outputs.get(key)
30    }
31
32    pub fn get_string(&self, key: &str) -> Option<String> {
33        self.outputs.get(key).and_then(|v| match v {
34            Value::String(s) => Some(s.clone()),
35            Value::Number(n) => Some(n.to_string()),
36            Value::Bool(b) => Some(b.to_string()),
37            _ => Some(v.to_string()),
38        })
39    }
40
41    pub fn insert(&mut self, key: impl Into<String>, value: Value) {
42        self.outputs.insert(key.into(), value);
43    }
44
45    pub fn to_value(&self) -> Value {
46        Value::Object(
47            self.outputs
48                .iter()
49                .map(|(k, v)| (k.clone(), v.clone()))
50                .collect(),
51        )
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct ContainerInfo {
57    pub url: String,
58    pub host: String,
59    pub port: u16,
60}
61
62impl ExprContext {
63    pub fn new() -> Self {
64        Self {
65            env: HashMap::new(),
66            steps: HashMap::new(),
67            background: HashMap::new(),
68            containers: HashMap::new(),
69            outputs: None,
70            needs: HashMap::new(),
71            matrix: HashMap::new(),
72            jobs: HashMap::new(),
73        }
74    }
75
76    pub fn with_outputs(&self, outputs: StepOutputs) -> Self {
77        Self {
78            env: self.env.clone(),
79            steps: self.steps.clone(),
80            background: self.background.clone(),
81            containers: self.containers.clone(),
82            outputs: Some(outputs),
83            needs: self.needs.clone(),
84            matrix: self.matrix.clone(),
85            jobs: self.jobs.clone(),
86        }
87    }
88
89    pub fn with_matrix(&self, matrix: HashMap<String, Value>) -> Self {
90        Self {
91            env: self.env.clone(),
92            steps: self.steps.clone(),
93            background: self.background.clone(),
94            containers: self.containers.clone(),
95            outputs: self.outputs.clone(),
96            needs: self.needs.clone(),
97            matrix,
98            jobs: self.jobs.clone(),
99        }
100    }
101}
102
103impl Default for ExprContext {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109pub fn evaluate(input: &str, ctx: &ExprContext) -> Result<String> {
110    let re = Regex::new(r"\$\{\{\s*(.+?)\s*\}\}").unwrap();
111
112    let mut result = input.to_string();
113    for cap in re.captures_iter(input) {
114        let full_match = &cap[0];
115        let expr = &cap[1];
116        let value = evaluate_expr(expr, ctx)?;
117        result = result.replace(full_match, &value);
118    }
119
120    Ok(result)
121}
122
123pub fn evaluate_value(value: &Value, ctx: &ExprContext) -> Result<Value> {
124    match value {
125        Value::String(s) => {
126            let evaluated = evaluate(s, ctx)?;
127            Ok(Value::String(evaluated))
128        }
129        Value::Object(map) => {
130            let mut new_map = serde_json::Map::new();
131            for (k, v) in map {
132                new_map.insert(k.clone(), evaluate_value(v, ctx)?);
133            }
134            Ok(Value::Object(new_map))
135        }
136        Value::Array(arr) => {
137            let new_arr: Result<Vec<_>> = arr.iter().map(|v| evaluate_value(v, ctx)).collect();
138            Ok(Value::Array(new_arr?))
139        }
140        _ => Ok(value.clone()),
141    }
142}
143
144pub fn evaluate_assertion(assertion: &str, ctx: &ExprContext) -> Result<bool> {
145    let re = Regex::new(r"\$\{\{\s*(.+?)\s*\}\}").unwrap();
146
147    if let Some(cap) = re.captures(assertion) {
148        let expr = &cap[1];
149        evaluate_bool_expr(expr, ctx)
150    } else {
151        Err(Error::Expression(format!(
152            "Invalid assertion format: {}",
153            assertion
154        )))
155    }
156}
157
158fn evaluate_bool_expr(expr: &str, ctx: &ExprContext) -> Result<bool> {
159    let ops = [" contains ", "==", "!=", ">=", "<=", ">", "<"];
160
161    for op in ops {
162        if let Some(pos) = find_operator(expr, op) {
163            let left = expr[..pos].trim();
164            let right = expr[pos + op.len()..].trim();
165
166            let left_val = evaluate_operand(left, ctx)?;
167            let right_val = evaluate_operand(right, ctx)?;
168
169            return Ok(compare_values(&left_val, &right_val, op.trim()));
170        }
171    }
172
173    Err(Error::Expression(format!(
174        "No comparison operator found in expression: {}",
175        expr
176    )))
177}
178
179fn find_operator(expr: &str, op: &str) -> Option<usize> {
180    let mut depth = 0;
181    let mut in_string = false;
182    let mut string_char = ' ';
183    let chars: Vec<char> = expr.chars().collect();
184
185    for i in 0..chars.len() {
186        let c = chars[i];
187
188        if in_string {
189            if c == string_char && (i == 0 || chars[i - 1] != '\\') {
190                in_string = false;
191            }
192            continue;
193        }
194
195        if c == '"' || c == '\'' {
196            in_string = true;
197            string_char = c;
198            continue;
199        }
200
201        if c == '{' || c == '[' {
202            depth += 1;
203        } else if c == '}' || c == ']' {
204            depth -= 1;
205        }
206
207        if depth == 0 && i + op.len() <= expr.len() {
208            if &expr[i..i + op.len()] == op {
209                return Some(i);
210            }
211        }
212    }
213    None
214}
215
216fn evaluate_operand(operand: &str, ctx: &ExprContext) -> Result<Value> {
217    let operand = operand.trim();
218
219    if operand.starts_with('{') || operand.starts_with('[') {
220        serde_json::from_str(operand)
221            .map_err(|e| Error::Expression(format!("Invalid JSON: {}", e)))
222    } else if operand.starts_with('"') {
223        Ok(Value::String(operand[1..operand.len() - 1].to_string()))
224    } else if operand.starts_with('\'') {
225        Ok(Value::String(operand[1..operand.len() - 1].to_string()))
226    } else if operand == "true" {
227        Ok(Value::Bool(true))
228    } else if operand == "false" {
229        Ok(Value::Bool(false))
230    } else if operand == "null" {
231        Ok(Value::Null)
232    } else if let Ok(num) = operand.parse::<i64>() {
233        Ok(Value::Number(num.into()))
234    } else if let Ok(num) = operand.parse::<f64>() {
235        Ok(serde_json::Number::from_f64(num)
236            .map(Value::Number)
237            .unwrap_or(Value::Null))
238    } else {
239        evaluate_expr_value(operand, ctx)
240    }
241}
242
243fn evaluate_expr_value(expr: &str, ctx: &ExprContext) -> Result<Value> {
244    let parts: Vec<&str> = expr.split('.').collect();
245
246    match parts.as_slice() {
247        ["outputs"] => ctx
248            .outputs
249            .as_ref()
250            .map(|o| o.to_value())
251            .ok_or_else(|| Error::Expression("No outputs context available".to_string())),
252
253        ["outputs", field] => ctx
254            .outputs
255            .as_ref()
256            .and_then(|o| o.get(field).cloned())
257            .ok_or_else(|| Error::Expression(format!("Output not found: {}", field))),
258
259        ["outputs", rest @ ..] => {
260            let field = rest[0];
261            let remaining: Vec<&str> = rest[1..].to_vec();
262            let base = ctx
263                .outputs
264                .as_ref()
265                .and_then(|o| o.get(field).cloned())
266                .ok_or_else(|| Error::Expression(format!("Output not found: {}", field)))?;
267            navigate_value(&base, &remaining)
268        }
269
270        ["env", var_name] => ctx
271            .env
272            .get(*var_name)
273            .map(|s| Value::String(s.clone()))
274            .ok_or_else(|| Error::EnvVar((*var_name).to_string())),
275
276        ["steps", step_id, "outputs"] => ctx
277            .steps
278            .get(*step_id)
279            .map(|o| o.to_value())
280            .ok_or_else(|| Error::Expression(format!("Step not found: {}", step_id))),
281
282        ["steps", step_id, "outputs", field] => ctx
283            .steps
284            .get(*step_id)
285            .and_then(|o| o.get(field).cloned())
286            .ok_or_else(|| {
287                Error::Expression(format!("Step output not found: {}.{}", step_id, field))
288            }),
289
290        ["containers", name, prop] => {
291            let container = ctx
292                .containers
293                .get(*name)
294                .ok_or_else(|| Error::Expression(format!("Container not found: {}", name)))?;
295            match *prop {
296                "url" => Ok(Value::String(container.url.clone())),
297                "host" => Ok(Value::String(container.host.clone())),
298                "port" => Ok(Value::Number(container.port.into())),
299                _ => Err(Error::Expression(format!(
300                    "Unknown container property: {}",
301                    prop
302                ))),
303            }
304        }
305
306        // needs.job_name.outputs.field
307        ["needs", job_name, "outputs"] => ctx
308            .needs
309            .get(*job_name)
310            .map(|o| o.to_value())
311            .ok_or_else(|| Error::Expression(format!("Job not found in needs: {}", job_name))),
312
313        ["needs", job_name, "outputs", field] => ctx
314            .needs
315            .get(*job_name)
316            .and_then(|o| o.get(field).cloned())
317            .ok_or_else(|| {
318                Error::Expression(format!("Job output not found: {}.{}", job_name, field))
319            }),
320
321        ["needs", job_name, "outputs", field, rest @ ..] => {
322            let base = ctx
323                .needs
324                .get(*job_name)
325                .and_then(|o| o.get(field).cloned())
326                .ok_or_else(|| {
327                    Error::Expression(format!("Job output not found: {}.{}", job_name, field))
328                })?;
329            navigate_value(&base, &rest.to_vec())
330        }
331
332        // matrix.key
333        ["matrix", key] => ctx
334            .matrix
335            .get(*key)
336            .cloned()
337            .ok_or_else(|| Error::Expression(format!("Matrix key not found: {}", key))),
338
339        // jobs.job_name.outputs.field (for workflow-level references)
340        ["jobs", job_name, "outputs"] => ctx
341            .jobs
342            .get(*job_name)
343            .map(|o| o.to_value())
344            .ok_or_else(|| Error::Expression(format!("Job not found: {}", job_name))),
345
346        ["jobs", job_name, "outputs", field] => ctx
347            .jobs
348            .get(*job_name)
349            .and_then(|o| o.get(field).cloned())
350            .ok_or_else(|| {
351                Error::Expression(format!("Job output not found: {}.{}", job_name, field))
352            }),
353
354        _ => Err(Error::Expression(format!("Unknown expression: {}", expr))),
355    }
356}
357
358fn navigate_value(value: &Value, path: &[&str]) -> Result<Value> {
359    if path.is_empty() {
360        return Ok(value.clone());
361    }
362
363    match value {
364        Value::Object(map) => {
365            let field = path[0];
366            let next = map
367                .get(field)
368                .ok_or_else(|| Error::Expression(format!("Field not found: {}", field)))?;
369            navigate_value(next, &path[1..])
370        }
371        Value::Array(arr) => {
372            let index: usize = path[0]
373                .parse()
374                .map_err(|_| Error::Expression(format!("Invalid array index: {}", path[0])))?;
375            let next = arr
376                .get(index)
377                .ok_or_else(|| Error::Expression(format!("Array index out of bounds: {}", index)))?;
378            navigate_value(next, &path[1..])
379        }
380        _ => Err(Error::Expression(format!(
381            "Cannot navigate into non-object/array value"
382        ))),
383    }
384}
385
386fn compare_values(left: &Value, right: &Value, op: &str) -> bool {
387    match op {
388        "==" => left == right,
389        "!=" => left != right,
390        "contains" => value_contains(left, right),
391        ">" => compare_numeric(left, right, |a, b| a > b),
392        "<" => compare_numeric(left, right, |a, b| a < b),
393        ">=" => compare_numeric(left, right, |a, b| a >= b),
394        "<=" => compare_numeric(left, right, |a, b| a <= b),
395        _ => false,
396    }
397}
398
399fn compare_numeric<F>(left: &Value, right: &Value, cmp: F) -> bool
400where
401    F: Fn(f64, f64) -> bool,
402{
403    match (value_to_f64(left), value_to_f64(right)) {
404        (Some(l), Some(r)) => cmp(l, r),
405        _ => false,
406    }
407}
408
409fn value_to_f64(value: &Value) -> Option<f64> {
410    match value {
411        Value::Number(n) => n.as_f64(),
412        Value::String(s) => s.parse().ok(),
413        _ => None,
414    }
415}
416
417fn value_contains(haystack: &Value, needle: &Value) -> bool {
418    match (haystack, needle) {
419        (Value::Object(h), Value::Object(n)) => n.iter().all(|(k, v)| {
420            h.get(k).map_or(false, |hv| {
421                if v.is_object() || v.is_array() {
422                    value_contains(hv, v)
423                } else {
424                    hv == v
425                }
426            })
427        }),
428
429        (Value::Array(h), Value::Array(n)) => n.iter().all(|needle_item| {
430            h.iter().any(|hay_item| {
431                if needle_item.is_object() {
432                    value_contains(hay_item, needle_item)
433                } else {
434                    hay_item == needle_item
435                }
436            })
437        }),
438
439        (Value::Array(h), needle) => h.iter().any(|item| {
440            if needle.is_object() {
441                value_contains(item, needle)
442            } else {
443                item == needle
444            }
445        }),
446
447        (Value::String(h), Value::String(n)) => h.contains(n.as_str()),
448
449        _ => false,
450    }
451}
452
453fn evaluate_expr(expr: &str, ctx: &ExprContext) -> Result<String> {
454    let parts: Vec<&str> = expr.split('.').collect();
455
456    match parts.as_slice() {
457        ["env", var_name] => ctx
458            .env
459            .get(*var_name)
460            .cloned()
461            .ok_or_else(|| Error::EnvVar((*var_name).to_string())),
462
463        ["steps", step_id, "outputs", field] => ctx
464            .steps
465            .get(*step_id)
466            .and_then(|outputs| outputs.get_string(field))
467            .ok_or_else(|| {
468                Error::Expression(format!("Step output not found: {}.{}", step_id, field))
469            }),
470
471        ["background", step_id, "outputs", field] => ctx
472            .background
473            .get(*step_id)
474            .and_then(|outputs| outputs.get_string(field))
475            .ok_or_else(|| {
476                Error::Expression(format!(
477                    "Background output not found: {}.{}",
478                    step_id, field
479                ))
480            }),
481
482        ["containers", name, "url"] => ctx
483            .containers
484            .get(*name)
485            .map(|c| c.url.clone())
486            .ok_or_else(|| Error::Expression(format!("Container not found: {}", name))),
487
488        ["containers", name, "host"] => ctx
489            .containers
490            .get(*name)
491            .map(|c| c.host.clone())
492            .ok_or_else(|| Error::Expression(format!("Container not found: {}", name))),
493
494        ["containers", name, "port"] => ctx
495            .containers
496            .get(*name)
497            .map(|c| c.port.to_string())
498            .ok_or_else(|| Error::Expression(format!("Container not found: {}", name))),
499
500        // needs.job_name.outputs.field
501        ["needs", job_name, "outputs", field] => ctx
502            .needs
503            .get(*job_name)
504            .and_then(|outputs| outputs.get_string(field))
505            .ok_or_else(|| {
506                Error::Expression(format!("Job output not found: {}.{}", job_name, field))
507            }),
508
509        // matrix.key
510        ["matrix", key] => ctx
511            .matrix
512            .get(*key)
513            .map(|v| value_to_string(v))
514            .ok_or_else(|| Error::Expression(format!("Matrix key not found: {}", key))),
515
516        // jobs.job_name.outputs.field
517        ["jobs", job_name, "outputs", field] => ctx
518            .jobs
519            .get(*job_name)
520            .and_then(|outputs| outputs.get_string(field))
521            .ok_or_else(|| {
522                Error::Expression(format!("Job output not found: {}.{}", job_name, field))
523            }),
524
525        _ => Err(Error::Expression(format!("Unknown expression: {}", expr))),
526    }
527}
528
529fn value_to_string(value: &Value) -> String {
530    match value {
531        Value::String(s) => s.clone(),
532        Value::Number(n) => n.to_string(),
533        Value::Bool(b) => b.to_string(),
534        Value::Null => "null".to_string(),
535        _ => value.to_string(),
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_evaluate_env() {
545        let mut ctx = ExprContext::new();
546        ctx.env.insert("DB_URL".to_string(), "postgres://localhost".to_string());
547
548        let result = evaluate("${{ env.DB_URL }}", &ctx).unwrap();
549        assert_eq!(result, "postgres://localhost");
550    }
551
552    #[test]
553    fn test_evaluate_step_output() {
554        let mut ctx = ExprContext::new();
555        let mut outputs = StepOutputs::new();
556        outputs.insert("id", "user-123");
557        ctx.steps.insert("user".to_string(), outputs);
558
559        let result = evaluate("User ID: ${{ steps.user.outputs.id }}", &ctx).unwrap();
560        assert_eq!(result, "User ID: user-123");
561    }
562
563    #[test]
564    fn test_evaluate_container() {
565        let mut ctx = ExprContext::new();
566        ctx.containers.insert(
567            "postgres".to_string(),
568            ContainerInfo {
569                url: "postgres://localhost:5432".to_string(),
570                host: "localhost".to_string(),
571                port: 5432,
572            },
573        );
574
575        let result = evaluate("${{ containers.postgres.url }}", &ctx).unwrap();
576        assert_eq!(result, "postgres://localhost:5432");
577    }
578}