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"] => 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] => 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"] => 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] => 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] => 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] => 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}