1use serde_yaml::Value;
11use std::collections::{HashMap, HashSet};
12
13use serde_json;
15
16#[derive(Debug, Clone, PartialEq)]
22pub enum ExprValue {
23 String(String),
24 Number(f64),
25 Bool(bool),
26 Null,
27 Object(HashMap<String, ExprValue>),
29}
30
31impl ExprValue {
32 pub fn is_truthy(&self) -> bool {
34 match self {
35 ExprValue::Bool(b) => *b,
36 ExprValue::Number(n) => *n != 0.0 && !n.is_nan(),
37 ExprValue::String(s) => !s.is_empty(),
38 ExprValue::Null => false,
39 ExprValue::Object(_) => true,
40 }
41 }
42
43 pub fn to_output_string(&self) -> String {
45 match self {
46 ExprValue::String(s) => s.clone(),
47 ExprValue::Number(n) => {
48 if n.is_finite() && *n == (*n as i64) as f64 {
49 format!("{}", *n as i64)
50 } else {
51 format!("{}", n)
52 }
53 }
54 ExprValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
55 ExprValue::Null => String::new(),
56 ExprValue::Object(map) => {
57 let sorted: std::collections::BTreeMap<&String, serde_json::Value> =
59 map.iter().map(|(k, v)| (k, expr_to_json(v))).collect();
60 serde_json::to_string_pretty(&sorted).unwrap_or_else(|_| "{}".to_string())
61 }
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
71enum Token {
72 Ident(String),
73 StringLit(String),
74 NumberLit(f64),
75 True,
76 False,
77 Null,
78 Dot,
79 LParen,
80 RParen,
81 Comma,
82 Eq, Ne, Lt, Le, Gt, Ge, And, Or, Not, Eof,
92}
93
94struct Tokenizer<'a> {
95 input: &'a str,
96 pos: usize, }
98
99impl<'a> Tokenizer<'a> {
100 fn new(input: &'a str) -> Self {
101 Self { input, pos: 0 }
102 }
103
104 fn skip_whitespace(&mut self) {
105 let bytes = self.input.as_bytes();
106 while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
107 self.pos += 1;
108 }
109 }
110
111 fn tokenize(&mut self) -> Result<Vec<Token>, String> {
112 let mut tokens = Vec::new();
113 loop {
114 self.skip_whitespace();
115 if self.pos >= self.input.len() {
116 tokens.push(Token::Eof);
117 return Ok(tokens);
118 }
119 let bytes = self.input.as_bytes();
120 let ch = bytes[self.pos] as char;
121 match ch {
122 '.' => {
123 tokens.push(Token::Dot);
124 self.pos += 1;
125 }
126 '(' => {
127 tokens.push(Token::LParen);
128 self.pos += 1;
129 }
130 ')' => {
131 tokens.push(Token::RParen);
132 self.pos += 1;
133 }
134 ',' => {
135 tokens.push(Token::Comma);
136 self.pos += 1;
137 }
138 '=' => {
139 if self.peek_next_byte() == Some(b'=') {
140 tokens.push(Token::Eq);
141 self.pos += 2;
142 } else {
143 return Err(format!("unexpected '=' at position {}", self.pos));
144 }
145 }
146 '!' => {
147 if self.peek_next_byte() == Some(b'=') {
148 tokens.push(Token::Ne);
149 self.pos += 2;
150 } else {
151 tokens.push(Token::Not);
152 self.pos += 1;
153 }
154 }
155 '<' => {
156 if self.peek_next_byte() == Some(b'=') {
157 tokens.push(Token::Le);
158 self.pos += 2;
159 } else {
160 tokens.push(Token::Lt);
161 self.pos += 1;
162 }
163 }
164 '>' => {
165 if self.peek_next_byte() == Some(b'=') {
166 tokens.push(Token::Ge);
167 self.pos += 2;
168 } else {
169 tokens.push(Token::Gt);
170 self.pos += 1;
171 }
172 }
173 '&' => {
174 if self.peek_next_byte() == Some(b'&') {
175 tokens.push(Token::And);
176 self.pos += 2;
177 } else {
178 return Err(format!("unexpected '&' at position {}", self.pos));
179 }
180 }
181 '|' => {
182 if self.peek_next_byte() == Some(b'|') {
183 tokens.push(Token::Or);
184 self.pos += 2;
185 } else {
186 return Err(format!("unexpected '|' at position {}", self.pos));
187 }
188 }
189 '\'' => {
190 tokens.push(self.read_string()?);
191 }
192 c if c.is_ascii_digit() => {
193 tokens.push(self.read_number()?);
194 }
195 c if c.is_ascii_alphabetic() || c == '_' => {
196 let ident = self.read_ident();
197 tokens.push(match ident.as_str() {
198 "true" => Token::True,
199 "false" => Token::False,
200 "null" => Token::Null,
201 _ => Token::Ident(ident),
202 });
203 }
204 _ => {
205 let actual_ch = self.input[self.pos..].chars().next().unwrap_or(ch);
207 return Err(format!(
208 "unexpected character '{}' at position {}",
209 actual_ch, self.pos
210 ));
211 }
212 }
213 }
214 }
215
216 fn peek_next_byte(&self) -> Option<u8> {
218 let bytes = self.input.as_bytes();
219 if self.pos + 1 < bytes.len() {
220 Some(bytes[self.pos + 1])
221 } else {
222 None
223 }
224 }
225
226 fn read_string(&mut self) -> Result<Token, String> {
228 self.pos += 1; let mut s = String::new();
230 while self.pos < self.input.len() {
231 let ch = self.input[self.pos..].chars().next().unwrap();
233 if ch == '\'' {
234 let next_pos = self.pos + 1;
236 if next_pos < self.input.len() && self.input.as_bytes()[next_pos] == b'\'' {
237 s.push('\'');
238 self.pos += 2;
239 } else {
240 self.pos += 1; return Ok(Token::StringLit(s));
242 }
243 } else {
244 s.push(ch);
245 self.pos += ch.len_utf8();
246 }
247 }
248 Err("unterminated string literal".to_string())
249 }
250
251 fn read_number(&mut self) -> Result<Token, String> {
252 let start = self.pos;
253 let bytes = self.input.as_bytes();
254 while self.pos < bytes.len()
255 && (bytes[self.pos].is_ascii_digit() || bytes[self.pos] == b'.')
256 {
257 self.pos += 1;
258 }
259 let s = &self.input[start..self.pos];
260 let n: f64 = s
261 .parse()
262 .map_err(|e| format!("invalid number '{}': {}", s, e))?;
263 Ok(Token::NumberLit(n))
264 }
265
266 fn read_ident(&mut self) -> String {
267 let start = self.pos;
268 let bytes = self.input.as_bytes();
269 while self.pos < bytes.len() {
270 let ch = bytes[self.pos];
271 if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-' {
272 self.pos += 1;
273 } else {
274 break;
275 }
276 }
277 self.input[start..self.pos].to_string()
278 }
279}
280
281pub(crate) fn github_context_suffix(key: &str) -> Option<String> {
297 let rest = key.strip_prefix("GITHUB_")?;
298 if rest.is_empty() {
299 return None;
300 }
301 let suffix = rest.to_ascii_lowercase();
302 if matches!(
303 suffix.as_str(),
304 "output" | "env" | "path" | "step_summary" | "actions"
305 ) {
306 return None;
307 }
308 Some(suffix)
309}
310
311pub struct ExpressionContext<'a> {
317 pub env_context: &'a HashMap<String, String>,
318 pub step_outputs: &'a HashMap<String, HashMap<String, String>>,
319 pub matrix_combination: &'a Option<HashMap<String, Value>>,
320 pub step_statuses: &'a HashMap<String, (String, String)>,
323 pub job_status: &'a str,
326 pub secrets_context: &'a HashMap<String, String>,
328 pub needs_context: &'a HashMap<String, HashMap<String, String>>,
330 pub needs_results: &'a HashMap<String, String>,
332 pub user_env: &'a HashMap<String, String>,
340}
341
342impl<'a> ExpressionContext<'a> {
343 fn resolve(&self, parts: &[String]) -> ExprValue {
346 if parts.is_empty() {
347 return ExprValue::Null;
348 }
349
350 let root = parts[0].as_str();
351 match root {
352 "inputs" if parts.len() == 2 => {
353 let env_key = format!("INPUT_{}", parts[1].to_uppercase().replace('-', "_"));
354 self.env_context
355 .get(&env_key)
356 .map(|v| ExprValue::String(v.clone()))
357 .unwrap_or(ExprValue::Null)
358 }
359 "env" if parts.len() == 2 => self
360 .env_context
361 .get(&parts[1])
362 .map(|v| ExprValue::String(v.clone()))
363 .unwrap_or(ExprValue::Null),
364 "github" if parts.len() >= 2 => {
365 let env_key = format!("GITHUB_{}", parts[1..].join("_").to_uppercase());
376 self.env_context
377 .get(&env_key)
378 .map(|v| ExprValue::String(v.clone()))
379 .unwrap_or(ExprValue::Null)
380 }
381 "runner" if parts.len() == 2 => {
382 let env_key = format!("RUNNER_{}", parts[1].to_uppercase());
383 self.env_context
384 .get(&env_key)
385 .map(|v| ExprValue::String(v.clone()))
386 .unwrap_or(ExprValue::Null)
387 }
388 "matrix" if parts.len() == 2 => {
389 if let Some(matrix) = self.matrix_combination {
390 matrix
391 .get(&parts[1])
392 .map(yaml_value_to_expr)
393 .unwrap_or(ExprValue::Null)
394 } else {
395 ExprValue::Null
396 }
397 }
398 "steps" if parts.len() == 4 && parts[2] == "outputs" => self
399 .step_outputs
400 .get(&parts[1])
401 .and_then(|m| m.get(&parts[3]))
402 .map(|v| ExprValue::String(v.clone()))
403 .unwrap_or(ExprValue::Null),
404 "needs" if parts.len() == 4 && parts[2] == "outputs" => self
405 .needs_context
406 .get(&parts[1])
407 .and_then(|m| m.get(&parts[3]))
408 .map(|v| ExprValue::String(v.clone()))
409 .unwrap_or(ExprValue::Null),
410 "needs" if parts.len() == 3 && parts[2] == "result" => self
411 .needs_results
412 .get(&parts[1])
413 .map(|v| ExprValue::String(v.clone()))
414 .unwrap_or(ExprValue::Null),
415 "jobs" if parts.len() == 4 && parts[2] == "outputs" => self
421 .needs_context
422 .get(&parts[1])
423 .and_then(|m| m.get(&parts[3]))
424 .map(|v| ExprValue::String(v.clone()))
425 .unwrap_or(ExprValue::Null),
426 "secrets" if parts.len() == 2 => self
427 .secrets_context
428 .get(&parts[1])
429 .map(|v| ExprValue::String(v.clone()))
430 .unwrap_or(ExprValue::Null),
431 "steps" if parts.len() == 3 && parts[2] == "outcome" => self
432 .step_statuses
433 .get(&parts[1])
434 .map(|(outcome, _)| ExprValue::String(outcome.clone()))
435 .unwrap_or(ExprValue::Null),
436 "steps" if parts.len() == 3 && parts[2] == "conclusion" => self
437 .step_statuses
438 .get(&parts[1])
439 .map(|(_, conclusion)| ExprValue::String(conclusion.clone()))
440 .unwrap_or(ExprValue::Null),
441 "steps" if parts.len() == 1 => {
444 let mut all_ids: HashSet<&String> = self.step_outputs.keys().collect();
446 all_ids.extend(self.step_statuses.keys());
447
448 let mut map = HashMap::new();
449 for step_id in all_ids {
450 let mut step_obj = HashMap::new();
451
452 let outputs_map: HashMap<String, ExprValue> = self
454 .step_outputs
455 .get(step_id)
456 .map(|m| {
457 m.iter()
458 .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
459 .collect()
460 })
461 .unwrap_or_default();
462 step_obj.insert("outputs".to_string(), ExprValue::Object(outputs_map));
463
464 if let Some((outcome, conclusion)) = self.step_statuses.get(step_id) {
466 step_obj.insert("outcome".to_string(), ExprValue::String(outcome.clone()));
467 step_obj.insert(
468 "conclusion".to_string(),
469 ExprValue::String(conclusion.clone()),
470 );
471 }
472
473 map.insert(step_id.clone(), ExprValue::Object(step_obj));
474 }
475 ExprValue::Object(map)
476 }
477 "env" if parts.len() == 1 => {
478 let map = self
483 .user_env
484 .iter()
485 .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
486 .collect();
487 ExprValue::Object(map)
488 }
489 "github" if parts.len() == 1 => {
490 let map = self
505 .env_context
506 .iter()
507 .filter_map(|(k, v)| {
508 github_context_suffix(k).map(|key| (key, ExprValue::String(v.clone())))
509 })
510 .collect();
511 ExprValue::Object(map)
512 }
513 "needs" if parts.len() == 1 => {
514 let mut all_ids: HashSet<&String> = self.needs_context.keys().collect();
516 all_ids.extend(self.needs_results.keys());
517
518 let mut map = HashMap::new();
519 for job_id in all_ids {
520 let mut job_obj = HashMap::new();
521
522 let outputs_map: HashMap<String, ExprValue> = self
524 .needs_context
525 .get(job_id)
526 .map(|m| {
527 m.iter()
528 .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
529 .collect()
530 })
531 .unwrap_or_default();
532 job_obj.insert("outputs".to_string(), ExprValue::Object(outputs_map));
533
534 if let Some(result) = self.needs_results.get(job_id) {
536 job_obj.insert("result".to_string(), ExprValue::String(result.clone()));
537 }
538
539 map.insert(job_id.clone(), ExprValue::Object(job_obj));
540 }
541 ExprValue::Object(map)
542 }
543 "secrets" if parts.len() == 1 => {
544 let map = self
554 .secrets_context
555 .iter()
556 .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
557 .collect();
558 ExprValue::Object(map)
559 }
560 "matrix" if parts.len() == 1 => {
561 if let Some(matrix) = self.matrix_combination {
574 let map = matrix
575 .iter()
576 .map(|(k, v)| (k.clone(), yaml_value_to_expr(v)))
577 .collect();
578 ExprValue::Object(map)
579 } else {
580 ExprValue::Null
581 }
582 }
583 _ => ExprValue::Null,
584 }
585 }
586}
587
588fn yaml_value_to_expr(v: &Value) -> ExprValue {
589 match v {
590 Value::String(s) => ExprValue::String(s.clone()),
591 Value::Number(n) => ExprValue::Number(n.as_f64().unwrap_or(0.0)),
592 Value::Bool(b) => ExprValue::Bool(*b),
593 Value::Null => ExprValue::Null,
594 _ => ExprValue::String(
595 serde_yaml::to_string(v)
596 .unwrap_or_else(|_| format!("{:?}", v))
597 .trim()
598 .to_string(),
599 ),
600 }
601}
602
603struct Parser {
608 tokens: Vec<Token>,
609 pos: usize,
610}
611
612impl Parser {
613 fn new(tokens: Vec<Token>) -> Self {
614 Self { tokens, pos: 0 }
615 }
616
617 fn peek(&self) -> &Token {
618 self.tokens.get(self.pos).unwrap_or(&Token::Eof)
619 }
620
621 fn advance(&mut self) -> Token {
622 let tok = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof);
623 self.pos += 1;
624 tok
625 }
626
627 fn expect(&mut self, expected: &Token) -> Result<(), String> {
628 let tok = self.advance();
629 if &tok == expected {
630 Ok(())
631 } else {
632 Err(format!("expected {:?}, got {:?}", expected, tok))
633 }
634 }
635
636 fn parse_expr(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
638 self.parse_or(ctx)
639 }
640
641 fn parse_or(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
643 let mut left = self.parse_and(ctx)?;
644 while *self.peek() == Token::Or {
645 self.advance();
646 let right = self.parse_and(ctx)?;
647 left = if left.is_truthy() { left } else { right };
649 }
650 Ok(left)
651 }
652
653 fn parse_and(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
655 let mut left = self.parse_comparison(ctx)?;
656 while *self.peek() == Token::And {
657 self.advance();
658 let right = self.parse_comparison(ctx)?;
659 left = if !left.is_truthy() { left } else { right };
661 }
662 Ok(left)
663 }
664
665 fn parse_comparison(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
667 let left = self.parse_unary(ctx)?;
668 match self.peek().clone() {
669 Token::Eq => {
670 self.advance();
671 let right = self.parse_unary(ctx)?;
672 Ok(ExprValue::Bool(expr_eq(&left, &right)))
673 }
674 Token::Ne => {
675 self.advance();
676 let right = self.parse_unary(ctx)?;
677 Ok(ExprValue::Bool(!expr_eq(&left, &right)))
678 }
679 Token::Lt => {
680 self.advance();
681 let right = self.parse_unary(ctx)?;
682 Ok(ExprValue::Bool(
683 expr_cmp(&left, &right) == Some(std::cmp::Ordering::Less),
684 ))
685 }
686 Token::Le => {
687 self.advance();
688 let right = self.parse_unary(ctx)?;
689 Ok(ExprValue::Bool(matches!(
690 expr_cmp(&left, &right),
691 Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
692 )))
693 }
694 Token::Gt => {
695 self.advance();
696 let right = self.parse_unary(ctx)?;
697 Ok(ExprValue::Bool(
698 expr_cmp(&left, &right) == Some(std::cmp::Ordering::Greater),
699 ))
700 }
701 Token::Ge => {
702 self.advance();
703 let right = self.parse_unary(ctx)?;
704 Ok(ExprValue::Bool(matches!(
705 expr_cmp(&left, &right),
706 Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
707 )))
708 }
709 _ => Ok(left),
710 }
711 }
712
713 fn parse_unary(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
715 if *self.peek() == Token::Not {
716 self.advance();
717 let val = self.parse_unary(ctx)?;
718 Ok(ExprValue::Bool(!val.is_truthy()))
719 } else {
720 self.parse_primary(ctx)
721 }
722 }
723
724 fn parse_primary(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
726 match self.peek().clone() {
727 Token::StringLit(s) => {
728 self.advance();
729 Ok(ExprValue::String(s))
730 }
731 Token::NumberLit(n) => {
732 self.advance();
733 Ok(ExprValue::Number(n))
734 }
735 Token::True => {
736 self.advance();
737 Ok(ExprValue::Bool(true))
738 }
739 Token::False => {
740 self.advance();
741 Ok(ExprValue::Bool(false))
742 }
743 Token::Null => {
744 self.advance();
745 Ok(ExprValue::Null)
746 }
747 Token::LParen => {
748 self.advance();
749 let val = self.parse_expr(ctx)?;
750 self.expect(&Token::RParen)?;
751 Ok(val)
752 }
753 Token::Ident(_) => self.parse_ident_or_call(ctx),
754 Token::Not => self.parse_unary(ctx),
755 other => Err(format!("unexpected token: {:?}", other)),
756 }
757 }
758
759 fn parse_ident_or_call(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
763 let Token::Ident(name) = self.advance() else {
764 return Err("expected identifier".to_string());
765 };
766
767 if *self.peek() == Token::LParen {
769 self.advance(); let mut args = Vec::new();
771 if *self.peek() != Token::RParen {
772 args.push(self.parse_expr(ctx)?);
773 while *self.peek() == Token::Comma {
774 self.advance();
775 args.push(self.parse_expr(ctx)?);
776 }
777 }
778 self.expect(&Token::RParen)?;
779 return call_builtin(&name, &args, ctx);
780 }
781
782 let mut parts = vec![name];
784 while *self.peek() == Token::Dot {
785 self.advance(); match self.advance() {
787 Token::Ident(part) => parts.push(part),
788 other => return Err(format!("expected identifier after '.', got {:?}", other)),
789 }
790 }
791
792 Ok(ctx.resolve(&parts))
793 }
794}
795
796fn expr_eq(a: &ExprValue, b: &ExprValue) -> bool {
801 match (a, b) {
803 (ExprValue::Null, ExprValue::Null) => true,
804 (ExprValue::Null, _) | (_, ExprValue::Null) => false,
805 (ExprValue::Bool(a), ExprValue::Bool(b)) => a == b,
806 (ExprValue::Number(a), ExprValue::Number(b)) => (a - b).abs() < f64::EPSILON,
807 (ExprValue::String(a), ExprValue::String(b)) => a.eq_ignore_ascii_case(b),
808 (ExprValue::String(s), ExprValue::Number(n))
810 | (ExprValue::Number(n), ExprValue::String(s)) => {
811 if let Ok(parsed) = s.parse::<f64>() {
812 (parsed - n).abs() < f64::EPSILON
813 } else {
814 false
815 }
816 }
817 (ExprValue::Bool(b), ExprValue::Number(n)) | (ExprValue::Number(n), ExprValue::Bool(b)) => {
819 let bv = if *b { 1.0 } else { 0.0 };
820 (bv - n).abs() < f64::EPSILON
821 }
822 (ExprValue::Bool(b), ExprValue::String(s)) | (ExprValue::String(s), ExprValue::Bool(b)) => {
823 let sv = s.eq_ignore_ascii_case("true");
827 *b == sv
828 }
829 (ExprValue::Object(_), _) | (_, ExprValue::Object(_)) => false,
831 }
832}
833
834fn expr_cmp(a: &ExprValue, b: &ExprValue) -> Option<std::cmp::Ordering> {
835 match (a, b) {
836 (ExprValue::Number(a), ExprValue::Number(b)) => a.partial_cmp(b),
837 (ExprValue::String(a), ExprValue::String(b)) => {
838 Some(a.to_lowercase().cmp(&b.to_lowercase()))
839 }
840 (ExprValue::Object(_), _) | (_, ExprValue::Object(_)) => None,
843 _ => None,
844 }
845}
846
847fn expr_to_json(v: &ExprValue) -> serde_json::Value {
853 match v {
854 ExprValue::String(s) => serde_json::Value::String(s.clone()),
855 ExprValue::Number(n) => serde_json::json!(n),
856 ExprValue::Bool(b) => serde_json::Value::Bool(*b),
857 ExprValue::Null => serde_json::Value::Null,
858 ExprValue::Object(map) => {
859 let obj: serde_json::Map<String, serde_json::Value> = map
860 .iter()
861 .map(|(k, v)| (k.clone(), expr_to_json(v)))
862 .collect();
863 serde_json::Value::Object(obj)
864 }
865 }
866}
867
868fn call_builtin(
869 name: &str,
870 args: &[ExprValue],
871 ctx: &ExpressionContext,
872) -> Result<ExprValue, String> {
873 match name {
874 "contains" => {
875 if args.len() != 2 {
876 return Err("contains() requires 2 arguments".to_string());
877 }
878 let haystack = args[0].to_output_string().to_lowercase();
879 let needle = args[1].to_output_string().to_lowercase();
880 Ok(ExprValue::Bool(haystack.contains(&needle)))
881 }
882 "startsWith" | "startswith" => {
883 if args.len() != 2 {
884 return Err("startsWith() requires 2 arguments".to_string());
885 }
886 let s = args[0].to_output_string().to_lowercase();
887 let prefix = args[1].to_output_string().to_lowercase();
888 Ok(ExprValue::Bool(s.starts_with(&prefix)))
889 }
890 "endsWith" | "endswith" => {
891 if args.len() != 2 {
892 return Err("endsWith() requires 2 arguments".to_string());
893 }
894 let s = args[0].to_output_string().to_lowercase();
895 let suffix = args[1].to_output_string().to_lowercase();
896 Ok(ExprValue::Bool(s.ends_with(&suffix)))
897 }
898 "format" => {
899 if args.is_empty() {
900 return Err("format() requires at least 1 argument".to_string());
901 }
902 let fmt = args[0].to_output_string();
903 let mut result = String::with_capacity(fmt.len());
907 let mut chars = fmt.char_indices().peekable();
908 while let Some((i, ch)) = chars.next() {
909 if ch == '{' {
910 let rest = &fmt[i + 1..];
912 if let Some(close) = rest.find('}') {
913 let inner = &rest[..close];
914 if let Ok(idx) = inner.parse::<usize>() {
915 if idx + 1 < args.len() {
916 result.push_str(&args[idx + 1].to_output_string());
917 let skip_to = i + 1 + close + 1;
919 while chars.peek().is_some_and(|(ci, _)| *ci < skip_to) {
920 chars.next();
921 }
922 continue;
923 }
924 }
925 }
926 }
927 result.push(ch);
928 }
929 Ok(ExprValue::String(result))
930 }
931 "join" => {
932 if args.is_empty() || args.len() > 2 {
933 return Err("join() requires 1 or 2 arguments".to_string());
934 }
935 let sep = if args.len() == 2 {
936 args[1].to_output_string()
937 } else {
938 ",".to_string()
939 };
940 Ok(ExprValue::String(
942 args[0].to_output_string().replace(',', &sep),
943 ))
944 }
945 "toJSON" | "tojson" => {
946 if args.len() != 1 {
947 return Err("toJSON() requires 1 argument".to_string());
948 }
949 match &args[0] {
950 ExprValue::String(s) => {
951 Ok(ExprValue::String(
953 serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s)),
954 ))
955 }
956 ExprValue::Number(n) => Ok(ExprValue::String(format!("{}", n))),
957 ExprValue::Bool(b) => Ok(ExprValue::String(format!("{}", b))),
958 ExprValue::Null => Ok(ExprValue::String("null".to_string())),
959 ExprValue::Object(map) => {
960 let sorted: std::collections::BTreeMap<&String, serde_json::Value> =
963 map.iter().map(|(k, v)| (k, expr_to_json(v))).collect();
964 Ok(ExprValue::String(
965 serde_json::to_string_pretty(&sorted).unwrap_or_else(|_| "{}".to_string()),
966 ))
967 }
968 }
969 }
970 "fromJSON" | "fromjson" => {
971 if args.len() != 1 {
972 return Err("fromJSON() requires 1 argument".to_string());
973 }
974 let s = args[0].to_output_string();
975 match s.as_str() {
977 "null" => Ok(ExprValue::Null),
978 "true" => Ok(ExprValue::Bool(true)),
979 "false" => Ok(ExprValue::Bool(false)),
980 _ => {
981 if let Ok(n) = s.parse::<f64>() {
982 Ok(ExprValue::Number(n))
983 } else {
984 let stripped = s
986 .strip_prefix('"')
987 .and_then(|s| s.strip_suffix('"'))
988 .unwrap_or(&s);
989 Ok(ExprValue::String(stripped.to_string()))
990 }
991 }
992 }
993 }
994 "success" => Ok(ExprValue::Bool(ctx.job_status == "success")),
996 "failure" => Ok(ExprValue::Bool(ctx.job_status == "failure")),
997 "always" => Ok(ExprValue::Bool(true)),
998 "cancelled" => Ok(ExprValue::Bool(ctx.job_status == "cancelled")),
999 _ => {
1000 Ok(ExprValue::Null)
1002 }
1003 }
1004}
1005
1006pub fn evaluate(expr: &str, ctx: &ExpressionContext) -> Result<ExprValue, String> {
1015 let trimmed = expr.trim();
1016 if trimmed.is_empty() {
1017 return Ok(ExprValue::Null);
1018 }
1019 let mut tokenizer = Tokenizer::new(trimmed);
1020 let tokens = tokenizer.tokenize()?;
1021 let mut parser = Parser::new(tokens);
1022 let result = parser.parse_expr(ctx)?;
1023 if *parser.peek() != Token::Eof {
1025 return Err(format!(
1026 "unexpected token after expression: {:?}",
1027 parser.peek()
1028 ));
1029 }
1030 Ok(result)
1031}
1032
1033pub fn evaluate_as_bool(expr: &str, ctx: &ExpressionContext) -> Result<bool, String> {
1037 let trimmed = expr.trim();
1038 let inner = if trimmed.starts_with("${{") && trimmed.ends_with("}}") {
1040 &trimmed[3..trimmed.len() - 2]
1041 } else {
1042 trimmed
1043 };
1044 let val = evaluate(inner, ctx)?;
1045 Ok(val.is_truthy())
1046}
1047
1048#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 lazy_static::lazy_static! {
1057 static ref EMPTY_ENV: HashMap<String, String> = HashMap::new();
1058 static ref EMPTY_USER_ENV: HashMap<String, String> = HashMap::new();
1059 static ref EMPTY_STEPS: HashMap<String, HashMap<String, String>> = HashMap::new();
1060 static ref EMPTY_MATRIX: Option<HashMap<String, Value>> = None;
1061 static ref EMPTY_STATUSES: HashMap<String, (String, String)> = HashMap::new();
1062 static ref EMPTY_SECRETS: HashMap<String, String> = HashMap::new();
1063 static ref EMPTY_NEEDS: HashMap<String, HashMap<String, String>> = HashMap::new();
1064 static ref EMPTY_NEEDS_RESULTS: HashMap<String, String> = HashMap::new();
1065 }
1066
1067 fn empty_ctx() -> ExpressionContext<'static> {
1068 ExpressionContext {
1069 env_context: &EMPTY_ENV,
1070 step_outputs: &EMPTY_STEPS,
1071 matrix_combination: &EMPTY_MATRIX,
1072 step_statuses: &EMPTY_STATUSES,
1073 job_status: "success",
1074 secrets_context: &EMPTY_SECRETS,
1075 needs_context: &EMPTY_NEEDS,
1076 needs_results: &EMPTY_NEEDS_RESULTS,
1077 user_env: &EMPTY_USER_ENV,
1078 }
1079 }
1080
1081 fn make_ctx<'a>(
1089 env: &'a HashMap<String, String>,
1090 steps: &'a HashMap<String, HashMap<String, String>>,
1091 matrix: &'a Option<HashMap<String, Value>>,
1092 ) -> ExpressionContext<'a> {
1093 ExpressionContext {
1094 env_context: env,
1095 step_outputs: steps,
1096 matrix_combination: matrix,
1097 step_statuses: &EMPTY_STATUSES,
1098 job_status: "success",
1099 secrets_context: &EMPTY_SECRETS,
1100 needs_context: &EMPTY_NEEDS,
1101 needs_results: &EMPTY_NEEDS_RESULTS,
1102 user_env: env,
1103 }
1104 }
1105
1106 #[test]
1109 fn eval_string_literal() {
1110 let ctx = empty_ctx();
1111 assert_eq!(
1112 evaluate("'hello'", &ctx).unwrap(),
1113 ExprValue::String("hello".to_string())
1114 );
1115 }
1116
1117 #[test]
1118 fn eval_empty_string_literal() {
1119 let ctx = empty_ctx();
1120 assert_eq!(
1121 evaluate("''", &ctx).unwrap(),
1122 ExprValue::String(String::new())
1123 );
1124 }
1125
1126 #[test]
1127 fn eval_number_literal() {
1128 let ctx = empty_ctx();
1129 assert_eq!(evaluate("42", &ctx).unwrap(), ExprValue::Number(42.0));
1130 }
1131
1132 #[test]
1133 fn eval_bool_literals() {
1134 let ctx = empty_ctx();
1135 assert_eq!(evaluate("true", &ctx).unwrap(), ExprValue::Bool(true));
1136 assert_eq!(evaluate("false", &ctx).unwrap(), ExprValue::Bool(false));
1137 }
1138
1139 #[test]
1140 fn eval_null_literal() {
1141 let ctx = empty_ctx();
1142 assert_eq!(evaluate("null", &ctx).unwrap(), ExprValue::Null);
1143 }
1144
1145 #[test]
1148 fn truthiness() {
1149 assert!(ExprValue::Bool(true).is_truthy());
1150 assert!(!ExprValue::Bool(false).is_truthy());
1151 assert!(ExprValue::Number(1.0).is_truthy());
1152 assert!(!ExprValue::Number(0.0).is_truthy());
1153 assert!(ExprValue::String("hello".to_string()).is_truthy());
1154 assert!(!ExprValue::String(String::new()).is_truthy());
1155 assert!(!ExprValue::Null.is_truthy());
1156 }
1157
1158 #[test]
1161 fn eval_equality() {
1162 let ctx = empty_ctx();
1163 assert_eq!(
1164 evaluate("'nightly' == 'nightly'", &ctx).unwrap(),
1165 ExprValue::Bool(true)
1166 );
1167 assert_eq!(
1168 evaluate("'nightly' == 'stable'", &ctx).unwrap(),
1169 ExprValue::Bool(false)
1170 );
1171 assert_eq!(
1172 evaluate("'nightly' != 'stable'", &ctx).unwrap(),
1173 ExprValue::Bool(true)
1174 );
1175 }
1176
1177 #[test]
1178 fn eval_bool_string_coercion() {
1179 let ctx = empty_ctx();
1180 assert_eq!(
1183 evaluate("false == 'random'", &ctx).unwrap(),
1184 ExprValue::Bool(true)
1185 );
1186 assert_eq!(
1187 evaluate("true == 'true'", &ctx).unwrap(),
1188 ExprValue::Bool(true)
1189 );
1190 assert_eq!(
1191 evaluate("true == 'TRUE'", &ctx).unwrap(),
1192 ExprValue::Bool(true)
1193 );
1194 assert_eq!(
1195 evaluate("true == 'false'", &ctx).unwrap(),
1196 ExprValue::Bool(false)
1197 );
1198 assert_eq!(
1199 evaluate("false == 'false'", &ctx).unwrap(),
1200 ExprValue::Bool(true)
1201 );
1202 }
1203
1204 #[test]
1205 fn eval_case_insensitive_equality() {
1206 let ctx = empty_ctx();
1207 assert_eq!(
1208 evaluate("'Nightly' == 'nightly'", &ctx).unwrap(),
1209 ExprValue::Bool(true)
1210 );
1211 }
1212
1213 #[test]
1214 fn eval_number_comparison() {
1215 let ctx = empty_ctx();
1216 assert_eq!(evaluate("1 < 2", &ctx).unwrap(), ExprValue::Bool(true));
1217 assert_eq!(evaluate("2 >= 2", &ctx).unwrap(), ExprValue::Bool(true));
1218 assert_eq!(evaluate("3 <= 2", &ctx).unwrap(), ExprValue::Bool(false));
1219 }
1220
1221 #[test]
1222 fn eval_and_operator() {
1223 let ctx = empty_ctx();
1224 assert_eq!(
1226 evaluate("true && 'hello'", &ctx).unwrap(),
1227 ExprValue::String("hello".to_string())
1228 );
1229 assert_eq!(
1230 evaluate("false && 'hello'", &ctx).unwrap(),
1231 ExprValue::Bool(false)
1232 );
1233 assert_eq!(
1234 evaluate("'' && 'hello'", &ctx).unwrap(),
1235 ExprValue::String(String::new())
1236 );
1237 }
1238
1239 #[test]
1240 fn eval_or_operator() {
1241 let ctx = empty_ctx();
1242 assert_eq!(
1244 evaluate("'hi' || 'bye'", &ctx).unwrap(),
1245 ExprValue::String("hi".to_string())
1246 );
1247 assert_eq!(
1248 evaluate("'' || 'fallback'", &ctx).unwrap(),
1249 ExprValue::String("fallback".to_string())
1250 );
1251 assert_eq!(
1252 evaluate("false || ''", &ctx).unwrap(),
1253 ExprValue::String(String::new())
1254 );
1255 }
1256
1257 #[test]
1258 fn eval_not_operator() {
1259 let ctx = empty_ctx();
1260 assert_eq!(evaluate("!true", &ctx).unwrap(), ExprValue::Bool(false));
1261 assert_eq!(evaluate("!false", &ctx).unwrap(), ExprValue::Bool(true));
1262 assert_eq!(evaluate("!''", &ctx).unwrap(), ExprValue::Bool(true));
1263 }
1264
1265 #[test]
1266 fn eval_parentheses() {
1267 let ctx = empty_ctx();
1268 assert_eq!(
1269 evaluate("(true || false) && false", &ctx).unwrap(),
1270 ExprValue::Bool(false)
1271 );
1272 }
1273
1274 #[test]
1277 fn eval_inputs_context() {
1278 let mut env = HashMap::new();
1279 env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string());
1280 let empty_steps = HashMap::new();
1281 let ctx = make_ctx(&env, &empty_steps, &None);
1282
1283 assert_eq!(
1284 evaluate("inputs.toolchain", &ctx).unwrap(),
1285 ExprValue::String("nightly".to_string())
1286 );
1287 }
1288
1289 #[test]
1290 fn eval_env_context() {
1291 let mut env = HashMap::new();
1292 env.insert("MY_VAR".to_string(), "hello".to_string());
1293 let empty_steps = HashMap::new();
1294 let ctx = make_ctx(&env, &empty_steps, &None);
1295
1296 assert_eq!(
1297 evaluate("env.MY_VAR", &ctx).unwrap(),
1298 ExprValue::String("hello".to_string())
1299 );
1300 }
1301
1302 #[test]
1303 fn eval_github_context() {
1304 let mut env = HashMap::new();
1305 env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
1306 let empty_steps = HashMap::new();
1307 let ctx = make_ctx(&env, &empty_steps, &None);
1308
1309 assert_eq!(
1310 evaluate("github.repository", &ctx).unwrap(),
1311 ExprValue::String("owner/repo".to_string())
1312 );
1313 }
1314
1315 #[test]
1316 fn eval_steps_outputs() {
1317 let mut steps = HashMap::new();
1318 let mut build_out = HashMap::new();
1319 build_out.insert("version".to_string(), "1.2.3".to_string());
1320 steps.insert("build".to_string(), build_out);
1321 let empty_env = HashMap::new();
1322 let ctx = make_ctx(&empty_env, &steps, &None);
1323
1324 assert_eq!(
1325 evaluate("steps.build.outputs.version", &ctx).unwrap(),
1326 ExprValue::String("1.2.3".to_string())
1327 );
1328 }
1329
1330 #[test]
1331 fn eval_matrix_context() {
1332 let mut matrix = HashMap::new();
1333 matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
1334 let empty_env = HashMap::new();
1335 let empty_steps = HashMap::new();
1336 let matrix = Some(matrix);
1337 let ctx = make_ctx(&empty_env, &empty_steps, &matrix);
1338
1339 assert_eq!(
1340 evaluate("matrix.os", &ctx).unwrap(),
1341 ExprValue::String("ubuntu".to_string())
1342 );
1343 }
1344
1345 #[test]
1346 fn eval_missing_context_returns_null() {
1347 let ctx = empty_ctx();
1348 assert_eq!(
1349 evaluate("inputs.nonexistent", &ctx).unwrap(),
1350 ExprValue::Null
1351 );
1352 }
1353
1354 #[test]
1357 fn eval_rust_toolchain_pattern() {
1358 let mut env = HashMap::new();
1360 env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
1361
1362 let mut steps = HashMap::new();
1363 let mut parse_out = HashMap::new();
1364 parse_out.insert("toolchain".to_string(), "nightly".to_string());
1365 steps.insert("parse".to_string(), parse_out);
1366
1367 let ctx = make_ctx(&env, &steps, &None);
1368
1369 let result = evaluate(
1370 "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
1371 &ctx,
1372 )
1373 .unwrap();
1374 assert_eq!(result, ExprValue::String(" --allow-downgrade".to_string()));
1375 }
1376
1377 #[test]
1378 fn eval_rust_toolchain_pattern_not_nightly() {
1379 let mut env = HashMap::new();
1380 env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
1381
1382 let mut steps = HashMap::new();
1383 let mut parse_out = HashMap::new();
1384 parse_out.insert("toolchain".to_string(), "stable".to_string());
1385 steps.insert("parse".to_string(), parse_out);
1386
1387 let ctx = make_ctx(&env, &steps, &None);
1388
1389 let result = evaluate(
1390 "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
1391 &ctx,
1392 )
1393 .unwrap();
1394 assert_eq!(result, ExprValue::String(String::new()));
1396 }
1397
1398 #[test]
1399 fn eval_rust_toolchain_pattern_no_components() {
1400 let env = HashMap::new(); let mut steps = HashMap::new();
1403 let mut parse_out = HashMap::new();
1404 parse_out.insert("toolchain".to_string(), "nightly".to_string());
1405 steps.insert("parse".to_string(), parse_out);
1406
1407 let ctx = make_ctx(&env, &steps, &None);
1408
1409 let result = evaluate(
1410 "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
1411 &ctx,
1412 )
1413 .unwrap();
1414 assert_eq!(result, ExprValue::String(String::new()));
1416 }
1417
1418 #[test]
1421 fn eval_contains() {
1422 let ctx = empty_ctx();
1423 assert_eq!(
1424 evaluate("contains('Hello World', 'hello')", &ctx).unwrap(),
1425 ExprValue::Bool(true)
1426 );
1427 assert_eq!(
1428 evaluate("contains('Hello', 'xyz')", &ctx).unwrap(),
1429 ExprValue::Bool(false)
1430 );
1431 }
1432
1433 #[test]
1434 fn eval_starts_with() {
1435 let ctx = empty_ctx();
1436 assert_eq!(
1437 evaluate("startsWith('refs/heads/main', 'refs/heads')", &ctx).unwrap(),
1438 ExprValue::Bool(true)
1439 );
1440 assert_eq!(
1441 evaluate("startsWith('refs/tags/v1', 'refs/heads')", &ctx).unwrap(),
1442 ExprValue::Bool(false)
1443 );
1444 }
1445
1446 #[test]
1447 fn eval_ends_with() {
1448 let ctx = empty_ctx();
1449 assert_eq!(
1450 evaluate("endsWith('hello.txt', '.txt')", &ctx).unwrap(),
1451 ExprValue::Bool(true)
1452 );
1453 }
1454
1455 #[test]
1456 fn eval_format_function() {
1457 let ctx = empty_ctx();
1458 assert_eq!(
1459 evaluate("format('Hello {0}, you are {1}', 'world', 'great')", &ctx).unwrap(),
1460 ExprValue::String("Hello world, you are great".to_string())
1461 );
1462 }
1463
1464 #[test]
1465 fn eval_format_non_ascii() {
1466 let ctx = empty_ctx();
1467 assert_eq!(
1468 evaluate("format('{0} → {1}', 'a', 'b')", &ctx).unwrap(),
1469 ExprValue::String("a → b".to_string())
1470 );
1471 }
1472
1473 #[test]
1474 fn eval_format_out_of_bounds_placeholder_preserved() {
1475 let ctx = empty_ctx();
1476 assert_eq!(
1478 evaluate("format('{0} {5}', 'hi')", &ctx).unwrap(),
1479 ExprValue::String("hi {5}".to_string())
1480 );
1481 }
1482
1483 #[test]
1484 fn eval_format_arg_containing_placeholder_not_reinterpreted() {
1485 let ctx = empty_ctx();
1486 assert_eq!(
1488 evaluate("format('{0} {1}', '{1}', 'x')", &ctx).unwrap(),
1489 ExprValue::String("{1} x".to_string())
1490 );
1491 }
1492
1493 #[test]
1494 fn eval_status_functions() {
1495 let ctx = empty_ctx();
1496 assert_eq!(evaluate("success()", &ctx).unwrap(), ExprValue::Bool(true));
1497 assert_eq!(evaluate("failure()", &ctx).unwrap(), ExprValue::Bool(false));
1498 assert_eq!(evaluate("always()", &ctx).unwrap(), ExprValue::Bool(true));
1499 assert_eq!(
1500 evaluate("cancelled()", &ctx).unwrap(),
1501 ExprValue::Bool(false)
1502 );
1503 }
1504
1505 #[test]
1508 fn eval_as_bool_strips_delimiters() {
1509 let ctx = empty_ctx();
1510 assert!(evaluate_as_bool("${{ true }}", &ctx).unwrap());
1511 assert!(!evaluate_as_bool("${{ false }}", &ctx).unwrap());
1512 }
1513
1514 #[test]
1515 fn eval_as_bool_bare_expression() {
1516 let ctx = empty_ctx();
1517 assert!(evaluate_as_bool("true", &ctx).unwrap());
1518 assert!(!evaluate_as_bool("false", &ctx).unwrap());
1519 }
1520
1521 #[test]
1522 fn eval_as_bool_condition_with_context() {
1523 let mut env = HashMap::new();
1524 env.insert("GITHUB_REF".to_string(), "refs/tags/v1.0.0".to_string());
1525 let empty_steps = HashMap::new();
1526 let ctx = make_ctx(&env, &empty_steps, &None);
1527
1528 assert!(evaluate_as_bool("startsWith(github.ref, 'refs/tags/')", &ctx).unwrap());
1529 }
1530
1531 #[test]
1534 fn output_string_formatting() {
1535 assert_eq!(ExprValue::String("hi".to_string()).to_output_string(), "hi");
1536 assert_eq!(ExprValue::Number(42.0).to_output_string(), "42");
1537 assert_eq!(ExprValue::Number(3.15).to_output_string(), "3.15");
1538 assert_eq!(ExprValue::Bool(true).to_output_string(), "true");
1539 assert_eq!(ExprValue::Null.to_output_string(), "");
1540 }
1541
1542 #[test]
1545 fn eval_unterminated_string_errors() {
1546 let ctx = empty_ctx();
1547 assert!(evaluate("'unterminated", &ctx).is_err());
1548 }
1549
1550 #[test]
1551 fn eval_unexpected_token_errors() {
1552 let ctx = empty_ctx();
1553 assert!(evaluate("&&", &ctx).is_err());
1554 }
1555
1556 #[test]
1557 fn eval_empty_expression() {
1558 let ctx = empty_ctx();
1559 assert_eq!(evaluate("", &ctx).unwrap(), ExprValue::Null);
1560 }
1561
1562 #[test]
1563 fn unknown_step_id_returns_null() {
1564 let ctx = empty_ctx();
1565 assert_eq!(
1566 evaluate("steps.nonexistent.outcome", &ctx).unwrap(),
1567 ExprValue::Null
1568 );
1569 assert_eq!(
1570 evaluate("steps.nonexistent.conclusion", &ctx).unwrap(),
1571 ExprValue::Null
1572 );
1573 }
1574
1575 #[test]
1576 fn tojson_escapes_control_characters() {
1577 let ctx = empty_ctx();
1578 let result = evaluate("toJSON('line1\tindented\nline2\rend')", &ctx).unwrap();
1580 let s = result.to_output_string();
1581 assert!(s.contains("\\t"), "should escape tab: {}", s);
1582 assert!(s.contains("\\n"), "should escape newline: {}", s);
1583 assert!(s.contains("\\r"), "should escape carriage return: {}", s);
1584 }
1585
1586 #[test]
1587 fn tojson_escapes_quotes_and_backslash() {
1588 let ctx = empty_ctx();
1589 let result = evaluate(r#"toJSON('say "hello\world"')"#, &ctx).unwrap();
1590 let s = result.to_output_string();
1591 assert!(s.contains(r#"\""#), "should escape quotes: {}", s);
1592 assert!(s.contains(r"\\"), "should escape backslash: {}", s);
1593 }
1594
1595 #[test]
1596 fn tojson_handles_null_bytes() {
1597 let ctx = empty_ctx();
1598 let result = evaluate("toJSON('before\x00after')", &ctx).unwrap();
1600 let s = result.to_output_string();
1601 assert!(!s.contains('\0'), "should not contain raw null: {}", s);
1602 }
1603
1604 #[test]
1605 fn tojson_env_returns_object() {
1606 let mut user_env = HashMap::new();
1609 user_env.insert("MY_VAR".to_string(), "hello".to_string());
1610 user_env.insert("OTHER".to_string(), "world".to_string());
1611 let mut env_context = user_env.clone();
1612 env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1613 env_context.insert("RUNNER_OS".to_string(), "Linux".to_string());
1614 env_context.insert("INPUT_NAME".to_string(), "test".to_string());
1615 env_context.insert("CI".to_string(), "true".to_string());
1616 let ctx = ExpressionContext {
1617 env_context: &env_context,
1618 user_env: &user_env,
1619 step_outputs: &EMPTY_STEPS,
1620 matrix_combination: &EMPTY_MATRIX,
1621 step_statuses: &EMPTY_STATUSES,
1622 job_status: "success",
1623 secrets_context: &EMPTY_SECRETS,
1624 needs_context: &EMPTY_NEEDS,
1625 needs_results: &EMPTY_NEEDS_RESULTS,
1626 };
1627 let result = evaluate("toJSON(env)", &ctx).unwrap();
1628 let s = result.to_output_string();
1629 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1630 let obj = parsed.as_object().expect("should be a JSON object");
1631 assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
1632 assert_eq!(obj.get("OTHER").unwrap(), "world");
1633 assert!(
1634 obj.get("GITHUB_SHA").is_none(),
1635 "runner-seeded GITHUB_SHA should not leak into toJSON(env)"
1636 );
1637 assert!(
1638 obj.get("RUNNER_OS").is_none(),
1639 "runner-seeded RUNNER_OS should not leak into toJSON(env)"
1640 );
1641 assert!(
1642 obj.get("INPUT_NAME").is_none(),
1643 "runner-seeded INPUT_NAME should not leak into toJSON(env)"
1644 );
1645 assert!(
1646 obj.get("CI").is_none(),
1647 "runner-seeded CI should not leak into toJSON(env)"
1648 );
1649 }
1650
1651 #[test]
1652 fn tojson_env_sorted_keys() {
1653 let mut env = HashMap::new();
1654 env.insert("ZEBRA".to_string(), "z".to_string());
1655 env.insert("APPLE".to_string(), "a".to_string());
1656 env.insert("MANGO".to_string(), "m".to_string());
1657 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1658 let result = evaluate("toJSON(env)", &ctx).unwrap();
1659 let s = result.to_output_string();
1660 let apple_pos = s.find("APPLE").unwrap();
1662 let mango_pos = s.find("MANGO").unwrap();
1663 let zebra_pos = s.find("ZEBRA").unwrap();
1664 assert!(apple_pos < mango_pos, "APPLE should come before MANGO");
1665 assert!(mango_pos < zebra_pos, "MANGO should come before ZEBRA");
1666 }
1667
1668 #[test]
1669 fn bare_env_is_truthy() {
1670 let mut env = HashMap::new();
1671 env.insert("FOO".to_string(), "bar".to_string());
1672 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1673 let result = evaluate("env", &ctx).unwrap();
1675 assert!(result.is_truthy());
1676 }
1677
1678 #[test]
1679 fn bare_env_to_output_string() {
1680 let mut env = HashMap::new();
1681 env.insert("FOO".to_string(), "bar".to_string());
1682 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1683 let result = evaluate("env", &ctx).unwrap();
1684 let s = result.to_output_string();
1686 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1687 let obj = parsed.as_object().expect("should be a JSON object");
1688 assert_eq!(obj.get("FOO").unwrap(), "bar");
1689 }
1690
1691 #[test]
1692 fn tojson_env_empty_when_only_internal_vars() {
1693 let mut env_context = HashMap::new();
1695 env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1696 env_context.insert("RUNNER_OS".to_string(), "Linux".to_string());
1697 env_context.insert("CI".to_string(), "true".to_string());
1698 let ctx = ExpressionContext {
1699 env_context: &env_context,
1700 user_env: &EMPTY_USER_ENV,
1701 step_outputs: &EMPTY_STEPS,
1702 matrix_combination: &EMPTY_MATRIX,
1703 step_statuses: &EMPTY_STATUSES,
1704 job_status: "success",
1705 secrets_context: &EMPTY_SECRETS,
1706 needs_context: &EMPTY_NEEDS,
1707 needs_results: &EMPTY_NEEDS_RESULTS,
1708 };
1709 let result = evaluate("toJSON(env)", &ctx).unwrap();
1710 let s = result.to_output_string();
1711 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1712 let obj = parsed.as_object().expect("should be a JSON object");
1713 assert!(
1714 obj.is_empty(),
1715 "should be empty when no user env is declared: {}",
1716 s
1717 );
1718 }
1719
1720 #[test]
1721 fn tojson_env_includes_user_var_with_internal_prefix() {
1722 let mut user_env = HashMap::new();
1726 user_env.insert("GITHUB_CUSTOM".to_string(), "user-val".to_string());
1727 user_env.insert("MY_VAR".to_string(), "hello".to_string());
1728 let env_context = user_env.clone();
1730 let ctx = ExpressionContext {
1731 env_context: &env_context,
1732 user_env: &user_env,
1733 step_outputs: &EMPTY_STEPS,
1734 matrix_combination: &EMPTY_MATRIX,
1735 step_statuses: &EMPTY_STATUSES,
1736 job_status: "success",
1737 secrets_context: &EMPTY_SECRETS,
1738 needs_context: &EMPTY_NEEDS,
1739 needs_results: &EMPTY_NEEDS_RESULTS,
1740 };
1741 let result = evaluate("toJSON(env)", &ctx).unwrap();
1742 let s = result.to_output_string();
1743 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1744 let obj = parsed.as_object().expect("should be a JSON object");
1745 assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
1746 assert_eq!(
1747 obj.get("GITHUB_CUSTOM").unwrap(),
1748 "user-val",
1749 "user-declared var with GITHUB_ prefix must appear in toJSON(env)"
1750 );
1751 }
1752
1753 #[test]
1754 fn tojson_env_includes_user_declared_github_token() {
1755 let mut user_env = HashMap::new();
1758 user_env.insert("GITHUB_TOKEN".to_string(), "ghp_user_supplied".to_string());
1759 let mut env_context = user_env.clone();
1760 env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1762 let ctx = ExpressionContext {
1763 env_context: &env_context,
1764 user_env: &user_env,
1765 step_outputs: &EMPTY_STEPS,
1766 matrix_combination: &EMPTY_MATRIX,
1767 step_statuses: &EMPTY_STATUSES,
1768 job_status: "success",
1769 secrets_context: &EMPTY_SECRETS,
1770 needs_context: &EMPTY_NEEDS,
1771 needs_results: &EMPTY_NEEDS_RESULTS,
1772 };
1773 let result = evaluate("toJSON(env)", &ctx).unwrap();
1774 let s = result.to_output_string();
1775 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1776 let obj = parsed.as_object().expect("should be a JSON object");
1777 assert_eq!(obj.get("GITHUB_TOKEN").unwrap(), "ghp_user_supplied");
1778 assert!(
1779 obj.get("GITHUB_SHA").is_none(),
1780 "process-inherited GITHUB_SHA must not appear in toJSON(env)"
1781 );
1782 }
1783
1784 #[test]
1785 fn tojson_env_empty_context() {
1786 let env = HashMap::new();
1787 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1788 let result = evaluate("toJSON(env)", &ctx).unwrap();
1789 let s = result.to_output_string();
1790 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1791 let obj = parsed.as_object().expect("should be a JSON object");
1792 assert!(obj.is_empty(), "should be empty with no env vars: {}", s);
1793 }
1794
1795 #[test]
1796 fn fromjson_tojson_env_produces_parseable_json() {
1797 let mut env = HashMap::new();
1801 env.insert("MY_VAR".to_string(), "hello".to_string());
1802 env.insert("OTHER".to_string(), "world".to_string());
1803 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1804 let result = evaluate("fromJSON(toJSON(env))", &ctx).unwrap();
1805 let s = result.to_output_string();
1806 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1807 let obj = parsed.as_object().expect("should be a JSON object");
1808 assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
1809 assert_eq!(obj.get("OTHER").unwrap(), "world");
1810 }
1811
1812 #[test]
1813 fn tojson_env_special_characters_in_values() {
1814 let mut env = HashMap::new();
1815 env.insert("QUOTED".to_string(), "he said \"hi\"".to_string());
1816 env.insert("NEWLINE".to_string(), "line1\nline2".to_string());
1817 env.insert("UNICODE".to_string(), "\u{1F600}".to_string());
1818 env.insert("BACKSLASH".to_string(), "path\\to\\file".to_string());
1819 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1820 let result = evaluate("toJSON(env)", &ctx).unwrap();
1821 let s = result.to_output_string();
1822 let parsed: serde_json::Value =
1823 serde_json::from_str(&s).expect("should be valid JSON despite special chars");
1824 let obj = parsed.as_object().expect("should be a JSON object");
1825 assert_eq!(obj.get("QUOTED").unwrap(), "he said \"hi\"");
1826 assert_eq!(obj.get("NEWLINE").unwrap(), "line1\nline2");
1827 assert_eq!(obj.get("UNICODE").unwrap(), "\u{1F600}");
1828 assert_eq!(obj.get("BACKSLASH").unwrap(), "path\\to\\file");
1829 }
1830
1831 #[test]
1834 fn tojson_github_returns_object() {
1835 let mut env = HashMap::new();
1836 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1837 env.insert("GITHUB_REF".to_string(), "refs/heads/main".to_string());
1838 env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
1839 env.insert("MY_VAR".to_string(), "hello".to_string());
1841 env.insert("RUNNER_OS".to_string(), "Linux".to_string());
1842 env.insert("INPUT_NAME".to_string(), "test".to_string());
1843 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1844 let result = evaluate("toJSON(github)", &ctx).unwrap();
1845 let s = result.to_output_string();
1846 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1847 let obj = parsed.as_object().expect("should be a JSON object");
1848 assert_eq!(obj.get("sha").unwrap(), "abc123");
1849 assert_eq!(obj.get("ref").unwrap(), "refs/heads/main");
1850 assert_eq!(obj.get("repository").unwrap(), "owner/repo");
1851 assert!(
1852 obj.get("MY_VAR").is_none(),
1853 "should exclude non-GITHUB vars"
1854 );
1855 assert!(
1856 obj.get("RUNNER_OS").is_none(),
1857 "should exclude RUNNER_ vars"
1858 );
1859 assert!(
1860 obj.get("INPUT_NAME").is_none(),
1861 "should exclude INPUT_ vars"
1862 );
1863 }
1864
1865 #[test]
1866 fn tojson_github_empty_context() {
1867 let env = HashMap::new();
1868 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1869 let result = evaluate("toJSON(github)", &ctx).unwrap();
1870 let s = result.to_output_string();
1871 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1872 let obj = parsed.as_object().expect("should be a JSON object");
1873 assert!(obj.is_empty(), "should be empty with no env vars: {}", s);
1874 }
1875
1876 #[test]
1877 fn tojson_github_no_github_prefix() {
1878 let mut env = HashMap::new();
1879 env.insert("MY_VAR".to_string(), "hello".to_string());
1880 env.insert("CI".to_string(), "true".to_string());
1881 env.insert("RUNNER_OS".to_string(), "Linux".to_string());
1882 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1883 let result = evaluate("toJSON(github)", &ctx).unwrap();
1884 let s = result.to_output_string();
1885 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1886 let obj = parsed.as_object().expect("should be a JSON object");
1887 assert!(
1888 obj.is_empty(),
1889 "should be empty when no GITHUB_* vars exist: {}",
1890 s
1891 );
1892 }
1893
1894 #[test]
1895 fn tojson_github_sorted_keys() {
1896 let mut env = HashMap::new();
1897 env.insert("GITHUB_ZEBRA".to_string(), "z".to_string());
1898 env.insert("GITHUB_APPLE".to_string(), "a".to_string());
1899 env.insert("GITHUB_MANGO".to_string(), "m".to_string());
1900 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1901 let result = evaluate("toJSON(github)", &ctx).unwrap();
1902 let s = result.to_output_string();
1903 let apple_pos = s.find("apple").unwrap();
1905 let mango_pos = s.find("mango").unwrap();
1906 let zebra_pos = s.find("zebra").unwrap();
1907 assert!(apple_pos < mango_pos, "apple should come before mango");
1908 assert!(mango_pos < zebra_pos, "mango should come before zebra");
1909 }
1910
1911 #[test]
1912 fn bare_github_is_truthy() {
1913 let mut env = HashMap::new();
1914 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1915 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1916 let result = evaluate("github", &ctx).unwrap();
1918 assert!(result.is_truthy());
1919 }
1920
1921 #[test]
1922 fn tojson_github_preserves_dotted_access() {
1923 let mut env = HashMap::new();
1926 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1927 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1928
1929 let dotted = evaluate("github.sha", &ctx).unwrap();
1931 assert_eq!(dotted.to_output_string(), "abc123");
1932
1933 let bare = evaluate("toJSON(github)", &ctx).unwrap();
1935 let s = bare.to_output_string();
1936 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1937 assert_eq!(parsed.get("sha").unwrap(), "abc123");
1938 }
1939
1940 #[test]
1941 fn tojson_github_special_characters_in_values() {
1942 let mut env = HashMap::new();
1943 env.insert(
1944 "GITHUB_EVENT_HEAD_COMMIT_MESSAGE".to_string(),
1945 "he said \"hi\"\nnew line".to_string(),
1946 );
1947 env.insert("GITHUB_WORKSPACE".to_string(), "C:\\Users\\dev".to_string());
1948 env.insert("GITHUB_ACTOR".to_string(), "\u{1F600}".to_string());
1949 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1950 let result = evaluate("toJSON(github)", &ctx).unwrap();
1951 let s = result.to_output_string();
1952 let parsed: serde_json::Value =
1953 serde_json::from_str(&s).expect("should be valid JSON despite special chars");
1954 let obj = parsed.as_object().expect("should be a JSON object");
1955 assert_eq!(
1956 obj.get("event_head_commit_message").unwrap(),
1957 "he said \"hi\"\nnew line"
1958 );
1959 assert_eq!(obj.get("workspace").unwrap(), "C:\\Users\\dev");
1960 assert_eq!(obj.get("actor").unwrap(), "\u{1F600}");
1961 }
1962
1963 #[test]
1964 fn fromjson_tojson_github_produces_parseable_json() {
1965 let mut env = HashMap::new();
1966 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1967 env.insert("GITHUB_REF".to_string(), "refs/heads/main".to_string());
1968 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1969 let result = evaluate("fromJSON(toJSON(github))", &ctx).unwrap();
1970 let s = result.to_output_string();
1971 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1972 let obj = parsed.as_object().expect("should be a JSON object");
1973 assert_eq!(obj.get("sha").unwrap(), "abc123");
1974 assert_eq!(obj.get("ref").unwrap(), "refs/heads/main");
1975 }
1976
1977 #[test]
1978 fn tojson_github_includes_token_in_plaintext() {
1979 let mut env = HashMap::new();
1984 env.insert("GITHUB_TOKEN".to_string(), "ghs_secret".to_string());
1985 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1986 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1987 let result = evaluate("toJSON(github)", &ctx).unwrap();
1988 let s = result.to_output_string();
1989 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1990 let obj = parsed.as_object().expect("should be a JSON object");
1991 assert_eq!(obj.get("token").unwrap(), "ghs_secret");
1992 assert_eq!(obj.get("sha").unwrap(), "abc123");
1993 }
1994
1995 #[test]
1996 fn tojson_github_ignores_prefix_only_key() {
1997 let mut env = HashMap::new();
2000 env.insert("GITHUB_".to_string(), "weird".to_string());
2001 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
2002 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2003 let result = evaluate("toJSON(github)", &ctx).unwrap();
2004 let s = result.to_output_string();
2005 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2006 let obj = parsed.as_object().expect("should be a JSON object");
2007 assert!(obj.get("").is_none(), "empty-key entry must not appear");
2008 assert_eq!(obj.get("sha").unwrap(), "abc123");
2009 assert_eq!(obj.len(), 1);
2010 }
2011
2012 #[test]
2013 fn tojson_github_excludes_runner_internal_keys() {
2014 let mut env = HashMap::new();
2023 env.insert("GITHUB_OUTPUT".to_string(), "/tmp/out".to_string());
2024 env.insert("GITHUB_ENV".to_string(), "/tmp/env".to_string());
2025 env.insert("GITHUB_PATH".to_string(), "/tmp/path".to_string());
2026 env.insert(
2027 "GITHUB_STEP_SUMMARY".to_string(),
2028 "/tmp/summary".to_string(),
2029 );
2030 env.insert("GITHUB_ACTIONS".to_string(), "true".to_string());
2031 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
2032 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2033 let result = evaluate("toJSON(github)", &ctx).unwrap();
2034 let s = result.to_output_string();
2035 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2036 let obj = parsed.as_object().expect("should be a JSON object");
2037 assert!(obj.get("output").is_none(), "should exclude GITHUB_OUTPUT");
2038 assert!(obj.get("env").is_none(), "should exclude GITHUB_ENV");
2039 assert!(obj.get("path").is_none(), "should exclude GITHUB_PATH");
2040 assert!(
2041 obj.get("step_summary").is_none(),
2042 "should exclude GITHUB_STEP_SUMMARY"
2043 );
2044 assert!(
2045 obj.get("actions").is_none(),
2046 "should exclude GITHUB_ACTIONS (not a github-context property)"
2047 );
2048 assert_eq!(obj.get("sha").unwrap(), "abc123");
2049 }
2050
2051 #[test]
2052 fn tojson_github_includes_user_defined_github_prefixed_vars() {
2053 let mut env = HashMap::new();
2058 env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
2059 env.insert("GITHUB_FOO".to_string(), "bar".to_string());
2060 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2061 let result = evaluate("toJSON(github)", &ctx).unwrap();
2062 let s = result.to_output_string();
2063 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2064 let obj = parsed.as_object().expect("should be a JSON object");
2065 assert_eq!(obj.get("foo").unwrap(), "bar");
2066 assert_eq!(obj.get("sha").unwrap(), "abc123");
2067 }
2068
2069 fn make_steps_ctx<'a>(
2073 step_outputs: &'a HashMap<String, HashMap<String, String>>,
2074 step_statuses: &'a HashMap<String, (String, String)>,
2075 ) -> ExpressionContext<'a> {
2076 ExpressionContext {
2077 env_context: &EMPTY_ENV,
2078 user_env: &EMPTY_USER_ENV,
2079 step_outputs,
2080 matrix_combination: &EMPTY_MATRIX,
2081 step_statuses,
2082 job_status: "success",
2083 secrets_context: &EMPTY_SECRETS,
2084 needs_context: &EMPTY_NEEDS,
2085 needs_results: &EMPTY_NEEDS_RESULTS,
2086 }
2087 }
2088
2089 #[test]
2090 fn tojson_steps_returns_nested_object() {
2091 let mut outputs = HashMap::new();
2092 let mut build_out = HashMap::new();
2093 build_out.insert("artifact".to_string(), "app.zip".to_string());
2094 build_out.insert("version".to_string(), "1.2.3".to_string());
2095 outputs.insert("build".to_string(), build_out);
2096
2097 let mut test_out = HashMap::new();
2098 test_out.insert("passed".to_string(), "true".to_string());
2099 outputs.insert("test".to_string(), test_out);
2100
2101 let mut statuses = HashMap::new();
2102 statuses.insert(
2103 "build".to_string(),
2104 ("success".to_string(), "success".to_string()),
2105 );
2106 statuses.insert(
2107 "test".to_string(),
2108 ("failure".to_string(), "failure".to_string()),
2109 );
2110
2111 let ctx = make_steps_ctx(&outputs, &statuses);
2112 let result = evaluate("toJSON(steps)", &ctx).unwrap();
2113 let s = result.to_output_string();
2114 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2115 let obj = parsed.as_object().expect("should be a JSON object");
2116
2117 let build = obj.get("build").unwrap().as_object().unwrap();
2119 assert_eq!(build.get("outcome").unwrap(), "success");
2120 assert_eq!(build.get("conclusion").unwrap(), "success");
2121 let build_outputs = build.get("outputs").unwrap().as_object().unwrap();
2122 assert_eq!(build_outputs.get("artifact").unwrap(), "app.zip");
2123 assert_eq!(build_outputs.get("version").unwrap(), "1.2.3");
2124
2125 let test = obj.get("test").unwrap().as_object().unwrap();
2127 assert_eq!(test.get("outcome").unwrap(), "failure");
2128 assert_eq!(test.get("conclusion").unwrap(), "failure");
2129 let test_outputs = test.get("outputs").unwrap().as_object().unwrap();
2130 assert_eq!(test_outputs.get("passed").unwrap(), "true");
2131 }
2132
2133 #[test]
2134 fn tojson_steps_empty_context() {
2135 let outputs = HashMap::new();
2136 let statuses = HashMap::new();
2137 let ctx = make_steps_ctx(&outputs, &statuses);
2138 let result = evaluate("toJSON(steps)", &ctx).unwrap();
2139 let s = result.to_output_string();
2140 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2141 let obj = parsed.as_object().expect("should be a JSON object");
2142 assert!(obj.is_empty(), "should be empty with no steps: {}", s);
2143 }
2144
2145 #[test]
2146 fn tojson_steps_sorted_keys() {
2147 let mut statuses = HashMap::new();
2148 statuses.insert(
2149 "zebra".to_string(),
2150 ("success".to_string(), "success".to_string()),
2151 );
2152 statuses.insert(
2153 "alpha".to_string(),
2154 ("success".to_string(), "success".to_string()),
2155 );
2156 statuses.insert(
2157 "middle".to_string(),
2158 ("success".to_string(), "success".to_string()),
2159 );
2160 let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
2161 let result = evaluate("toJSON(steps)", &ctx).unwrap();
2162 let s = result.to_output_string();
2163 let alpha_pos = s.find("alpha").unwrap();
2164 let middle_pos = s.find("middle").unwrap();
2165 let zebra_pos = s.find("zebra").unwrap();
2166 assert!(alpha_pos < middle_pos, "alpha should come before middle");
2167 assert!(middle_pos < zebra_pos, "middle should come before zebra");
2168 }
2169
2170 #[test]
2171 fn tojson_steps_status_without_outputs() {
2172 let mut statuses = HashMap::new();
2173 statuses.insert(
2174 "checkout".to_string(),
2175 ("success".to_string(), "success".to_string()),
2176 );
2177 let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
2178 let result = evaluate("toJSON(steps)", &ctx).unwrap();
2179 let s = result.to_output_string();
2180 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2181 let checkout = parsed.get("checkout").unwrap().as_object().unwrap();
2182 assert_eq!(checkout.get("outcome").unwrap(), "success");
2183 assert_eq!(checkout.get("conclusion").unwrap(), "success");
2184 let outputs = checkout.get("outputs").unwrap().as_object().unwrap();
2185 assert!(outputs.is_empty(), "outputs should be empty: {:?}", outputs);
2186 }
2187
2188 #[test]
2189 fn tojson_steps_outputs_without_status() {
2190 let mut outputs = HashMap::new();
2192 let mut step_out = HashMap::new();
2193 step_out.insert("result".to_string(), "42".to_string());
2194 outputs.insert("compute".to_string(), step_out);
2195 let statuses = HashMap::new();
2196 let ctx = make_steps_ctx(&outputs, &statuses);
2197 let result = evaluate("toJSON(steps)", &ctx).unwrap();
2198 let s = result.to_output_string();
2199 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2200 let compute = parsed.get("compute").unwrap().as_object().unwrap();
2201 assert!(compute.get("outcome").is_none(), "should have no outcome");
2203 assert!(
2204 compute.get("conclusion").is_none(),
2205 "should have no conclusion"
2206 );
2207 let out = compute.get("outputs").unwrap().as_object().unwrap();
2208 assert_eq!(out.get("result").unwrap(), "42");
2209 }
2210
2211 #[test]
2212 fn bare_steps_is_truthy() {
2213 let mut statuses = HashMap::new();
2214 statuses.insert(
2215 "build".to_string(),
2216 ("success".to_string(), "success".to_string()),
2217 );
2218 let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
2219 let result = evaluate("steps", &ctx).unwrap();
2220 assert!(result.is_truthy());
2221 }
2222
2223 #[test]
2224 fn tojson_steps_special_characters_in_outputs() {
2225 let mut outputs = HashMap::new();
2226 let mut step_out = HashMap::new();
2227 step_out.insert("msg".to_string(), "he said \"hi\"".to_string());
2228 step_out.insert("path".to_string(), "C:\\Users\\dev".to_string());
2229 step_out.insert("emoji".to_string(), "\u{1F680}".to_string());
2230 outputs.insert("deploy".to_string(), step_out);
2231 let mut statuses = HashMap::new();
2232 statuses.insert(
2233 "deploy".to_string(),
2234 ("success".to_string(), "success".to_string()),
2235 );
2236 let ctx = make_steps_ctx(&outputs, &statuses);
2237 let result = evaluate("toJSON(steps)", &ctx).unwrap();
2238 let s = result.to_output_string();
2239 let parsed: serde_json::Value =
2240 serde_json::from_str(&s).expect("should be valid JSON despite special chars");
2241 let deploy_outputs = parsed
2242 .get("deploy")
2243 .unwrap()
2244 .get("outputs")
2245 .unwrap()
2246 .as_object()
2247 .unwrap();
2248 assert_eq!(deploy_outputs.get("msg").unwrap(), "he said \"hi\"");
2249 assert_eq!(deploy_outputs.get("path").unwrap(), "C:\\Users\\dev");
2250 assert_eq!(deploy_outputs.get("emoji").unwrap(), "\u{1F680}");
2251 }
2252
2253 fn make_needs_ctx<'a>(
2257 needs_context: &'a HashMap<String, HashMap<String, String>>,
2258 needs_results: &'a HashMap<String, String>,
2259 ) -> ExpressionContext<'a> {
2260 ExpressionContext {
2261 env_context: &EMPTY_ENV,
2262 user_env: &EMPTY_USER_ENV,
2263 step_outputs: &EMPTY_STEPS,
2264 matrix_combination: &EMPTY_MATRIX,
2265 step_statuses: &EMPTY_STATUSES,
2266 job_status: "success",
2267 secrets_context: &EMPTY_SECRETS,
2268 needs_context,
2269 needs_results,
2270 }
2271 }
2272
2273 #[test]
2274 fn tojson_needs_returns_nested_object() {
2275 let mut needs_ctx = HashMap::new();
2276 let mut build_out = HashMap::new();
2277 build_out.insert("artifact".to_string(), "app.zip".to_string());
2278 build_out.insert("version".to_string(), "1.2.3".to_string());
2279 needs_ctx.insert("build".to_string(), build_out);
2280
2281 let mut test_out = HashMap::new();
2282 test_out.insert("passed".to_string(), "true".to_string());
2283 needs_ctx.insert("test".to_string(), test_out);
2284
2285 let mut needs_res = HashMap::new();
2286 needs_res.insert("build".to_string(), "success".to_string());
2287 needs_res.insert("test".to_string(), "failure".to_string());
2288
2289 let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2290 let result = evaluate("toJSON(needs)", &ctx).unwrap();
2291 let s = result.to_output_string();
2292 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2293 let obj = parsed.as_object().expect("should be a JSON object");
2294
2295 let build = obj.get("build").unwrap().as_object().unwrap();
2297 assert_eq!(build.get("result").unwrap(), "success");
2298 let build_outputs = build.get("outputs").unwrap().as_object().unwrap();
2299 assert_eq!(build_outputs.get("artifact").unwrap(), "app.zip");
2300 assert_eq!(build_outputs.get("version").unwrap(), "1.2.3");
2301
2302 let test = obj.get("test").unwrap().as_object().unwrap();
2304 assert_eq!(test.get("result").unwrap(), "failure");
2305 let test_outputs = test.get("outputs").unwrap().as_object().unwrap();
2306 assert_eq!(test_outputs.get("passed").unwrap(), "true");
2307 }
2308
2309 #[test]
2310 fn tojson_needs_empty_context() {
2311 let needs_ctx = HashMap::new();
2312 let needs_res = HashMap::new();
2313 let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2314 let result = evaluate("toJSON(needs)", &ctx).unwrap();
2315 let s = result.to_output_string();
2316 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2317 let obj = parsed.as_object().expect("should be a JSON object");
2318 assert!(obj.is_empty(), "should be empty with no needs: {}", s);
2319 }
2320
2321 #[test]
2322 fn tojson_needs_sorted_keys() {
2323 let mut needs_res = HashMap::new();
2324 needs_res.insert("zebra".to_string(), "success".to_string());
2325 needs_res.insert("alpha".to_string(), "success".to_string());
2326 needs_res.insert("middle".to_string(), "success".to_string());
2327 let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
2328 let result = evaluate("toJSON(needs)", &ctx).unwrap();
2329 let s = result.to_output_string();
2330 let alpha_pos = s.find("alpha").unwrap();
2331 let middle_pos = s.find("middle").unwrap();
2332 let zebra_pos = s.find("zebra").unwrap();
2333 assert!(alpha_pos < middle_pos, "alpha should come before middle");
2334 assert!(middle_pos < zebra_pos, "middle should come before zebra");
2335 }
2336
2337 #[test]
2338 fn tojson_needs_result_without_outputs() {
2339 let mut needs_res = HashMap::new();
2340 needs_res.insert("lint".to_string(), "success".to_string());
2341 let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
2342 let result = evaluate("toJSON(needs)", &ctx).unwrap();
2343 let s = result.to_output_string();
2344 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2345 let lint = parsed.get("lint").unwrap().as_object().unwrap();
2346 assert_eq!(lint.get("result").unwrap(), "success");
2347 let outputs = lint.get("outputs").unwrap().as_object().unwrap();
2348 assert!(outputs.is_empty(), "outputs should be empty: {:?}", outputs);
2349 }
2350
2351 #[test]
2352 fn tojson_needs_outputs_without_result() {
2353 let mut needs_ctx = HashMap::new();
2355 let mut job_out = HashMap::new();
2356 job_out.insert("value".to_string(), "42".to_string());
2357 needs_ctx.insert("compute".to_string(), job_out);
2358 let needs_res = HashMap::new();
2359 let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2360 let result = evaluate("toJSON(needs)", &ctx).unwrap();
2361 let s = result.to_output_string();
2362 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2363 let compute = parsed.get("compute").unwrap().as_object().unwrap();
2364 assert!(compute.get("result").is_none(), "should have no result");
2365 let out = compute.get("outputs").unwrap().as_object().unwrap();
2366 assert_eq!(out.get("value").unwrap(), "42");
2367 }
2368
2369 #[test]
2370 fn bare_needs_is_truthy() {
2371 let mut needs_res = HashMap::new();
2372 needs_res.insert("build".to_string(), "success".to_string());
2373 let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
2374 let result = evaluate("needs", &ctx).unwrap();
2375 assert!(result.is_truthy());
2376 }
2377
2378 #[test]
2379 fn tojson_needs_special_characters_in_outputs() {
2380 let mut needs_ctx = HashMap::new();
2381 let mut job_out = HashMap::new();
2382 job_out.insert("msg".to_string(), "he said \"hi\"".to_string());
2383 job_out.insert("path".to_string(), "C:\\Users\\dev".to_string());
2384 job_out.insert("emoji".to_string(), "\u{1F680}".to_string());
2385 needs_ctx.insert("deploy".to_string(), job_out);
2386 let mut needs_res = HashMap::new();
2387 needs_res.insert("deploy".to_string(), "success".to_string());
2388 let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2389 let result = evaluate("toJSON(needs)", &ctx).unwrap();
2390 let s = result.to_output_string();
2391 let parsed: serde_json::Value =
2392 serde_json::from_str(&s).expect("should be valid JSON despite special chars");
2393 let deploy_outputs = parsed
2394 .get("deploy")
2395 .unwrap()
2396 .get("outputs")
2397 .unwrap()
2398 .as_object()
2399 .unwrap();
2400 assert_eq!(deploy_outputs.get("msg").unwrap(), "he said \"hi\"");
2401 assert_eq!(deploy_outputs.get("path").unwrap(), "C:\\Users\\dev");
2402 assert_eq!(deploy_outputs.get("emoji").unwrap(), "\u{1F680}");
2403 }
2404
2405 fn make_secrets_ctx(secrets: &HashMap<String, String>) -> ExpressionContext<'_> {
2409 ExpressionContext {
2410 env_context: &EMPTY_ENV,
2411 user_env: &EMPTY_USER_ENV,
2412 step_outputs: &EMPTY_STEPS,
2413 matrix_combination: &EMPTY_MATRIX,
2414 step_statuses: &EMPTY_STATUSES,
2415 job_status: "success",
2416 secrets_context: secrets,
2417 needs_context: &EMPTY_NEEDS,
2418 needs_results: &EMPTY_NEEDS_RESULTS,
2419 }
2420 }
2421
2422 #[test]
2423 fn tojson_secrets_returns_object() {
2424 let mut secrets = HashMap::new();
2425 secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
2426 secrets.insert("DEPLOY_KEY".to_string(), "xyz".to_string());
2427 let ctx = make_secrets_ctx(&secrets);
2428 let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2429 let s = result.to_output_string();
2430 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2431 let obj = parsed.as_object().expect("should be a JSON object");
2432 assert_eq!(obj.get("NPM_TOKEN").unwrap(), "abc");
2433 assert_eq!(obj.get("DEPLOY_KEY").unwrap(), "xyz");
2434 assert_eq!(obj.len(), 2);
2435 }
2436
2437 #[test]
2438 fn tojson_secrets_empty_context() {
2439 let secrets = HashMap::new();
2440 let ctx = make_secrets_ctx(&secrets);
2441 let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2442 let s = result.to_output_string();
2443 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2444 let obj = parsed.as_object().expect("should be a JSON object");
2445 assert!(obj.is_empty(), "should be empty with no secrets: {}", s);
2446 }
2447
2448 #[test]
2449 fn tojson_secrets_sorted_keys() {
2450 let mut secrets = HashMap::new();
2451 secrets.insert("ZEBRA".to_string(), "z".to_string());
2452 secrets.insert("APPLE".to_string(), "a".to_string());
2453 secrets.insert("MANGO".to_string(), "m".to_string());
2454 let ctx = make_secrets_ctx(&secrets);
2455 let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2456 let s = result.to_output_string();
2457 let apple_pos = s.find("APPLE").unwrap();
2458 let mango_pos = s.find("MANGO").unwrap();
2459 let zebra_pos = s.find("ZEBRA").unwrap();
2460 assert!(apple_pos < mango_pos, "APPLE should come before MANGO");
2461 assert!(mango_pos < zebra_pos, "MANGO should come before ZEBRA");
2462 }
2463
2464 #[test]
2465 fn tojson_secrets_preserves_special_characters() {
2466 let mut secrets = HashMap::new();
2467 secrets.insert("QUOTE".to_string(), "he said \"hi\"".to_string());
2468 secrets.insert("BACKSLASH".to_string(), "path\\to\\key".to_string());
2469 secrets.insert(
2470 "NEWLINE".to_string(),
2471 "-----BEGIN-----\nBODY\n-----END-----".to_string(),
2472 );
2473 let ctx = make_secrets_ctx(&secrets);
2474 let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2475 let s = result.to_output_string();
2476 let parsed: serde_json::Value =
2477 serde_json::from_str(&s).expect("should be valid JSON despite special chars");
2478 let obj = parsed.as_object().unwrap();
2479 assert_eq!(obj.get("QUOTE").unwrap(), "he said \"hi\"");
2480 assert_eq!(obj.get("BACKSLASH").unwrap(), "path\\to\\key");
2481 assert_eq!(
2482 obj.get("NEWLINE").unwrap(),
2483 "-----BEGIN-----\nBODY\n-----END-----"
2484 );
2485 }
2486
2487 #[test]
2488 fn fromjson_tojson_secrets_produces_parseable_json() {
2489 let mut secrets = HashMap::new();
2495 secrets.insert("NPM_TOKEN".to_string(), "npm_ABC123".to_string());
2496 secrets.insert("DEPLOY_KEY".to_string(), "deploy_XYZ".to_string());
2497 let ctx = make_secrets_ctx(&secrets);
2498 let result = evaluate("fromJSON(toJSON(secrets))", &ctx).unwrap();
2499 let s = result.to_output_string();
2500 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2501 let obj = parsed.as_object().expect("should be a JSON object");
2502 assert_eq!(obj.get("NPM_TOKEN").unwrap(), "npm_ABC123");
2503 assert_eq!(obj.get("DEPLOY_KEY").unwrap(), "deploy_XYZ");
2504 }
2505
2506 #[test]
2507 fn bare_secrets_is_truthy() {
2508 let mut secrets = HashMap::new();
2509 secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
2510 let ctx = make_secrets_ctx(&secrets);
2511 let result = evaluate("secrets", &ctx).unwrap();
2512 assert!(result.is_truthy());
2513 }
2514
2515 #[test]
2516 fn bare_secrets_does_not_shadow_dotted_access() {
2517 let mut secrets = HashMap::new();
2520 secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
2521 let ctx = make_secrets_ctx(&secrets);
2522 let result = evaluate("secrets.NPM_TOKEN", &ctx).unwrap();
2523 assert_eq!(result, ExprValue::String("abc".to_string()));
2524 }
2525
2526 #[test]
2527 fn tojson_secrets_returns_values_in_plaintext() {
2528 let mut secrets = HashMap::new();
2534 secrets.insert("GITHUB_TOKEN".to_string(), "ghs_supersecret".to_string());
2535 let ctx = make_secrets_ctx(&secrets);
2536 let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2537 let s = result.to_output_string();
2538 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2539 let obj = parsed.as_object().unwrap();
2540 assert_eq!(obj.get("GITHUB_TOKEN").unwrap(), "ghs_supersecret");
2541 }
2542
2543 #[test]
2546 fn tojson_matrix_returns_object() {
2547 let mut matrix = HashMap::new();
2548 matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
2549 matrix.insert("node".to_string(), Value::String("20".to_string()));
2550 let matrix = Some(matrix);
2551 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2552 let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2553 let s = result.to_output_string();
2554 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2555 let obj = parsed.as_object().expect("should be a JSON object");
2556 assert_eq!(obj.get("os").unwrap(), "ubuntu-latest");
2557 assert_eq!(obj.get("node").unwrap(), "20");
2558 assert_eq!(obj.len(), 2);
2559 }
2560
2561 #[test]
2562 fn tojson_matrix_no_matrix_returns_null() {
2563 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &EMPTY_MATRIX);
2566 let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2567 assert_eq!(result, ExprValue::String("null".to_string()));
2568 }
2569
2570 #[test]
2571 fn tojson_matrix_empty_combination() {
2572 let matrix: Option<HashMap<String, Value>> = Some(HashMap::new());
2575 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2576 let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2577 let s = result.to_output_string();
2578 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2579 let obj = parsed.as_object().expect("should be a JSON object");
2580 assert!(obj.is_empty(), "should be empty with zero keys: {}", s);
2581 }
2582
2583 #[test]
2584 fn tojson_matrix_mixed_value_types() {
2585 let mut matrix = HashMap::new();
2589 matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
2590 matrix.insert("node".to_string(), Value::Number(20.into()));
2591 matrix.insert("experimental".to_string(), Value::Bool(true));
2592 let matrix = Some(matrix);
2593 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2594 let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2595 let s = result.to_output_string();
2596 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2597 let obj = parsed.as_object().unwrap();
2598 assert_eq!(obj.get("os").unwrap(), "ubuntu");
2599 assert_eq!(obj.get("node").unwrap().as_f64().unwrap(), 20.0);
2600 assert!(obj.get("experimental").unwrap().as_bool().unwrap());
2601 }
2602
2603 #[test]
2604 fn tojson_matrix_sorted_keys() {
2605 let mut matrix = HashMap::new();
2606 matrix.insert("zebra".to_string(), Value::String("z".to_string()));
2607 matrix.insert("apple".to_string(), Value::String("a".to_string()));
2608 matrix.insert("mango".to_string(), Value::String("m".to_string()));
2609 let matrix = Some(matrix);
2610 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2611 let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2612 let s = result.to_output_string();
2613 let apple_pos = s.find("apple").unwrap();
2614 let mango_pos = s.find("mango").unwrap();
2615 let zebra_pos = s.find("zebra").unwrap();
2616 assert!(apple_pos < mango_pos, "apple should come before mango");
2617 assert!(mango_pos < zebra_pos, "mango should come before zebra");
2618 }
2619
2620 #[test]
2621 fn fromjson_tojson_matrix_produces_parseable_json() {
2622 let mut matrix = HashMap::new();
2623 matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
2624 matrix.insert("node".to_string(), Value::String("20".to_string()));
2625 let matrix = Some(matrix);
2626 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2627 let result = evaluate("fromJSON(toJSON(matrix))", &ctx).unwrap();
2628 let s = result.to_output_string();
2629 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2630 let obj = parsed.as_object().expect("should be a JSON object");
2631 assert_eq!(obj.get("os").unwrap(), "ubuntu-latest");
2632 assert_eq!(obj.get("node").unwrap(), "20");
2633 }
2634
2635 #[test]
2636 fn bare_matrix_is_truthy() {
2637 let mut matrix = HashMap::new();
2638 matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
2639 let matrix = Some(matrix);
2640 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2641 let result = evaluate("matrix", &ctx).unwrap();
2642 assert!(result.is_truthy());
2643 }
2644
2645 #[test]
2646 fn bare_matrix_when_none_is_null() {
2647 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &EMPTY_MATRIX);
2648 let result = evaluate("matrix", &ctx).unwrap();
2649 assert_eq!(result, ExprValue::Null);
2650 assert!(!result.is_truthy());
2651 }
2652
2653 #[test]
2654 fn bare_matrix_does_not_shadow_dotted_access() {
2655 let mut matrix = HashMap::new();
2658 matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
2659 let matrix = Some(matrix);
2660 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2661 let result = evaluate("matrix.os", &ctx).unwrap();
2662 assert_eq!(result, ExprValue::String("ubuntu-latest".to_string()));
2663 }
2664
2665 #[test]
2666 fn tojson_matrix_yaml_sequence_falls_through_to_string() {
2667 let mut matrix = HashMap::new();
2672 matrix.insert(
2673 "versions".to_string(),
2674 Value::Sequence(vec![
2675 Value::String("a".to_string()),
2676 Value::String("b".to_string()),
2677 ]),
2678 );
2679 let matrix = Some(matrix);
2680 let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2681 let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2682 let s = result.to_output_string();
2683 let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2684 let obj = parsed.as_object().expect("should be a JSON object");
2685 let versions = obj.get("versions").expect("versions key present");
2686 let rendered = versions.as_str().expect("rendered as JSON string");
2687 assert!(
2688 rendered.contains("- a") && rendered.contains("- b"),
2689 "expected YAML sequence rendering, got: {}",
2690 rendered
2691 );
2692 }
2693
2694 #[test]
2695 fn object_cmp_returns_none() {
2696 let mut env = HashMap::new();
2699 env.insert("FOO".to_string(), "bar".to_string());
2700 let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2701 let result = evaluate("env < env", &ctx).unwrap();
2703 assert!(!result.is_truthy(), "env < env should be false");
2704 let result = evaluate("env > env", &ctx).unwrap();
2705 assert!(!result.is_truthy(), "env > env should be false");
2706 let result = evaluate("env <= env", &ctx).unwrap();
2707 assert!(!result.is_truthy(), "env <= env should be false");
2708 let result = evaluate("env >= env", &ctx).unwrap();
2709 assert!(!result.is_truthy(), "env >= env should be false");
2710 }
2711}