use serde_yaml::Value;
use std::collections::{HashMap, HashSet};
use serde_json;
#[derive(Debug, Clone, PartialEq)]
pub enum ExprValue {
String(String),
Number(f64),
Bool(bool),
Null,
Object(HashMap<String, ExprValue>),
}
impl ExprValue {
pub fn is_truthy(&self) -> bool {
match self {
ExprValue::Bool(b) => *b,
ExprValue::Number(n) => *n != 0.0 && !n.is_nan(),
ExprValue::String(s) => !s.is_empty(),
ExprValue::Null => false,
ExprValue::Object(_) => true,
}
}
pub fn to_output_string(&self) -> String {
match self {
ExprValue::String(s) => s.clone(),
ExprValue::Number(n) => {
if n.is_finite() && *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{}", n)
}
}
ExprValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
ExprValue::Null => String::new(),
ExprValue::Object(map) => {
let sorted: std::collections::BTreeMap<&String, serde_json::Value> =
map.iter().map(|(k, v)| (k, expr_to_json(v))).collect();
serde_json::to_string_pretty(&sorted).unwrap_or_else(|_| "{}".to_string())
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Ident(String),
StringLit(String),
NumberLit(f64),
True,
False,
Null,
Dot,
LParen,
RParen,
Comma,
Eq, Ne, Lt, Le, Gt, Ge, And, Or, Not, Eof,
}
struct Tokenizer<'a> {
input: &'a str,
pos: usize, }
impl<'a> Tokenizer<'a> {
fn new(input: &'a str) -> Self {
Self { input, pos: 0 }
}
fn skip_whitespace(&mut self) {
let bytes = self.input.as_bytes();
while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
self.pos += 1;
}
}
fn tokenize(&mut self) -> Result<Vec<Token>, String> {
let mut tokens = Vec::new();
loop {
self.skip_whitespace();
if self.pos >= self.input.len() {
tokens.push(Token::Eof);
return Ok(tokens);
}
let bytes = self.input.as_bytes();
let ch = bytes[self.pos] as char;
match ch {
'.' => {
tokens.push(Token::Dot);
self.pos += 1;
}
'(' => {
tokens.push(Token::LParen);
self.pos += 1;
}
')' => {
tokens.push(Token::RParen);
self.pos += 1;
}
',' => {
tokens.push(Token::Comma);
self.pos += 1;
}
'=' => {
if self.peek_next_byte() == Some(b'=') {
tokens.push(Token::Eq);
self.pos += 2;
} else {
return Err(format!("unexpected '=' at position {}", self.pos));
}
}
'!' => {
if self.peek_next_byte() == Some(b'=') {
tokens.push(Token::Ne);
self.pos += 2;
} else {
tokens.push(Token::Not);
self.pos += 1;
}
}
'<' => {
if self.peek_next_byte() == Some(b'=') {
tokens.push(Token::Le);
self.pos += 2;
} else {
tokens.push(Token::Lt);
self.pos += 1;
}
}
'>' => {
if self.peek_next_byte() == Some(b'=') {
tokens.push(Token::Ge);
self.pos += 2;
} else {
tokens.push(Token::Gt);
self.pos += 1;
}
}
'&' => {
if self.peek_next_byte() == Some(b'&') {
tokens.push(Token::And);
self.pos += 2;
} else {
return Err(format!("unexpected '&' at position {}", self.pos));
}
}
'|' => {
if self.peek_next_byte() == Some(b'|') {
tokens.push(Token::Or);
self.pos += 2;
} else {
return Err(format!("unexpected '|' at position {}", self.pos));
}
}
'\'' => {
tokens.push(self.read_string()?);
}
c if c.is_ascii_digit() => {
tokens.push(self.read_number()?);
}
c if c.is_ascii_alphabetic() || c == '_' => {
let ident = self.read_ident();
tokens.push(match ident.as_str() {
"true" => Token::True,
"false" => Token::False,
"null" => Token::Null,
_ => Token::Ident(ident),
});
}
_ => {
let actual_ch = self.input[self.pos..].chars().next().unwrap_or(ch);
return Err(format!(
"unexpected character '{}' at position {}",
actual_ch, self.pos
));
}
}
}
}
fn peek_next_byte(&self) -> Option<u8> {
let bytes = self.input.as_bytes();
if self.pos + 1 < bytes.len() {
Some(bytes[self.pos + 1])
} else {
None
}
}
fn read_string(&mut self) -> Result<Token, String> {
self.pos += 1; let mut s = String::new();
while self.pos < self.input.len() {
let ch = self.input[self.pos..].chars().next().unwrap();
if ch == '\'' {
let next_pos = self.pos + 1;
if next_pos < self.input.len() && self.input.as_bytes()[next_pos] == b'\'' {
s.push('\'');
self.pos += 2;
} else {
self.pos += 1; return Ok(Token::StringLit(s));
}
} else {
s.push(ch);
self.pos += ch.len_utf8();
}
}
Err("unterminated string literal".to_string())
}
fn read_number(&mut self) -> Result<Token, String> {
let start = self.pos;
let bytes = self.input.as_bytes();
while self.pos < bytes.len()
&& (bytes[self.pos].is_ascii_digit() || bytes[self.pos] == b'.')
{
self.pos += 1;
}
let s = &self.input[start..self.pos];
let n: f64 = s
.parse()
.map_err(|e| format!("invalid number '{}': {}", s, e))?;
Ok(Token::NumberLit(n))
}
fn read_ident(&mut self) -> String {
let start = self.pos;
let bytes = self.input.as_bytes();
while self.pos < bytes.len() {
let ch = bytes[self.pos];
if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-' {
self.pos += 1;
} else {
break;
}
}
self.input[start..self.pos].to_string()
}
}
pub(crate) fn github_context_suffix(key: &str) -> Option<String> {
let rest = key.strip_prefix("GITHUB_")?;
if rest.is_empty() {
return None;
}
let suffix = rest.to_ascii_lowercase();
if matches!(
suffix.as_str(),
"output" | "env" | "path" | "step_summary" | "actions"
) {
return None;
}
Some(suffix)
}
pub struct ExpressionContext<'a> {
pub env_context: &'a HashMap<String, String>,
pub step_outputs: &'a HashMap<String, HashMap<String, String>>,
pub matrix_combination: &'a Option<HashMap<String, Value>>,
pub step_statuses: &'a HashMap<String, (String, String)>,
pub job_status: &'a str,
pub secrets_context: &'a HashMap<String, String>,
pub needs_context: &'a HashMap<String, HashMap<String, String>>,
pub needs_results: &'a HashMap<String, String>,
pub user_env: &'a HashMap<String, String>,
}
impl<'a> ExpressionContext<'a> {
fn resolve(&self, parts: &[String]) -> ExprValue {
if parts.is_empty() {
return ExprValue::Null;
}
let root = parts[0].as_str();
match root {
"inputs" if parts.len() == 2 => {
let env_key = format!("INPUT_{}", parts[1].to_uppercase().replace('-', "_"));
self.env_context
.get(&env_key)
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null)
}
"env" if parts.len() == 2 => self
.env_context
.get(&parts[1])
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null),
"github" if parts.len() >= 2 => {
let env_key = format!("GITHUB_{}", parts[1..].join("_").to_uppercase());
self.env_context
.get(&env_key)
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null)
}
"runner" if parts.len() == 2 => {
let env_key = format!("RUNNER_{}", parts[1].to_uppercase());
self.env_context
.get(&env_key)
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null)
}
"matrix" if parts.len() == 2 => {
if let Some(matrix) = self.matrix_combination {
matrix
.get(&parts[1])
.map(yaml_value_to_expr)
.unwrap_or(ExprValue::Null)
} else {
ExprValue::Null
}
}
"steps" if parts.len() == 4 && parts[2] == "outputs" => self
.step_outputs
.get(&parts[1])
.and_then(|m| m.get(&parts[3]))
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null),
"needs" if parts.len() == 4 && parts[2] == "outputs" => self
.needs_context
.get(&parts[1])
.and_then(|m| m.get(&parts[3]))
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null),
"needs" if parts.len() == 3 && parts[2] == "result" => self
.needs_results
.get(&parts[1])
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null),
"jobs" if parts.len() == 4 && parts[2] == "outputs" => self
.needs_context
.get(&parts[1])
.and_then(|m| m.get(&parts[3]))
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null),
"secrets" if parts.len() == 2 => self
.secrets_context
.get(&parts[1])
.map(|v| ExprValue::String(v.clone()))
.unwrap_or(ExprValue::Null),
"steps" if parts.len() == 3 && parts[2] == "outcome" => self
.step_statuses
.get(&parts[1])
.map(|(outcome, _)| ExprValue::String(outcome.clone()))
.unwrap_or(ExprValue::Null),
"steps" if parts.len() == 3 && parts[2] == "conclusion" => self
.step_statuses
.get(&parts[1])
.map(|(_, conclusion)| ExprValue::String(conclusion.clone()))
.unwrap_or(ExprValue::Null),
"steps" if parts.len() == 1 => {
let mut all_ids: HashSet<&String> = self.step_outputs.keys().collect();
all_ids.extend(self.step_statuses.keys());
let mut map = HashMap::new();
for step_id in all_ids {
let mut step_obj = HashMap::new();
let outputs_map: HashMap<String, ExprValue> = self
.step_outputs
.get(step_id)
.map(|m| {
m.iter()
.map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
.collect()
})
.unwrap_or_default();
step_obj.insert("outputs".to_string(), ExprValue::Object(outputs_map));
if let Some((outcome, conclusion)) = self.step_statuses.get(step_id) {
step_obj.insert("outcome".to_string(), ExprValue::String(outcome.clone()));
step_obj.insert(
"conclusion".to_string(),
ExprValue::String(conclusion.clone()),
);
}
map.insert(step_id.clone(), ExprValue::Object(step_obj));
}
ExprValue::Object(map)
}
"env" if parts.len() == 1 => {
let map = self
.user_env
.iter()
.map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
.collect();
ExprValue::Object(map)
}
"github" if parts.len() == 1 => {
let map = self
.env_context
.iter()
.filter_map(|(k, v)| {
github_context_suffix(k).map(|key| (key, ExprValue::String(v.clone())))
})
.collect();
ExprValue::Object(map)
}
"needs" if parts.len() == 1 => {
let mut all_ids: HashSet<&String> = self.needs_context.keys().collect();
all_ids.extend(self.needs_results.keys());
let mut map = HashMap::new();
for job_id in all_ids {
let mut job_obj = HashMap::new();
let outputs_map: HashMap<String, ExprValue> = self
.needs_context
.get(job_id)
.map(|m| {
m.iter()
.map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
.collect()
})
.unwrap_or_default();
job_obj.insert("outputs".to_string(), ExprValue::Object(outputs_map));
if let Some(result) = self.needs_results.get(job_id) {
job_obj.insert("result".to_string(), ExprValue::String(result.clone()));
}
map.insert(job_id.clone(), ExprValue::Object(job_obj));
}
ExprValue::Object(map)
}
"secrets" if parts.len() == 1 => {
let map = self
.secrets_context
.iter()
.map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
.collect();
ExprValue::Object(map)
}
"matrix" if parts.len() == 1 => {
if let Some(matrix) = self.matrix_combination {
let map = matrix
.iter()
.map(|(k, v)| (k.clone(), yaml_value_to_expr(v)))
.collect();
ExprValue::Object(map)
} else {
ExprValue::Null
}
}
_ => ExprValue::Null,
}
}
}
fn yaml_value_to_expr(v: &Value) -> ExprValue {
match v {
Value::String(s) => ExprValue::String(s.clone()),
Value::Number(n) => ExprValue::Number(n.as_f64().unwrap_or(0.0)),
Value::Bool(b) => ExprValue::Bool(*b),
Value::Null => ExprValue::Null,
_ => ExprValue::String(
serde_yaml::to_string(v)
.unwrap_or_else(|_| format!("{:?}", v))
.trim()
.to_string(),
),
}
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> &Token {
self.tokens.get(self.pos).unwrap_or(&Token::Eof)
}
fn advance(&mut self) -> Token {
let tok = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof);
self.pos += 1;
tok
}
fn expect(&mut self, expected: &Token) -> Result<(), String> {
let tok = self.advance();
if &tok == expected {
Ok(())
} else {
Err(format!("expected {:?}, got {:?}", expected, tok))
}
}
fn parse_expr(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
self.parse_or(ctx)
}
fn parse_or(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
let mut left = self.parse_and(ctx)?;
while *self.peek() == Token::Or {
self.advance();
let right = self.parse_and(ctx)?;
left = if left.is_truthy() { left } else { right };
}
Ok(left)
}
fn parse_and(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
let mut left = self.parse_comparison(ctx)?;
while *self.peek() == Token::And {
self.advance();
let right = self.parse_comparison(ctx)?;
left = if !left.is_truthy() { left } else { right };
}
Ok(left)
}
fn parse_comparison(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
let left = self.parse_unary(ctx)?;
match self.peek().clone() {
Token::Eq => {
self.advance();
let right = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(expr_eq(&left, &right)))
}
Token::Ne => {
self.advance();
let right = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(!expr_eq(&left, &right)))
}
Token::Lt => {
self.advance();
let right = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(
expr_cmp(&left, &right) == Some(std::cmp::Ordering::Less),
))
}
Token::Le => {
self.advance();
let right = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(matches!(
expr_cmp(&left, &right),
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
)))
}
Token::Gt => {
self.advance();
let right = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(
expr_cmp(&left, &right) == Some(std::cmp::Ordering::Greater),
))
}
Token::Ge => {
self.advance();
let right = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(matches!(
expr_cmp(&left, &right),
Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
)))
}
_ => Ok(left),
}
}
fn parse_unary(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
if *self.peek() == Token::Not {
self.advance();
let val = self.parse_unary(ctx)?;
Ok(ExprValue::Bool(!val.is_truthy()))
} else {
self.parse_primary(ctx)
}
}
fn parse_primary(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
match self.peek().clone() {
Token::StringLit(s) => {
self.advance();
Ok(ExprValue::String(s))
}
Token::NumberLit(n) => {
self.advance();
Ok(ExprValue::Number(n))
}
Token::True => {
self.advance();
Ok(ExprValue::Bool(true))
}
Token::False => {
self.advance();
Ok(ExprValue::Bool(false))
}
Token::Null => {
self.advance();
Ok(ExprValue::Null)
}
Token::LParen => {
self.advance();
let val = self.parse_expr(ctx)?;
self.expect(&Token::RParen)?;
Ok(val)
}
Token::Ident(_) => self.parse_ident_or_call(ctx),
Token::Not => self.parse_unary(ctx),
other => Err(format!("unexpected token: {:?}", other)),
}
}
fn parse_ident_or_call(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
let Token::Ident(name) = self.advance() else {
return Err("expected identifier".to_string());
};
if *self.peek() == Token::LParen {
self.advance(); let mut args = Vec::new();
if *self.peek() != Token::RParen {
args.push(self.parse_expr(ctx)?);
while *self.peek() == Token::Comma {
self.advance();
args.push(self.parse_expr(ctx)?);
}
}
self.expect(&Token::RParen)?;
return call_builtin(&name, &args, ctx);
}
let mut parts = vec![name];
while *self.peek() == Token::Dot {
self.advance(); match self.advance() {
Token::Ident(part) => parts.push(part),
other => return Err(format!("expected identifier after '.', got {:?}", other)),
}
}
Ok(ctx.resolve(&parts))
}
}
fn expr_eq(a: &ExprValue, b: &ExprValue) -> bool {
match (a, b) {
(ExprValue::Null, ExprValue::Null) => true,
(ExprValue::Null, _) | (_, ExprValue::Null) => false,
(ExprValue::Bool(a), ExprValue::Bool(b)) => a == b,
(ExprValue::Number(a), ExprValue::Number(b)) => (a - b).abs() < f64::EPSILON,
(ExprValue::String(a), ExprValue::String(b)) => a.eq_ignore_ascii_case(b),
(ExprValue::String(s), ExprValue::Number(n))
| (ExprValue::Number(n), ExprValue::String(s)) => {
if let Ok(parsed) = s.parse::<f64>() {
(parsed - n).abs() < f64::EPSILON
} else {
false
}
}
(ExprValue::Bool(b), ExprValue::Number(n)) | (ExprValue::Number(n), ExprValue::Bool(b)) => {
let bv = if *b { 1.0 } else { 0.0 };
(bv - n).abs() < f64::EPSILON
}
(ExprValue::Bool(b), ExprValue::String(s)) | (ExprValue::String(s), ExprValue::Bool(b)) => {
let sv = s.eq_ignore_ascii_case("true");
*b == sv
}
(ExprValue::Object(_), _) | (_, ExprValue::Object(_)) => false,
}
}
fn expr_cmp(a: &ExprValue, b: &ExprValue) -> Option<std::cmp::Ordering> {
match (a, b) {
(ExprValue::Number(a), ExprValue::Number(b)) => a.partial_cmp(b),
(ExprValue::String(a), ExprValue::String(b)) => {
Some(a.to_lowercase().cmp(&b.to_lowercase()))
}
(ExprValue::Object(_), _) | (_, ExprValue::Object(_)) => None,
_ => None,
}
}
fn expr_to_json(v: &ExprValue) -> serde_json::Value {
match v {
ExprValue::String(s) => serde_json::Value::String(s.clone()),
ExprValue::Number(n) => serde_json::json!(n),
ExprValue::Bool(b) => serde_json::Value::Bool(*b),
ExprValue::Null => serde_json::Value::Null,
ExprValue::Object(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map
.iter()
.map(|(k, v)| (k.clone(), expr_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
}
}
fn call_builtin(
name: &str,
args: &[ExprValue],
ctx: &ExpressionContext,
) -> Result<ExprValue, String> {
match name {
"contains" => {
if args.len() != 2 {
return Err("contains() requires 2 arguments".to_string());
}
let haystack = args[0].to_output_string().to_lowercase();
let needle = args[1].to_output_string().to_lowercase();
Ok(ExprValue::Bool(haystack.contains(&needle)))
}
"startsWith" | "startswith" => {
if args.len() != 2 {
return Err("startsWith() requires 2 arguments".to_string());
}
let s = args[0].to_output_string().to_lowercase();
let prefix = args[1].to_output_string().to_lowercase();
Ok(ExprValue::Bool(s.starts_with(&prefix)))
}
"endsWith" | "endswith" => {
if args.len() != 2 {
return Err("endsWith() requires 2 arguments".to_string());
}
let s = args[0].to_output_string().to_lowercase();
let suffix = args[1].to_output_string().to_lowercase();
Ok(ExprValue::Bool(s.ends_with(&suffix)))
}
"format" => {
if args.is_empty() {
return Err("format() requires at least 1 argument".to_string());
}
let fmt = args[0].to_output_string();
let mut result = String::with_capacity(fmt.len());
let mut chars = fmt.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
if ch == '{' {
let rest = &fmt[i + 1..];
if let Some(close) = rest.find('}') {
let inner = &rest[..close];
if let Ok(idx) = inner.parse::<usize>() {
if idx + 1 < args.len() {
result.push_str(&args[idx + 1].to_output_string());
let skip_to = i + 1 + close + 1;
while chars.peek().is_some_and(|(ci, _)| *ci < skip_to) {
chars.next();
}
continue;
}
}
}
}
result.push(ch);
}
Ok(ExprValue::String(result))
}
"join" => {
if args.is_empty() || args.len() > 2 {
return Err("join() requires 1 or 2 arguments".to_string());
}
let sep = if args.len() == 2 {
args[1].to_output_string()
} else {
",".to_string()
};
Ok(ExprValue::String(
args[0].to_output_string().replace(',', &sep),
))
}
"toJSON" | "tojson" => {
if args.len() != 1 {
return Err("toJSON() requires 1 argument".to_string());
}
match &args[0] {
ExprValue::String(s) => {
Ok(ExprValue::String(
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s)),
))
}
ExprValue::Number(n) => Ok(ExprValue::String(format!("{}", n))),
ExprValue::Bool(b) => Ok(ExprValue::String(format!("{}", b))),
ExprValue::Null => Ok(ExprValue::String("null".to_string())),
ExprValue::Object(map) => {
let sorted: std::collections::BTreeMap<&String, serde_json::Value> =
map.iter().map(|(k, v)| (k, expr_to_json(v))).collect();
Ok(ExprValue::String(
serde_json::to_string_pretty(&sorted).unwrap_or_else(|_| "{}".to_string()),
))
}
}
}
"fromJSON" | "fromjson" => {
if args.len() != 1 {
return Err("fromJSON() requires 1 argument".to_string());
}
let s = args[0].to_output_string();
match s.as_str() {
"null" => Ok(ExprValue::Null),
"true" => Ok(ExprValue::Bool(true)),
"false" => Ok(ExprValue::Bool(false)),
_ => {
if let Ok(n) = s.parse::<f64>() {
Ok(ExprValue::Number(n))
} else {
let stripped = s
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(&s);
Ok(ExprValue::String(stripped.to_string()))
}
}
}
}
"success" => Ok(ExprValue::Bool(ctx.job_status == "success")),
"failure" => Ok(ExprValue::Bool(ctx.job_status == "failure")),
"always" => Ok(ExprValue::Bool(true)),
"cancelled" => Ok(ExprValue::Bool(ctx.job_status == "cancelled")),
_ => {
Ok(ExprValue::Null)
}
}
}
pub fn evaluate(expr: &str, ctx: &ExpressionContext) -> Result<ExprValue, String> {
let trimmed = expr.trim();
if trimmed.is_empty() {
return Ok(ExprValue::Null);
}
let mut tokenizer = Tokenizer::new(trimmed);
let tokens = tokenizer.tokenize()?;
let mut parser = Parser::new(tokens);
let result = parser.parse_expr(ctx)?;
if *parser.peek() != Token::Eof {
return Err(format!(
"unexpected token after expression: {:?}",
parser.peek()
));
}
Ok(result)
}
pub fn evaluate_as_bool(expr: &str, ctx: &ExpressionContext) -> Result<bool, String> {
let trimmed = expr.trim();
let inner = if trimmed.starts_with("${{") && trimmed.ends_with("}}") {
&trimmed[3..trimmed.len() - 2]
} else {
trimmed
};
let val = evaluate(inner, ctx)?;
Ok(val.is_truthy())
}
#[cfg(test)]
mod tests {
use super::*;
lazy_static::lazy_static! {
static ref EMPTY_ENV: HashMap<String, String> = HashMap::new();
static ref EMPTY_USER_ENV: HashMap<String, String> = HashMap::new();
static ref EMPTY_STEPS: HashMap<String, HashMap<String, String>> = HashMap::new();
static ref EMPTY_MATRIX: Option<HashMap<String, Value>> = None;
static ref EMPTY_STATUSES: HashMap<String, (String, String)> = HashMap::new();
static ref EMPTY_SECRETS: HashMap<String, String> = HashMap::new();
static ref EMPTY_NEEDS: HashMap<String, HashMap<String, String>> = HashMap::new();
static ref EMPTY_NEEDS_RESULTS: HashMap<String, String> = HashMap::new();
}
fn empty_ctx() -> ExpressionContext<'static> {
ExpressionContext {
env_context: &EMPTY_ENV,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
user_env: &EMPTY_USER_ENV,
}
}
fn make_ctx<'a>(
env: &'a HashMap<String, String>,
steps: &'a HashMap<String, HashMap<String, String>>,
matrix: &'a Option<HashMap<String, Value>>,
) -> ExpressionContext<'a> {
ExpressionContext {
env_context: env,
step_outputs: steps,
matrix_combination: matrix,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
user_env: env,
}
}
#[test]
fn eval_string_literal() {
let ctx = empty_ctx();
assert_eq!(
evaluate("'hello'", &ctx).unwrap(),
ExprValue::String("hello".to_string())
);
}
#[test]
fn eval_empty_string_literal() {
let ctx = empty_ctx();
assert_eq!(
evaluate("''", &ctx).unwrap(),
ExprValue::String(String::new())
);
}
#[test]
fn eval_number_literal() {
let ctx = empty_ctx();
assert_eq!(evaluate("42", &ctx).unwrap(), ExprValue::Number(42.0));
}
#[test]
fn eval_bool_literals() {
let ctx = empty_ctx();
assert_eq!(evaluate("true", &ctx).unwrap(), ExprValue::Bool(true));
assert_eq!(evaluate("false", &ctx).unwrap(), ExprValue::Bool(false));
}
#[test]
fn eval_null_literal() {
let ctx = empty_ctx();
assert_eq!(evaluate("null", &ctx).unwrap(), ExprValue::Null);
}
#[test]
fn truthiness() {
assert!(ExprValue::Bool(true).is_truthy());
assert!(!ExprValue::Bool(false).is_truthy());
assert!(ExprValue::Number(1.0).is_truthy());
assert!(!ExprValue::Number(0.0).is_truthy());
assert!(ExprValue::String("hello".to_string()).is_truthy());
assert!(!ExprValue::String(String::new()).is_truthy());
assert!(!ExprValue::Null.is_truthy());
}
#[test]
fn eval_equality() {
let ctx = empty_ctx();
assert_eq!(
evaluate("'nightly' == 'nightly'", &ctx).unwrap(),
ExprValue::Bool(true)
);
assert_eq!(
evaluate("'nightly' == 'stable'", &ctx).unwrap(),
ExprValue::Bool(false)
);
assert_eq!(
evaluate("'nightly' != 'stable'", &ctx).unwrap(),
ExprValue::Bool(true)
);
}
#[test]
fn eval_bool_string_coercion() {
let ctx = empty_ctx();
assert_eq!(
evaluate("false == 'random'", &ctx).unwrap(),
ExprValue::Bool(true)
);
assert_eq!(
evaluate("true == 'true'", &ctx).unwrap(),
ExprValue::Bool(true)
);
assert_eq!(
evaluate("true == 'TRUE'", &ctx).unwrap(),
ExprValue::Bool(true)
);
assert_eq!(
evaluate("true == 'false'", &ctx).unwrap(),
ExprValue::Bool(false)
);
assert_eq!(
evaluate("false == 'false'", &ctx).unwrap(),
ExprValue::Bool(true)
);
}
#[test]
fn eval_case_insensitive_equality() {
let ctx = empty_ctx();
assert_eq!(
evaluate("'Nightly' == 'nightly'", &ctx).unwrap(),
ExprValue::Bool(true)
);
}
#[test]
fn eval_number_comparison() {
let ctx = empty_ctx();
assert_eq!(evaluate("1 < 2", &ctx).unwrap(), ExprValue::Bool(true));
assert_eq!(evaluate("2 >= 2", &ctx).unwrap(), ExprValue::Bool(true));
assert_eq!(evaluate("3 <= 2", &ctx).unwrap(), ExprValue::Bool(false));
}
#[test]
fn eval_and_operator() {
let ctx = empty_ctx();
assert_eq!(
evaluate("true && 'hello'", &ctx).unwrap(),
ExprValue::String("hello".to_string())
);
assert_eq!(
evaluate("false && 'hello'", &ctx).unwrap(),
ExprValue::Bool(false)
);
assert_eq!(
evaluate("'' && 'hello'", &ctx).unwrap(),
ExprValue::String(String::new())
);
}
#[test]
fn eval_or_operator() {
let ctx = empty_ctx();
assert_eq!(
evaluate("'hi' || 'bye'", &ctx).unwrap(),
ExprValue::String("hi".to_string())
);
assert_eq!(
evaluate("'' || 'fallback'", &ctx).unwrap(),
ExprValue::String("fallback".to_string())
);
assert_eq!(
evaluate("false || ''", &ctx).unwrap(),
ExprValue::String(String::new())
);
}
#[test]
fn eval_not_operator() {
let ctx = empty_ctx();
assert_eq!(evaluate("!true", &ctx).unwrap(), ExprValue::Bool(false));
assert_eq!(evaluate("!false", &ctx).unwrap(), ExprValue::Bool(true));
assert_eq!(evaluate("!''", &ctx).unwrap(), ExprValue::Bool(true));
}
#[test]
fn eval_parentheses() {
let ctx = empty_ctx();
assert_eq!(
evaluate("(true || false) && false", &ctx).unwrap(),
ExprValue::Bool(false)
);
}
#[test]
fn eval_inputs_context() {
let mut env = HashMap::new();
env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string());
let empty_steps = HashMap::new();
let ctx = make_ctx(&env, &empty_steps, &None);
assert_eq!(
evaluate("inputs.toolchain", &ctx).unwrap(),
ExprValue::String("nightly".to_string())
);
}
#[test]
fn eval_env_context() {
let mut env = HashMap::new();
env.insert("MY_VAR".to_string(), "hello".to_string());
let empty_steps = HashMap::new();
let ctx = make_ctx(&env, &empty_steps, &None);
assert_eq!(
evaluate("env.MY_VAR", &ctx).unwrap(),
ExprValue::String("hello".to_string())
);
}
#[test]
fn eval_github_context() {
let mut env = HashMap::new();
env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
let empty_steps = HashMap::new();
let ctx = make_ctx(&env, &empty_steps, &None);
assert_eq!(
evaluate("github.repository", &ctx).unwrap(),
ExprValue::String("owner/repo".to_string())
);
}
#[test]
fn eval_steps_outputs() {
let mut steps = HashMap::new();
let mut build_out = HashMap::new();
build_out.insert("version".to_string(), "1.2.3".to_string());
steps.insert("build".to_string(), build_out);
let empty_env = HashMap::new();
let ctx = make_ctx(&empty_env, &steps, &None);
assert_eq!(
evaluate("steps.build.outputs.version", &ctx).unwrap(),
ExprValue::String("1.2.3".to_string())
);
}
#[test]
fn eval_matrix_context() {
let mut matrix = HashMap::new();
matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
let empty_env = HashMap::new();
let empty_steps = HashMap::new();
let matrix = Some(matrix);
let ctx = make_ctx(&empty_env, &empty_steps, &matrix);
assert_eq!(
evaluate("matrix.os", &ctx).unwrap(),
ExprValue::String("ubuntu".to_string())
);
}
#[test]
fn eval_missing_context_returns_null() {
let ctx = empty_ctx();
assert_eq!(
evaluate("inputs.nonexistent", &ctx).unwrap(),
ExprValue::Null
);
}
#[test]
fn eval_rust_toolchain_pattern() {
let mut env = HashMap::new();
env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
let mut steps = HashMap::new();
let mut parse_out = HashMap::new();
parse_out.insert("toolchain".to_string(), "nightly".to_string());
steps.insert("parse".to_string(), parse_out);
let ctx = make_ctx(&env, &steps, &None);
let result = evaluate(
"steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
&ctx,
)
.unwrap();
assert_eq!(result, ExprValue::String(" --allow-downgrade".to_string()));
}
#[test]
fn eval_rust_toolchain_pattern_not_nightly() {
let mut env = HashMap::new();
env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
let mut steps = HashMap::new();
let mut parse_out = HashMap::new();
parse_out.insert("toolchain".to_string(), "stable".to_string());
steps.insert("parse".to_string(), parse_out);
let ctx = make_ctx(&env, &steps, &None);
let result = evaluate(
"steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
&ctx,
)
.unwrap();
assert_eq!(result, ExprValue::String(String::new()));
}
#[test]
fn eval_rust_toolchain_pattern_no_components() {
let env = HashMap::new();
let mut steps = HashMap::new();
let mut parse_out = HashMap::new();
parse_out.insert("toolchain".to_string(), "nightly".to_string());
steps.insert("parse".to_string(), parse_out);
let ctx = make_ctx(&env, &steps, &None);
let result = evaluate(
"steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
&ctx,
)
.unwrap();
assert_eq!(result, ExprValue::String(String::new()));
}
#[test]
fn eval_contains() {
let ctx = empty_ctx();
assert_eq!(
evaluate("contains('Hello World', 'hello')", &ctx).unwrap(),
ExprValue::Bool(true)
);
assert_eq!(
evaluate("contains('Hello', 'xyz')", &ctx).unwrap(),
ExprValue::Bool(false)
);
}
#[test]
fn eval_starts_with() {
let ctx = empty_ctx();
assert_eq!(
evaluate("startsWith('refs/heads/main', 'refs/heads')", &ctx).unwrap(),
ExprValue::Bool(true)
);
assert_eq!(
evaluate("startsWith('refs/tags/v1', 'refs/heads')", &ctx).unwrap(),
ExprValue::Bool(false)
);
}
#[test]
fn eval_ends_with() {
let ctx = empty_ctx();
assert_eq!(
evaluate("endsWith('hello.txt', '.txt')", &ctx).unwrap(),
ExprValue::Bool(true)
);
}
#[test]
fn eval_format_function() {
let ctx = empty_ctx();
assert_eq!(
evaluate("format('Hello {0}, you are {1}', 'world', 'great')", &ctx).unwrap(),
ExprValue::String("Hello world, you are great".to_string())
);
}
#[test]
fn eval_format_non_ascii() {
let ctx = empty_ctx();
assert_eq!(
evaluate("format('{0} → {1}', 'a', 'b')", &ctx).unwrap(),
ExprValue::String("a → b".to_string())
);
}
#[test]
fn eval_format_out_of_bounds_placeholder_preserved() {
let ctx = empty_ctx();
assert_eq!(
evaluate("format('{0} {5}', 'hi')", &ctx).unwrap(),
ExprValue::String("hi {5}".to_string())
);
}
#[test]
fn eval_format_arg_containing_placeholder_not_reinterpreted() {
let ctx = empty_ctx();
assert_eq!(
evaluate("format('{0} {1}', '{1}', 'x')", &ctx).unwrap(),
ExprValue::String("{1} x".to_string())
);
}
#[test]
fn eval_status_functions() {
let ctx = empty_ctx();
assert_eq!(evaluate("success()", &ctx).unwrap(), ExprValue::Bool(true));
assert_eq!(evaluate("failure()", &ctx).unwrap(), ExprValue::Bool(false));
assert_eq!(evaluate("always()", &ctx).unwrap(), ExprValue::Bool(true));
assert_eq!(
evaluate("cancelled()", &ctx).unwrap(),
ExprValue::Bool(false)
);
}
#[test]
fn eval_as_bool_strips_delimiters() {
let ctx = empty_ctx();
assert!(evaluate_as_bool("${{ true }}", &ctx).unwrap());
assert!(!evaluate_as_bool("${{ false }}", &ctx).unwrap());
}
#[test]
fn eval_as_bool_bare_expression() {
let ctx = empty_ctx();
assert!(evaluate_as_bool("true", &ctx).unwrap());
assert!(!evaluate_as_bool("false", &ctx).unwrap());
}
#[test]
fn eval_as_bool_condition_with_context() {
let mut env = HashMap::new();
env.insert("GITHUB_REF".to_string(), "refs/tags/v1.0.0".to_string());
let empty_steps = HashMap::new();
let ctx = make_ctx(&env, &empty_steps, &None);
assert!(evaluate_as_bool("startsWith(github.ref, 'refs/tags/')", &ctx).unwrap());
}
#[test]
fn output_string_formatting() {
assert_eq!(ExprValue::String("hi".to_string()).to_output_string(), "hi");
assert_eq!(ExprValue::Number(42.0).to_output_string(), "42");
assert_eq!(ExprValue::Number(3.15).to_output_string(), "3.15");
assert_eq!(ExprValue::Bool(true).to_output_string(), "true");
assert_eq!(ExprValue::Null.to_output_string(), "");
}
#[test]
fn eval_unterminated_string_errors() {
let ctx = empty_ctx();
assert!(evaluate("'unterminated", &ctx).is_err());
}
#[test]
fn eval_unexpected_token_errors() {
let ctx = empty_ctx();
assert!(evaluate("&&", &ctx).is_err());
}
#[test]
fn eval_empty_expression() {
let ctx = empty_ctx();
assert_eq!(evaluate("", &ctx).unwrap(), ExprValue::Null);
}
#[test]
fn unknown_step_id_returns_null() {
let ctx = empty_ctx();
assert_eq!(
evaluate("steps.nonexistent.outcome", &ctx).unwrap(),
ExprValue::Null
);
assert_eq!(
evaluate("steps.nonexistent.conclusion", &ctx).unwrap(),
ExprValue::Null
);
}
#[test]
fn tojson_escapes_control_characters() {
let ctx = empty_ctx();
let result = evaluate("toJSON('line1\tindented\nline2\rend')", &ctx).unwrap();
let s = result.to_output_string();
assert!(s.contains("\\t"), "should escape tab: {}", s);
assert!(s.contains("\\n"), "should escape newline: {}", s);
assert!(s.contains("\\r"), "should escape carriage return: {}", s);
}
#[test]
fn tojson_escapes_quotes_and_backslash() {
let ctx = empty_ctx();
let result = evaluate(r#"toJSON('say "hello\world"')"#, &ctx).unwrap();
let s = result.to_output_string();
assert!(s.contains(r#"\""#), "should escape quotes: {}", s);
assert!(s.contains(r"\\"), "should escape backslash: {}", s);
}
#[test]
fn tojson_handles_null_bytes() {
let ctx = empty_ctx();
let result = evaluate("toJSON('before\x00after')", &ctx).unwrap();
let s = result.to_output_string();
assert!(!s.contains('\0'), "should not contain raw null: {}", s);
}
#[test]
fn tojson_env_returns_object() {
let mut user_env = HashMap::new();
user_env.insert("MY_VAR".to_string(), "hello".to_string());
user_env.insert("OTHER".to_string(), "world".to_string());
let mut env_context = user_env.clone();
env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
env_context.insert("RUNNER_OS".to_string(), "Linux".to_string());
env_context.insert("INPUT_NAME".to_string(), "test".to_string());
env_context.insert("CI".to_string(), "true".to_string());
let ctx = ExpressionContext {
env_context: &env_context,
user_env: &user_env,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
};
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
assert_eq!(obj.get("OTHER").unwrap(), "world");
assert!(
obj.get("GITHUB_SHA").is_none(),
"runner-seeded GITHUB_SHA should not leak into toJSON(env)"
);
assert!(
obj.get("RUNNER_OS").is_none(),
"runner-seeded RUNNER_OS should not leak into toJSON(env)"
);
assert!(
obj.get("INPUT_NAME").is_none(),
"runner-seeded INPUT_NAME should not leak into toJSON(env)"
);
assert!(
obj.get("CI").is_none(),
"runner-seeded CI should not leak into toJSON(env)"
);
}
#[test]
fn tojson_env_sorted_keys() {
let mut env = HashMap::new();
env.insert("ZEBRA".to_string(), "z".to_string());
env.insert("APPLE".to_string(), "a".to_string());
env.insert("MANGO".to_string(), "m".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let apple_pos = s.find("APPLE").unwrap();
let mango_pos = s.find("MANGO").unwrap();
let zebra_pos = s.find("ZEBRA").unwrap();
assert!(apple_pos < mango_pos, "APPLE should come before MANGO");
assert!(mango_pos < zebra_pos, "MANGO should come before ZEBRA");
}
#[test]
fn bare_env_is_truthy() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("env", &ctx).unwrap();
assert!(result.is_truthy());
}
#[test]
fn bare_env_to_output_string() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("env", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("FOO").unwrap(), "bar");
}
#[test]
fn tojson_env_empty_when_only_internal_vars() {
let mut env_context = HashMap::new();
env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
env_context.insert("RUNNER_OS".to_string(), "Linux".to_string());
env_context.insert("CI".to_string(), "true".to_string());
let ctx = ExpressionContext {
env_context: &env_context,
user_env: &EMPTY_USER_ENV,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
};
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(
obj.is_empty(),
"should be empty when no user env is declared: {}",
s
);
}
#[test]
fn tojson_env_includes_user_var_with_internal_prefix() {
let mut user_env = HashMap::new();
user_env.insert("GITHUB_CUSTOM".to_string(), "user-val".to_string());
user_env.insert("MY_VAR".to_string(), "hello".to_string());
let env_context = user_env.clone();
let ctx = ExpressionContext {
env_context: &env_context,
user_env: &user_env,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
};
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
assert_eq!(
obj.get("GITHUB_CUSTOM").unwrap(),
"user-val",
"user-declared var with GITHUB_ prefix must appear in toJSON(env)"
);
}
#[test]
fn tojson_env_includes_user_declared_github_token() {
let mut user_env = HashMap::new();
user_env.insert("GITHUB_TOKEN".to_string(), "ghp_user_supplied".to_string());
let mut env_context = user_env.clone();
env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
let ctx = ExpressionContext {
env_context: &env_context,
user_env: &user_env,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
};
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("GITHUB_TOKEN").unwrap(), "ghp_user_supplied");
assert!(
obj.get("GITHUB_SHA").is_none(),
"process-inherited GITHUB_SHA must not appear in toJSON(env)"
);
}
#[test]
fn tojson_env_empty_context() {
let env = HashMap::new();
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.is_empty(), "should be empty with no env vars: {}", s);
}
#[test]
fn fromjson_tojson_env_produces_parseable_json() {
let mut env = HashMap::new();
env.insert("MY_VAR".to_string(), "hello".to_string());
env.insert("OTHER".to_string(), "world".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("fromJSON(toJSON(env))", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
assert_eq!(obj.get("OTHER").unwrap(), "world");
}
#[test]
fn tojson_env_special_characters_in_values() {
let mut env = HashMap::new();
env.insert("QUOTED".to_string(), "he said \"hi\"".to_string());
env.insert("NEWLINE".to_string(), "line1\nline2".to_string());
env.insert("UNICODE".to_string(), "\u{1F600}".to_string());
env.insert("BACKSLASH".to_string(), "path\\to\\file".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(env)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value =
serde_json::from_str(&s).expect("should be valid JSON despite special chars");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("QUOTED").unwrap(), "he said \"hi\"");
assert_eq!(obj.get("NEWLINE").unwrap(), "line1\nline2");
assert_eq!(obj.get("UNICODE").unwrap(), "\u{1F600}");
assert_eq!(obj.get("BACKSLASH").unwrap(), "path\\to\\file");
}
#[test]
fn tojson_github_returns_object() {
let mut env = HashMap::new();
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
env.insert("GITHUB_REF".to_string(), "refs/heads/main".to_string());
env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
env.insert("MY_VAR".to_string(), "hello".to_string());
env.insert("RUNNER_OS".to_string(), "Linux".to_string());
env.insert("INPUT_NAME".to_string(), "test".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("sha").unwrap(), "abc123");
assert_eq!(obj.get("ref").unwrap(), "refs/heads/main");
assert_eq!(obj.get("repository").unwrap(), "owner/repo");
assert!(
obj.get("MY_VAR").is_none(),
"should exclude non-GITHUB vars"
);
assert!(
obj.get("RUNNER_OS").is_none(),
"should exclude RUNNER_ vars"
);
assert!(
obj.get("INPUT_NAME").is_none(),
"should exclude INPUT_ vars"
);
}
#[test]
fn tojson_github_empty_context() {
let env = HashMap::new();
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.is_empty(), "should be empty with no env vars: {}", s);
}
#[test]
fn tojson_github_no_github_prefix() {
let mut env = HashMap::new();
env.insert("MY_VAR".to_string(), "hello".to_string());
env.insert("CI".to_string(), "true".to_string());
env.insert("RUNNER_OS".to_string(), "Linux".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(
obj.is_empty(),
"should be empty when no GITHUB_* vars exist: {}",
s
);
}
#[test]
fn tojson_github_sorted_keys() {
let mut env = HashMap::new();
env.insert("GITHUB_ZEBRA".to_string(), "z".to_string());
env.insert("GITHUB_APPLE".to_string(), "a".to_string());
env.insert("GITHUB_MANGO".to_string(), "m".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let apple_pos = s.find("apple").unwrap();
let mango_pos = s.find("mango").unwrap();
let zebra_pos = s.find("zebra").unwrap();
assert!(apple_pos < mango_pos, "apple should come before mango");
assert!(mango_pos < zebra_pos, "mango should come before zebra");
}
#[test]
fn bare_github_is_truthy() {
let mut env = HashMap::new();
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("github", &ctx).unwrap();
assert!(result.is_truthy());
}
#[test]
fn tojson_github_preserves_dotted_access() {
let mut env = HashMap::new();
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let dotted = evaluate("github.sha", &ctx).unwrap();
assert_eq!(dotted.to_output_string(), "abc123");
let bare = evaluate("toJSON(github)", &ctx).unwrap();
let s = bare.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
assert_eq!(parsed.get("sha").unwrap(), "abc123");
}
#[test]
fn tojson_github_special_characters_in_values() {
let mut env = HashMap::new();
env.insert(
"GITHUB_EVENT_HEAD_COMMIT_MESSAGE".to_string(),
"he said \"hi\"\nnew line".to_string(),
);
env.insert("GITHUB_WORKSPACE".to_string(), "C:\\Users\\dev".to_string());
env.insert("GITHUB_ACTOR".to_string(), "\u{1F600}".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value =
serde_json::from_str(&s).expect("should be valid JSON despite special chars");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(
obj.get("event_head_commit_message").unwrap(),
"he said \"hi\"\nnew line"
);
assert_eq!(obj.get("workspace").unwrap(), "C:\\Users\\dev");
assert_eq!(obj.get("actor").unwrap(), "\u{1F600}");
}
#[test]
fn fromjson_tojson_github_produces_parseable_json() {
let mut env = HashMap::new();
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
env.insert("GITHUB_REF".to_string(), "refs/heads/main".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("fromJSON(toJSON(github))", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("sha").unwrap(), "abc123");
assert_eq!(obj.get("ref").unwrap(), "refs/heads/main");
}
#[test]
fn tojson_github_includes_token_in_plaintext() {
let mut env = HashMap::new();
env.insert("GITHUB_TOKEN".to_string(), "ghs_secret".to_string());
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("token").unwrap(), "ghs_secret");
assert_eq!(obj.get("sha").unwrap(), "abc123");
}
#[test]
fn tojson_github_ignores_prefix_only_key() {
let mut env = HashMap::new();
env.insert("GITHUB_".to_string(), "weird".to_string());
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.get("").is_none(), "empty-key entry must not appear");
assert_eq!(obj.get("sha").unwrap(), "abc123");
assert_eq!(obj.len(), 1);
}
#[test]
fn tojson_github_excludes_runner_internal_keys() {
let mut env = HashMap::new();
env.insert("GITHUB_OUTPUT".to_string(), "/tmp/out".to_string());
env.insert("GITHUB_ENV".to_string(), "/tmp/env".to_string());
env.insert("GITHUB_PATH".to_string(), "/tmp/path".to_string());
env.insert(
"GITHUB_STEP_SUMMARY".to_string(),
"/tmp/summary".to_string(),
);
env.insert("GITHUB_ACTIONS".to_string(), "true".to_string());
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.get("output").is_none(), "should exclude GITHUB_OUTPUT");
assert!(obj.get("env").is_none(), "should exclude GITHUB_ENV");
assert!(obj.get("path").is_none(), "should exclude GITHUB_PATH");
assert!(
obj.get("step_summary").is_none(),
"should exclude GITHUB_STEP_SUMMARY"
);
assert!(
obj.get("actions").is_none(),
"should exclude GITHUB_ACTIONS (not a github-context property)"
);
assert_eq!(obj.get("sha").unwrap(), "abc123");
}
#[test]
fn tojson_github_includes_user_defined_github_prefixed_vars() {
let mut env = HashMap::new();
env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
env.insert("GITHUB_FOO".to_string(), "bar".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(github)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("foo").unwrap(), "bar");
assert_eq!(obj.get("sha").unwrap(), "abc123");
}
fn make_steps_ctx<'a>(
step_outputs: &'a HashMap<String, HashMap<String, String>>,
step_statuses: &'a HashMap<String, (String, String)>,
) -> ExpressionContext<'a> {
ExpressionContext {
env_context: &EMPTY_ENV,
user_env: &EMPTY_USER_ENV,
step_outputs,
matrix_combination: &EMPTY_MATRIX,
step_statuses,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
}
}
#[test]
fn tojson_steps_returns_nested_object() {
let mut outputs = HashMap::new();
let mut build_out = HashMap::new();
build_out.insert("artifact".to_string(), "app.zip".to_string());
build_out.insert("version".to_string(), "1.2.3".to_string());
outputs.insert("build".to_string(), build_out);
let mut test_out = HashMap::new();
test_out.insert("passed".to_string(), "true".to_string());
outputs.insert("test".to_string(), test_out);
let mut statuses = HashMap::new();
statuses.insert(
"build".to_string(),
("success".to_string(), "success".to_string()),
);
statuses.insert(
"test".to_string(),
("failure".to_string(), "failure".to_string()),
);
let ctx = make_steps_ctx(&outputs, &statuses);
let result = evaluate("toJSON(steps)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
let build = obj.get("build").unwrap().as_object().unwrap();
assert_eq!(build.get("outcome").unwrap(), "success");
assert_eq!(build.get("conclusion").unwrap(), "success");
let build_outputs = build.get("outputs").unwrap().as_object().unwrap();
assert_eq!(build_outputs.get("artifact").unwrap(), "app.zip");
assert_eq!(build_outputs.get("version").unwrap(), "1.2.3");
let test = obj.get("test").unwrap().as_object().unwrap();
assert_eq!(test.get("outcome").unwrap(), "failure");
assert_eq!(test.get("conclusion").unwrap(), "failure");
let test_outputs = test.get("outputs").unwrap().as_object().unwrap();
assert_eq!(test_outputs.get("passed").unwrap(), "true");
}
#[test]
fn tojson_steps_empty_context() {
let outputs = HashMap::new();
let statuses = HashMap::new();
let ctx = make_steps_ctx(&outputs, &statuses);
let result = evaluate("toJSON(steps)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.is_empty(), "should be empty with no steps: {}", s);
}
#[test]
fn tojson_steps_sorted_keys() {
let mut statuses = HashMap::new();
statuses.insert(
"zebra".to_string(),
("success".to_string(), "success".to_string()),
);
statuses.insert(
"alpha".to_string(),
("success".to_string(), "success".to_string()),
);
statuses.insert(
"middle".to_string(),
("success".to_string(), "success".to_string()),
);
let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
let result = evaluate("toJSON(steps)", &ctx).unwrap();
let s = result.to_output_string();
let alpha_pos = s.find("alpha").unwrap();
let middle_pos = s.find("middle").unwrap();
let zebra_pos = s.find("zebra").unwrap();
assert!(alpha_pos < middle_pos, "alpha should come before middle");
assert!(middle_pos < zebra_pos, "middle should come before zebra");
}
#[test]
fn tojson_steps_status_without_outputs() {
let mut statuses = HashMap::new();
statuses.insert(
"checkout".to_string(),
("success".to_string(), "success".to_string()),
);
let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
let result = evaluate("toJSON(steps)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let checkout = parsed.get("checkout").unwrap().as_object().unwrap();
assert_eq!(checkout.get("outcome").unwrap(), "success");
assert_eq!(checkout.get("conclusion").unwrap(), "success");
let outputs = checkout.get("outputs").unwrap().as_object().unwrap();
assert!(outputs.is_empty(), "outputs should be empty: {:?}", outputs);
}
#[test]
fn tojson_steps_outputs_without_status() {
let mut outputs = HashMap::new();
let mut step_out = HashMap::new();
step_out.insert("result".to_string(), "42".to_string());
outputs.insert("compute".to_string(), step_out);
let statuses = HashMap::new();
let ctx = make_steps_ctx(&outputs, &statuses);
let result = evaluate("toJSON(steps)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let compute = parsed.get("compute").unwrap().as_object().unwrap();
assert!(compute.get("outcome").is_none(), "should have no outcome");
assert!(
compute.get("conclusion").is_none(),
"should have no conclusion"
);
let out = compute.get("outputs").unwrap().as_object().unwrap();
assert_eq!(out.get("result").unwrap(), "42");
}
#[test]
fn bare_steps_is_truthy() {
let mut statuses = HashMap::new();
statuses.insert(
"build".to_string(),
("success".to_string(), "success".to_string()),
);
let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
let result = evaluate("steps", &ctx).unwrap();
assert!(result.is_truthy());
}
#[test]
fn tojson_steps_special_characters_in_outputs() {
let mut outputs = HashMap::new();
let mut step_out = HashMap::new();
step_out.insert("msg".to_string(), "he said \"hi\"".to_string());
step_out.insert("path".to_string(), "C:\\Users\\dev".to_string());
step_out.insert("emoji".to_string(), "\u{1F680}".to_string());
outputs.insert("deploy".to_string(), step_out);
let mut statuses = HashMap::new();
statuses.insert(
"deploy".to_string(),
("success".to_string(), "success".to_string()),
);
let ctx = make_steps_ctx(&outputs, &statuses);
let result = evaluate("toJSON(steps)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value =
serde_json::from_str(&s).expect("should be valid JSON despite special chars");
let deploy_outputs = parsed
.get("deploy")
.unwrap()
.get("outputs")
.unwrap()
.as_object()
.unwrap();
assert_eq!(deploy_outputs.get("msg").unwrap(), "he said \"hi\"");
assert_eq!(deploy_outputs.get("path").unwrap(), "C:\\Users\\dev");
assert_eq!(deploy_outputs.get("emoji").unwrap(), "\u{1F680}");
}
fn make_needs_ctx<'a>(
needs_context: &'a HashMap<String, HashMap<String, String>>,
needs_results: &'a HashMap<String, String>,
) -> ExpressionContext<'a> {
ExpressionContext {
env_context: &EMPTY_ENV,
user_env: &EMPTY_USER_ENV,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: &EMPTY_SECRETS,
needs_context,
needs_results,
}
}
#[test]
fn tojson_needs_returns_nested_object() {
let mut needs_ctx = HashMap::new();
let mut build_out = HashMap::new();
build_out.insert("artifact".to_string(), "app.zip".to_string());
build_out.insert("version".to_string(), "1.2.3".to_string());
needs_ctx.insert("build".to_string(), build_out);
let mut test_out = HashMap::new();
test_out.insert("passed".to_string(), "true".to_string());
needs_ctx.insert("test".to_string(), test_out);
let mut needs_res = HashMap::new();
needs_res.insert("build".to_string(), "success".to_string());
needs_res.insert("test".to_string(), "failure".to_string());
let ctx = make_needs_ctx(&needs_ctx, &needs_res);
let result = evaluate("toJSON(needs)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
let build = obj.get("build").unwrap().as_object().unwrap();
assert_eq!(build.get("result").unwrap(), "success");
let build_outputs = build.get("outputs").unwrap().as_object().unwrap();
assert_eq!(build_outputs.get("artifact").unwrap(), "app.zip");
assert_eq!(build_outputs.get("version").unwrap(), "1.2.3");
let test = obj.get("test").unwrap().as_object().unwrap();
assert_eq!(test.get("result").unwrap(), "failure");
let test_outputs = test.get("outputs").unwrap().as_object().unwrap();
assert_eq!(test_outputs.get("passed").unwrap(), "true");
}
#[test]
fn tojson_needs_empty_context() {
let needs_ctx = HashMap::new();
let needs_res = HashMap::new();
let ctx = make_needs_ctx(&needs_ctx, &needs_res);
let result = evaluate("toJSON(needs)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.is_empty(), "should be empty with no needs: {}", s);
}
#[test]
fn tojson_needs_sorted_keys() {
let mut needs_res = HashMap::new();
needs_res.insert("zebra".to_string(), "success".to_string());
needs_res.insert("alpha".to_string(), "success".to_string());
needs_res.insert("middle".to_string(), "success".to_string());
let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
let result = evaluate("toJSON(needs)", &ctx).unwrap();
let s = result.to_output_string();
let alpha_pos = s.find("alpha").unwrap();
let middle_pos = s.find("middle").unwrap();
let zebra_pos = s.find("zebra").unwrap();
assert!(alpha_pos < middle_pos, "alpha should come before middle");
assert!(middle_pos < zebra_pos, "middle should come before zebra");
}
#[test]
fn tojson_needs_result_without_outputs() {
let mut needs_res = HashMap::new();
needs_res.insert("lint".to_string(), "success".to_string());
let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
let result = evaluate("toJSON(needs)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let lint = parsed.get("lint").unwrap().as_object().unwrap();
assert_eq!(lint.get("result").unwrap(), "success");
let outputs = lint.get("outputs").unwrap().as_object().unwrap();
assert!(outputs.is_empty(), "outputs should be empty: {:?}", outputs);
}
#[test]
fn tojson_needs_outputs_without_result() {
let mut needs_ctx = HashMap::new();
let mut job_out = HashMap::new();
job_out.insert("value".to_string(), "42".to_string());
needs_ctx.insert("compute".to_string(), job_out);
let needs_res = HashMap::new();
let ctx = make_needs_ctx(&needs_ctx, &needs_res);
let result = evaluate("toJSON(needs)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let compute = parsed.get("compute").unwrap().as_object().unwrap();
assert!(compute.get("result").is_none(), "should have no result");
let out = compute.get("outputs").unwrap().as_object().unwrap();
assert_eq!(out.get("value").unwrap(), "42");
}
#[test]
fn bare_needs_is_truthy() {
let mut needs_res = HashMap::new();
needs_res.insert("build".to_string(), "success".to_string());
let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
let result = evaluate("needs", &ctx).unwrap();
assert!(result.is_truthy());
}
#[test]
fn tojson_needs_special_characters_in_outputs() {
let mut needs_ctx = HashMap::new();
let mut job_out = HashMap::new();
job_out.insert("msg".to_string(), "he said \"hi\"".to_string());
job_out.insert("path".to_string(), "C:\\Users\\dev".to_string());
job_out.insert("emoji".to_string(), "\u{1F680}".to_string());
needs_ctx.insert("deploy".to_string(), job_out);
let mut needs_res = HashMap::new();
needs_res.insert("deploy".to_string(), "success".to_string());
let ctx = make_needs_ctx(&needs_ctx, &needs_res);
let result = evaluate("toJSON(needs)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value =
serde_json::from_str(&s).expect("should be valid JSON despite special chars");
let deploy_outputs = parsed
.get("deploy")
.unwrap()
.get("outputs")
.unwrap()
.as_object()
.unwrap();
assert_eq!(deploy_outputs.get("msg").unwrap(), "he said \"hi\"");
assert_eq!(deploy_outputs.get("path").unwrap(), "C:\\Users\\dev");
assert_eq!(deploy_outputs.get("emoji").unwrap(), "\u{1F680}");
}
fn make_secrets_ctx(secrets: &HashMap<String, String>) -> ExpressionContext<'_> {
ExpressionContext {
env_context: &EMPTY_ENV,
user_env: &EMPTY_USER_ENV,
step_outputs: &EMPTY_STEPS,
matrix_combination: &EMPTY_MATRIX,
step_statuses: &EMPTY_STATUSES,
job_status: "success",
secrets_context: secrets,
needs_context: &EMPTY_NEEDS,
needs_results: &EMPTY_NEEDS_RESULTS,
}
}
#[test]
fn tojson_secrets_returns_object() {
let mut secrets = HashMap::new();
secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
secrets.insert("DEPLOY_KEY".to_string(), "xyz".to_string());
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("toJSON(secrets)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("NPM_TOKEN").unwrap(), "abc");
assert_eq!(obj.get("DEPLOY_KEY").unwrap(), "xyz");
assert_eq!(obj.len(), 2);
}
#[test]
fn tojson_secrets_empty_context() {
let secrets = HashMap::new();
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("toJSON(secrets)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.is_empty(), "should be empty with no secrets: {}", s);
}
#[test]
fn tojson_secrets_sorted_keys() {
let mut secrets = HashMap::new();
secrets.insert("ZEBRA".to_string(), "z".to_string());
secrets.insert("APPLE".to_string(), "a".to_string());
secrets.insert("MANGO".to_string(), "m".to_string());
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("toJSON(secrets)", &ctx).unwrap();
let s = result.to_output_string();
let apple_pos = s.find("APPLE").unwrap();
let mango_pos = s.find("MANGO").unwrap();
let zebra_pos = s.find("ZEBRA").unwrap();
assert!(apple_pos < mango_pos, "APPLE should come before MANGO");
assert!(mango_pos < zebra_pos, "MANGO should come before ZEBRA");
}
#[test]
fn tojson_secrets_preserves_special_characters() {
let mut secrets = HashMap::new();
secrets.insert("QUOTE".to_string(), "he said \"hi\"".to_string());
secrets.insert("BACKSLASH".to_string(), "path\\to\\key".to_string());
secrets.insert(
"NEWLINE".to_string(),
"-----BEGIN-----\nBODY\n-----END-----".to_string(),
);
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("toJSON(secrets)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value =
serde_json::from_str(&s).expect("should be valid JSON despite special chars");
let obj = parsed.as_object().unwrap();
assert_eq!(obj.get("QUOTE").unwrap(), "he said \"hi\"");
assert_eq!(obj.get("BACKSLASH").unwrap(), "path\\to\\key");
assert_eq!(
obj.get("NEWLINE").unwrap(),
"-----BEGIN-----\nBODY\n-----END-----"
);
}
#[test]
fn fromjson_tojson_secrets_produces_parseable_json() {
let mut secrets = HashMap::new();
secrets.insert("NPM_TOKEN".to_string(), "npm_ABC123".to_string());
secrets.insert("DEPLOY_KEY".to_string(), "deploy_XYZ".to_string());
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("fromJSON(toJSON(secrets))", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("NPM_TOKEN").unwrap(), "npm_ABC123");
assert_eq!(obj.get("DEPLOY_KEY").unwrap(), "deploy_XYZ");
}
#[test]
fn bare_secrets_is_truthy() {
let mut secrets = HashMap::new();
secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("secrets", &ctx).unwrap();
assert!(result.is_truthy());
}
#[test]
fn bare_secrets_does_not_shadow_dotted_access() {
let mut secrets = HashMap::new();
secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("secrets.NPM_TOKEN", &ctx).unwrap();
assert_eq!(result, ExprValue::String("abc".to_string()));
}
#[test]
fn tojson_secrets_returns_values_in_plaintext() {
let mut secrets = HashMap::new();
secrets.insert("GITHUB_TOKEN".to_string(), "ghs_supersecret".to_string());
let ctx = make_secrets_ctx(&secrets);
let result = evaluate("toJSON(secrets)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().unwrap();
assert_eq!(obj.get("GITHUB_TOKEN").unwrap(), "ghs_supersecret");
}
#[test]
fn tojson_matrix_returns_object() {
let mut matrix = HashMap::new();
matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
matrix.insert("node".to_string(), Value::String("20".to_string()));
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("toJSON(matrix)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("os").unwrap(), "ubuntu-latest");
assert_eq!(obj.get("node").unwrap(), "20");
assert_eq!(obj.len(), 2);
}
#[test]
fn tojson_matrix_no_matrix_returns_null() {
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("toJSON(matrix)", &ctx).unwrap();
assert_eq!(result, ExprValue::String("null".to_string()));
}
#[test]
fn tojson_matrix_empty_combination() {
let matrix: Option<HashMap<String, Value>> = Some(HashMap::new());
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("toJSON(matrix)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert!(obj.is_empty(), "should be empty with zero keys: {}", s);
}
#[test]
fn tojson_matrix_mixed_value_types() {
let mut matrix = HashMap::new();
matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
matrix.insert("node".to_string(), Value::Number(20.into()));
matrix.insert("experimental".to_string(), Value::Bool(true));
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("toJSON(matrix)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().unwrap();
assert_eq!(obj.get("os").unwrap(), "ubuntu");
assert_eq!(obj.get("node").unwrap().as_f64().unwrap(), 20.0);
assert!(obj.get("experimental").unwrap().as_bool().unwrap());
}
#[test]
fn tojson_matrix_sorted_keys() {
let mut matrix = HashMap::new();
matrix.insert("zebra".to_string(), Value::String("z".to_string()));
matrix.insert("apple".to_string(), Value::String("a".to_string()));
matrix.insert("mango".to_string(), Value::String("m".to_string()));
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("toJSON(matrix)", &ctx).unwrap();
let s = result.to_output_string();
let apple_pos = s.find("apple").unwrap();
let mango_pos = s.find("mango").unwrap();
let zebra_pos = s.find("zebra").unwrap();
assert!(apple_pos < mango_pos, "apple should come before mango");
assert!(mango_pos < zebra_pos, "mango should come before zebra");
}
#[test]
fn fromjson_tojson_matrix_produces_parseable_json() {
let mut matrix = HashMap::new();
matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
matrix.insert("node".to_string(), Value::String("20".to_string()));
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("fromJSON(toJSON(matrix))", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
assert_eq!(obj.get("os").unwrap(), "ubuntu-latest");
assert_eq!(obj.get("node").unwrap(), "20");
}
#[test]
fn bare_matrix_is_truthy() {
let mut matrix = HashMap::new();
matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("matrix", &ctx).unwrap();
assert!(result.is_truthy());
}
#[test]
fn bare_matrix_when_none_is_null() {
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("matrix", &ctx).unwrap();
assert_eq!(result, ExprValue::Null);
assert!(!result.is_truthy());
}
#[test]
fn bare_matrix_does_not_shadow_dotted_access() {
let mut matrix = HashMap::new();
matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("matrix.os", &ctx).unwrap();
assert_eq!(result, ExprValue::String("ubuntu-latest".to_string()));
}
#[test]
fn tojson_matrix_yaml_sequence_falls_through_to_string() {
let mut matrix = HashMap::new();
matrix.insert(
"versions".to_string(),
Value::Sequence(vec![
Value::String("a".to_string()),
Value::String("b".to_string()),
]),
);
let matrix = Some(matrix);
let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
let result = evaluate("toJSON(matrix)", &ctx).unwrap();
let s = result.to_output_string();
let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
let obj = parsed.as_object().expect("should be a JSON object");
let versions = obj.get("versions").expect("versions key present");
let rendered = versions.as_str().expect("rendered as JSON string");
assert!(
rendered.contains("- a") && rendered.contains("- b"),
"expected YAML sequence rendering, got: {}",
rendered
);
}
#[test]
fn object_cmp_returns_none() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
let result = evaluate("env < env", &ctx).unwrap();
assert!(!result.is_truthy(), "env < env should be false");
let result = evaluate("env > env", &ctx).unwrap();
assert!(!result.is_truthy(), "env > env should be false");
let result = evaluate("env <= env", &ctx).unwrap();
assert!(!result.is_truthy(), "env <= env should be false");
let result = evaluate("env >= env", &ctx).unwrap();
assert!(!result.is_truthy(), "env >= env should be false");
}
}