diff --git a/crates/executor/src/engine.rs b/crates/executor/src/engine.rs index 4024138..fed957b 100644 --- a/crates/executor/src/engine.rs +++ b/crates/executor/src/engine.rs @@ -2096,7 +2096,12 @@ async fn run_step_with_guards( // Check step-level if condition if let Some(if_cond) = &step.if_condition { - let should_run = evaluate_job_condition(if_cond, job_env, workflow); + let should_run = evaluate_condition_with_context( + if_cond, + job_env, + step_exec_ctx.step_outputs, + step_exec_ctx.matrix_combination, + ); if !should_run { wrkflw_logging::info(&format!( " {} Skipping step '{}' due to condition: {}", @@ -2910,8 +2915,9 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result { - // Add command details to output - output.push_str(&format!("Command: {}\n\n", run)); + // Add command details to output (show resolved version so + // users can see expression substitutions were applied) + output.push_str(&format!("Command: {}\n\n", resolved_run)); if !container_output.stdout.is_empty() { output.push_str("Standard Output:\n"); @@ -3972,88 +3978,48 @@ fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result, - workflow: &WorkflowDefinition, + _workflow: &WorkflowDefinition, ) -> bool { + evaluate_condition_with_context(condition, env_context, &HashMap::new(), &None) +} + +/// Evaluate a job/step `if:` condition using the expression evaluator. +/// +/// Accepts the full expression context (env, step outputs, matrix) for accurate +/// resolution of context references and operators. +fn evaluate_condition_with_context( + condition: &str, + env_context: &HashMap, + step_outputs: &HashMap>, + matrix_combination: &Option>, +) -> bool { + use crate::expression::{evaluate_as_bool, ExpressionContext}; + wrkflw_logging::debug(&format!("Evaluating condition: {}", condition)); - // Handle status functions and step references that we can't fully evaluate. - // We default conservatively: only `always()` and `success()` resolve to true, - // since those represent the common "run this step" intent. Bare `steps.*` - // references (e.g. `steps.X.outcome == 'failure'`) default to false to avoid - // running steps that depend on prior failure/output we can't evaluate. - let has_always = condition.contains("always()"); - let has_success = condition.contains("success()"); - let has_failure = condition.contains("failure()"); - let has_cancelled = condition.contains("cancelled()"); - // Match "steps." only at word boundaries to avoid false positives on env var - // names like "env.MY_STEPS_COUNT" or "env._STEPS_CHECK". We check for - // start-of-string or a character that isn't alphanumeric/underscore before "steps.". - let has_steps_ref = condition.match_indices("steps.").any(|(pos, _)| { - pos == 0 || { - let b = condition.as_bytes()[pos - 1]; - !b.is_ascii_alphanumeric() && b != b'_' + let ctx = ExpressionContext { + env_context, + step_outputs, + matrix_combination, + }; + + match evaluate_as_bool(condition, &ctx) { + Ok(result) => { + wrkflw_logging::debug(&format!( + "Condition '{}' evaluated to {}", + condition, result + )); + result } - }); - let has_unsupported = - has_always || has_success || has_failure || has_cancelled || has_steps_ref; - - if has_unsupported { - wrkflw_logging::warning(&format!( - "Condition '{}' uses status functions/step references not fully supported in local execution", - condition - )); - - // In GitHub Actions, `always()` means "run this step regardless of job - // status" — it is a *scheduling* directive, not a boolean `true` literal. - // Similarly, `success()` means "run when all previous steps succeeded". - // Since we can't evaluate actual job/step status locally, we treat - // `always()` and `success()` as "likely to run" → true, and `failure()` - // / `cancelled()` as "unlikely" → false. - // - // Known limitation: compound expressions like `always() && failure()` will - // return true (because `always()` is present) even though a real evaluator - // would AND the two. This is acceptable because we lack step-status context - // and would rather over-run than silently skip steps. - if has_always || has_success { - return true; + Err(e) => { + wrkflw_logging::warning(&format!( + "Failed to evaluate condition '{}': {} — defaulting to true", + condition, e + )); + // Default to true to avoid breaking workflows + true } - // Bare steps.* refs, failure(), cancelled() without positive counterpart → false - return false; } - - // For now, implement basic pattern matching for common conditions - // TODO: Implement a full GitHub Actions expression evaluator - - // Handle simple boolean conditions - if condition == "true" { - return true; - } - if condition == "false" { - return false; - } - - // Handle github.event.pull_request.draft == false - if condition.contains("github.event.pull_request.draft == false") { - // For local execution, assume this is always true (not a draft) - return true; - } - - // Handle needs.jobname.outputs.outputname == 'value' patterns - if condition.contains("needs.") && condition.contains(".outputs.") { - // For now, simulate that outputs are available but empty - // This means conditions like needs.changes.outputs.source-code == 'true' will be false - wrkflw_logging::debug( - "Evaluating needs.outputs condition - defaulting to false for local execution", - ); - return false; - } - - // Default to true for unknown conditions to avoid breaking workflows - wrkflw_logging::warning(&format!( - "Unknown condition pattern: '{}' - defaulting to true", - condition - )); - true } #[cfg(test)] @@ -4425,15 +4391,22 @@ mod tests { } #[test] - fn condition_steps_reference_defaults_false() { + fn condition_steps_reference_evaluates_with_default_outcome() { let env = HashMap::new(); let wf = empty_workflow(); - // Bare step-level expressions default to false (conservative — we can't evaluate them) - assert!(!evaluate_job_condition( + // steps.build.outcome defaults to "success" in local execution since + // we don't track actual step status — so == 'success' is true. + assert!(evaluate_job_condition( "steps.build.outcome == 'success'", &env, &wf )); + // Checking for failure should be false + assert!(!evaluate_job_condition( + "steps.build.outcome == 'failure'", + &env, + &wf + )); } #[test] @@ -4485,37 +4458,37 @@ mod tests { } #[test] - fn condition_always_with_failure_defaults_true() { + fn condition_always_and_failure_evaluates_correctly() { let env = HashMap::new(); let wf = empty_workflow(); - // always() present → true regardless of other functions - assert!(evaluate_job_condition("always() && failure()", &env, &wf)); + // always() → true, failure() → false, true && false → false + // The expression evaluator correctly evaluates the compound expression + assert!(!evaluate_job_condition("always() && failure()", &env, &wf)); + // always() alone → true + assert!(evaluate_job_condition("always()", &env, &wf)); + // always() || failure() → true (|| returns first truthy) + assert!(evaluate_job_condition("always() || failure()", &env, &wf)); } #[test] - fn condition_env_var_containing_steps_not_treated_as_step_ref() { - let env = HashMap::new(); + fn condition_env_context_evaluates_correctly() { + let mut env = HashMap::new(); + env.insert("MY_STEPS_COUNT".to_string(), "5".to_string()); + env.insert("_STEPS_CHECK".to_string(), "ok".to_string()); let wf = empty_workflow(); - // "env.MY_STEPS_COUNT" contains "steps." as a substring but should NOT - // trigger the step-reference heuristic (which returns false). Instead it - // falls through to the unknown-condition default (true). - // A bare "steps.build.outcome" at the start SHOULD be caught. + // env.MY_STEPS_COUNT resolves via the env context, not as a steps ref assert!(evaluate_job_condition( "env.MY_STEPS_COUNT == '5'", &env, &wf )); - // Underscore-prefixed names should also NOT be treated as step refs assert!(evaluate_job_condition( "env._STEPS_CHECK == 'ok'", &env, &wf )); - assert!(!evaluate_job_condition( - "steps.build.outcome == 'success'", - &env, - &wf - )); + // Missing env var → null, null != '5' → false + assert!(!evaluate_job_condition("env.MISSING_VAR == '5'", &env, &wf)); } // --- volume path traversal tests --- diff --git a/crates/executor/src/environment.rs b/crates/executor/src/environment.rs index 8d3e759..a303c27 100644 --- a/crates/executor/src/environment.rs +++ b/crates/executor/src/environment.rs @@ -122,7 +122,11 @@ pub fn create_github_context( // Miscellaneous env.insert("GITHUB_RETENTION_DAYS".to_string(), "90".to_string()); - // Path-related variables + // Runner variables + env.insert("RUNNER_OS".to_string(), get_runner_os()); + env.insert("RUNNER_ARCH".to_string(), get_runner_arch()); + env.insert("RUNNER_NAME".to_string(), "wrkflw-local".to_string()); + env.insert("RUNNER_ENVIRONMENT".to_string(), "local".to_string()); env.insert("RUNNER_TEMP".to_string(), get_temp_dir()); env.insert("RUNNER_TOOL_CACHE".to_string(), get_tool_cache_dir()); @@ -275,6 +279,23 @@ fn get_current_ref() -> String { "refs/heads/main".to_string() } +fn get_runner_os() -> String { + match std::env::consts::OS { + "macos" => "macOS".to_string(), + "linux" => "Linux".to_string(), + "windows" => "Windows".to_string(), + other => other.to_string(), + } +} + +fn get_runner_arch() -> String { + match std::env::consts::ARCH { + "x86_64" | "x86" => "X64".to_string(), + "aarch64" => "ARM64".to_string(), + other => other.to_string(), + } +} + fn get_temp_dir() -> String { let temp_dir = std::env::temp_dir(); temp_dir.join("wrkflw").to_string_lossy().to_string() diff --git a/crates/executor/src/expression.rs b/crates/executor/src/expression.rs new file mode 100644 index 0000000..b3d8088 --- /dev/null +++ b/crates/executor/src/expression.rs @@ -0,0 +1,1185 @@ +//! GitHub Actions expression evaluator. +//! +//! Implements the expression language used inside `${{ }}` blocks in GitHub +//! Actions workflows. Supports context references (`inputs.*`, `env.*`, +//! `github.*`, `runner.*`, `matrix.*`, `steps.*.outputs.*`), operators +//! (`==`, `!=`, `&&`, `||`, `!`, comparisons), string/number/boolean literals, +//! and built-in functions (`contains`, `startsWith`, `endsWith`, `format`, +//! `success`, `failure`, `always`, `cancelled`). + +use serde_yaml::Value; +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// Value type +// --------------------------------------------------------------------------- + +/// Runtime value in the GitHub Actions expression language. +#[derive(Debug, Clone, PartialEq)] +pub enum ExprValue { + String(String), + Number(f64), + Bool(bool), + Null, +} + +impl ExprValue { + /// GitHub Actions truthiness: `false`, `0`, `""`, and `null` are falsy. + 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, + } + } + + /// Coerce to string for substitution output. + 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(), + } + } +} + +// --------------------------------------------------------------------------- +// Tokenizer +// --------------------------------------------------------------------------- + +#[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 [u8], + pos: usize, +} + +impl<'a> Tokenizer<'a> { + fn new(input: &'a str) -> Self { + Self { + input: input.as_bytes(), + pos: 0, + } + } + + fn skip_whitespace(&mut self) { + while self.pos < self.input.len() && self.input[self.pos].is_ascii_whitespace() { + self.pos += 1; + } + } + + fn tokenize(&mut self) -> Result, String> { + let mut tokens = Vec::new(); + loop { + self.skip_whitespace(); + if self.pos >= self.input.len() { + tokens.push(Token::Eof); + return Ok(tokens); + } + let ch = self.input[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() == Some('=') { + tokens.push(Token::Eq); + self.pos += 2; + } else { + return Err(format!("unexpected '=' at position {}", self.pos)); + } + } + '!' => { + if self.peek_next() == Some('=') { + tokens.push(Token::Ne); + self.pos += 2; + } else { + tokens.push(Token::Not); + self.pos += 1; + } + } + '<' => { + if self.peek_next() == Some('=') { + tokens.push(Token::Le); + self.pos += 2; + } else { + tokens.push(Token::Lt); + self.pos += 1; + } + } + '>' => { + if self.peek_next() == Some('=') { + tokens.push(Token::Ge); + self.pos += 2; + } else { + tokens.push(Token::Gt); + self.pos += 1; + } + } + '&' => { + if self.peek_next() == Some('&') { + tokens.push(Token::And); + self.pos += 2; + } else { + return Err(format!("unexpected '&' at position {}", self.pos)); + } + } + '|' => { + if self.peek_next() == Some('|') { + 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), + }); + } + _ => { + return Err(format!( + "unexpected character '{}' at position {}", + ch, self.pos + )) + } + } + } + } + + fn peek_next(&self) -> Option { + if self.pos + 1 < self.input.len() { + Some(self.input[self.pos + 1] as char) + } else { + None + } + } + + fn read_string(&mut self) -> Result { + self.pos += 1; // skip opening quote + let mut s = String::new(); + while self.pos < self.input.len() { + let ch = self.input[self.pos] as char; + if ch == '\'' { + // Check for escaped quote ('') + if self.peek_next() == Some('\'') { + s.push('\''); + self.pos += 2; + } else { + self.pos += 1; // skip closing quote + return Ok(Token::StringLit(s)); + } + } else { + s.push(ch); + self.pos += 1; + } + } + Err("unterminated string literal".to_string()) + } + + fn read_number(&mut self) -> Result { + let start = self.pos; + while self.pos < self.input.len() + && (self.input[self.pos].is_ascii_digit() || self.input[self.pos] == b'.') + { + self.pos += 1; + } + let s = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|e| format!("invalid number: {}", e))?; + 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; + while self.pos < self.input.len() { + let ch = self.input[self.pos]; + if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-' { + self.pos += 1; + } else { + break; + } + } + String::from_utf8_lossy(&self.input[start..self.pos]).to_string() + } +} + +// --------------------------------------------------------------------------- +// Expression context +// --------------------------------------------------------------------------- + +/// Provides variable resolution for expression evaluation. +pub struct ExpressionContext<'a> { + pub env_context: &'a HashMap, + pub step_outputs: &'a HashMap>, + pub matrix_combination: &'a Option>, +} + +impl<'a> ExpressionContext<'a> { + /// Resolve a dotted context reference like `inputs.toolchain` or + /// `steps.build.outputs.version`. + 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].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), + "steps" if parts.len() == 3 && parts[2] == "outcome" => { + // We don't track step outcome; default to "success" + ExprValue::String("success".to_string()) + } + "steps" if parts.len() == 3 && parts[2] == "conclusion" => { + ExprValue::String("success".to_string()) + } + _ => 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(format!("{:?}", v)), + } +} + +// --------------------------------------------------------------------------- +// Parser + Evaluator (recursive descent) +// --------------------------------------------------------------------------- + +struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + fn new(tokens: Vec) -> 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)) + } + } + + // Grammar: expr = or_expr + fn parse_expr(&mut self, ctx: &ExpressionContext) -> Result { + self.parse_or(ctx) + } + + // or_expr = and_expr ( '||' and_expr )* + fn parse_or(&mut self, ctx: &ExpressionContext) -> Result { + let mut left = self.parse_and(ctx)?; + while *self.peek() == Token::Or { + self.advance(); + let right = self.parse_and(ctx)?; + // GitHub Actions || returns the first truthy value, or the last value + left = if left.is_truthy() { left } else { right }; + } + Ok(left) + } + + // and_expr = comparison ( '&&' comparison )* + fn parse_and(&mut self, ctx: &ExpressionContext) -> Result { + let mut left = self.parse_comparison(ctx)?; + while *self.peek() == Token::And { + self.advance(); + let right = self.parse_comparison(ctx)?; + // GitHub Actions && returns the first falsy value, or the last value + left = if !left.is_truthy() { left } else { right }; + } + Ok(left) + } + + // comparison = unary ( ('==' | '!=' | '<' | '<=' | '>' | '>=') unary )? + fn parse_comparison(&mut self, ctx: &ExpressionContext) -> Result { + 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), + } + } + + // unary = '!' unary | primary + fn parse_unary(&mut self, ctx: &ExpressionContext) -> Result { + if *self.peek() == Token::Not { + self.advance(); + let val = self.parse_unary(ctx)?; + Ok(ExprValue::Bool(!val.is_truthy())) + } else { + self.parse_primary(ctx) + } + } + + // primary = literal | '(' expr ')' | ident_or_call + fn parse_primary(&mut self, ctx: &ExpressionContext) -> Result { + 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)), + } + } + + // ident_or_call: + // ident '(' args ')' => function call + // ident ('.' ident)* => context reference + fn parse_ident_or_call(&mut self, ctx: &ExpressionContext) -> Result { + let Token::Ident(name) = self.advance() else { + return Err("expected identifier".to_string()); + }; + + // Function call? + if *self.peek() == Token::LParen { + self.advance(); // consume '(' + 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); + } + + // Context reference: ident.ident.ident... + let mut parts = vec![name]; + while *self.peek() == Token::Dot { + self.advance(); // consume '.' + match self.advance() { + Token::Ident(part) => parts.push(part), + other => return Err(format!("expected identifier after '.', got {:?}", other)), + } + } + + Ok(ctx.resolve(&parts)) + } +} + +// --------------------------------------------------------------------------- +// Comparison helpers +// --------------------------------------------------------------------------- + +fn expr_eq(a: &ExprValue, b: &ExprValue) -> bool { + // GitHub Actions does loose type coercion for == + 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), + // Coerce number to string for comparison + (ExprValue::String(s), ExprValue::Number(n)) + | (ExprValue::Number(n), ExprValue::String(s)) => { + if let Ok(parsed) = s.parse::() { + (parsed - n).abs() < f64::EPSILON + } else { + false + } + } + // Coerce bool to number: true=1, false=0 + (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)) => { + // GitHub Actions coerces strings to booleans for comparison: + // "true" (case-insensitive) → true, everything else → false. + // This means `false == "random"` is true (both coerce to false). + let sv = s.eq_ignore_ascii_case("true"); + *b == sv + } + } +} + +fn expr_cmp(a: &ExprValue, b: &ExprValue) -> Option { + 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())) + } + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Built-in functions +// --------------------------------------------------------------------------- + +fn call_builtin(name: &str, args: &[ExprValue]) -> Result { + 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 = fmt; + for (i, arg) in args.iter().skip(1).enumerate() { + result = result.replace(&format!("{{{}}}", i), &arg.to_output_string()); + } + 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() + }; + // Best-effort: just return the value as-is since we don't have arrays + 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(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())), + } + } + "fromJSON" | "fromjson" => { + if args.len() != 1 { + return Err("fromJSON() requires 1 argument".to_string()); + } + let s = args[0].to_output_string(); + // Basic parsing + match s.as_str() { + "null" => Ok(ExprValue::Null), + "true" => Ok(ExprValue::Bool(true)), + "false" => Ok(ExprValue::Bool(false)), + _ => { + if let Ok(n) = s.parse::() { + Ok(ExprValue::Number(n)) + } else { + // Strip quotes if present + let stripped = s.trim_matches('"'); + Ok(ExprValue::String(stripped.to_string())) + } + } + } + } + // Status functions — in local execution we assume success + "success" => Ok(ExprValue::Bool(true)), + "failure" => Ok(ExprValue::Bool(false)), + "always" => Ok(ExprValue::Bool(true)), + "cancelled" => Ok(ExprValue::Bool(false)), + _ => { + // Unknown function — return null rather than erroring + Ok(ExprValue::Null) + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Evaluate a GitHub Actions expression string and return the result. +/// +/// The expression should be the content inside `${{ ... }}` (without the +/// delimiters). Returns `Err` on parse/evaluation errors. +pub fn evaluate(expr: &str, ctx: &ExpressionContext) -> Result { + 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)?; + // Ensure we consumed all tokens + if *parser.peek() != Token::Eof { + return Err(format!( + "unexpected token after expression: {:?}", + parser.peek() + )); + } + Ok(result) +} + +/// Evaluate a GitHub Actions expression and return it as a boolean. +/// +/// Used for `if:` conditions. Strips `${{ }}` wrappers if present. +pub fn evaluate_as_bool(expr: &str, ctx: &ExpressionContext) -> Result { + let trimmed = expr.trim(); + // Strip ${{ }} if present + 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()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_ctx() -> ExpressionContext<'static> { + // Leak to get 'static — fine for tests + let env: &'static HashMap = Box::leak(Box::new(HashMap::new())); + let steps: &'static HashMap> = + Box::leak(Box::new(HashMap::new())); + let matrix: &'static Option> = Box::leak(Box::new(None)); + ExpressionContext { + env_context: env, + step_outputs: steps, + matrix_combination: matrix, + } + } + + fn ctx_with( + env: HashMap, + steps: HashMap>, + matrix: Option>, + ) -> ( + ExpressionContext<'static>, + // Drop guards to prevent leaks in tests — not strictly needed since + // we're leaking anyway, but makes the pattern explicit + ) { + let env: &'static _ = Box::leak(Box::new(env)); + let steps: &'static _ = Box::leak(Box::new(steps)); + let matrix: &'static _ = Box::leak(Box::new(matrix)); + (ExpressionContext { + env_context: env, + step_outputs: steps, + matrix_combination: matrix, + },) + } + + // -- Literals -- + + #[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); + } + + // -- Truthiness -- + + #[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()); + } + + // -- Operators -- + + #[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(); + // GitHub Actions coerces strings to booleans: "true" → true, everything else → false. + // So false == "random" is true because "random" coerces to false. + 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(); + // && returns first falsy or last value + 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(); + // || returns first truthy or last value + 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) + ); + } + + // -- Context resolution -- + + #[test] + fn eval_inputs_context() { + let mut env = HashMap::new(); + env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string()); + let (ctx,) = ctx_with(env, HashMap::new(), 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 (ctx,) = ctx_with(env, HashMap::new(), 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 (ctx,) = ctx_with(env, HashMap::new(), 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 (ctx,) = ctx_with(HashMap::new(), 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 (ctx,) = ctx_with(HashMap::new(), HashMap::new(), Some(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 + ); + } + + // -- Complex expressions (the dtolnay/rust-toolchain pattern) -- + + #[test] + fn eval_rust_toolchain_pattern() { + // ${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }} + 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,) = ctx_with(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,) = ctx_with(env, steps, None); + + let result = evaluate( + "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''", + &ctx, + ) + .unwrap(); + // 'stable' != 'nightly' → false, && short-circuits, || returns '' + assert_eq!(result, ExprValue::String(String::new())); + } + + #[test] + fn eval_rust_toolchain_pattern_no_components() { + let env = HashMap::new(); // no INPUT_COMPONENTS + + 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,) = ctx_with(env, steps, None); + + let result = evaluate( + "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''", + &ctx, + ) + .unwrap(); + // toolchain == nightly → true, inputs.components → null (falsy), && returns null, || returns '' + assert_eq!(result, ExprValue::String(String::new())); + } + + // -- Built-in functions -- + + #[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_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) + ); + } + + // -- evaluate_as_bool -- + + #[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 (ctx,) = ctx_with(env, HashMap::new(), None); + + assert!(evaluate_as_bool("startsWith(github.ref, 'refs/tags/')", &ctx).unwrap()); + } + + // -- Output string formatting -- + + #[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.14).to_output_string(), "3.14"); + assert_eq!(ExprValue::Bool(true).to_output_string(), "true"); + assert_eq!(ExprValue::Null.to_output_string(), ""); + } + + // -- Error cases -- + + #[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); + } +} diff --git a/crates/executor/src/lib.rs b/crates/executor/src/lib.rs index 98ad0e1..7c64f07 100644 --- a/crates/executor/src/lib.rs +++ b/crates/executor/src/lib.rs @@ -7,6 +7,7 @@ pub mod dependency; pub mod docker; pub mod engine; pub mod environment; +pub mod expression; pub mod github_env_files; pub mod podman; pub mod substitution; diff --git a/crates/executor/src/substitution.rs b/crates/executor/src/substitution.rs index 65ef739..9af011a 100644 --- a/crates/executor/src/substitution.rs +++ b/crates/executor/src/substitution.rs @@ -10,12 +10,10 @@ lazy_static! { Regex::new(r"\$\{\{\s*matrix\.([a-zA-Z0-9_]+)\s*\}\}").unwrap(); static ref HASH_FILES_PATTERN: Regex = Regex::new(r"\$\{\{\s*hashFiles\(([^)]+)\)\s*\}\}").unwrap(); - static ref STEPS_OUTPUT_PATTERN: Regex = Regex::new( - r"\$\{\{\s*steps\.([a-zA-Z_][a-zA-Z0-9_-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\s*\}\}" - ) - .unwrap(); - static ref ENV_CONTEXT_PATTERN: Regex = - Regex::new(r"\$\{\{\s*env\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap(); + /// Matches any `${{ ... }}` expression. Handles single `}` inside format + /// placeholders like `{0}` by requiring the closing `}}` pair. + static ref EXPRESSION_PATTERN: Regex = + Regex::new(r"\$\{\{(?:[^}]|\}[^}])*\}\}").unwrap(); } /// Preprocesses a command string to replace GitHub-style matrix variable references @@ -58,38 +56,6 @@ pub fn process_step_run(run: &str, matrix_combination: &Option.outputs. }}` with the corresponding step output value. -/// -/// Missing step IDs or output keys resolve to an empty string, matching GitHub Actions behavior. -pub fn preprocess_step_outputs( - text: &str, - step_outputs: &HashMap>, -) -> String { - STEPS_OUTPUT_PATTERN - .replace_all(text, |caps: ®ex::Captures| { - let step_id = &caps[1]; - let output_key = &caps[2]; - step_outputs - .get(step_id) - .and_then(|m| m.get(output_key)) - .cloned() - .unwrap_or_default() - }) - .into_owned() -} - -/// Replace `${{ env. }}` with the value of the environment variable. -/// -/// Missing variables resolve to an empty string, matching GitHub Actions behavior. -pub fn preprocess_env_context(text: &str, env: &HashMap) -> String { - ENV_CONTEXT_PATTERN - .replace_all(text, |caps: ®ex::Captures| { - let var_name = &caps[1]; - env.get(var_name).cloned().unwrap_or_default() - }) - .into_owned() -} - /// Replace `${{ hashFiles(...) }}` expressions with the SHA-256 hash of matched files. /// /// Accepts one or more comma-separated, quoted glob patterns. Files are matched @@ -182,7 +148,15 @@ fn compute_hash_files(args_raw: &str, workspace: &Path) -> Result>, env_context: &HashMap, ) -> Result { - // Resolve hashFiles first (needs filesystem access) + use crate::expression::{evaluate, ExpressionContext}; + + // Resolve hashFiles first (needs filesystem access not available in the + // expression evaluator) let result = preprocess_hash_files(text, workspace)?; - // Then resolve step outputs and env context - let result = preprocess_step_outputs(&result, step_outputs); - let result = preprocess_env_context(&result, env_context); - // Finally resolve matrix variables - Ok(if let Some(matrix) = matrix_combination { - preprocess_command(&result, matrix) - } else { - MATRIX_PATTERN - .replace_all(&result, |caps: ®ex::Captures| { - let var_name = &caps[1]; - format!("\\${{{{ matrix.{} }}}}", var_name) - }) - .to_string() - }) + + let ctx = ExpressionContext { + env_context, + step_outputs, + matrix_combination, + }; + + // Evaluate all remaining ${{ ... }} expressions through the expression evaluator + let result = EXPRESSION_PATTERN + .replace_all(&result, |caps: ®ex::Captures| { + let full_match = &caps[0]; + // Extract the inner expression (strip "${{" and "}}") + let inner = &full_match[3..full_match.len() - 2]; + match evaluate(inner, &ctx) { + Ok(val) => val.to_output_string(), + Err(e) => { + wrkflw_logging::debug(&format!( + "Expression evaluation failed for '{}': {} — substituting empty string", + inner.trim(), + e + )); + String::new() + } + } + }) + .into_owned(); + + Ok(result) } #[cfg(test)] @@ -390,56 +381,74 @@ mod tests { assert!(result.unwrap_err().contains("path traversal")); } + // -- step outputs via expression evaluator -- + #[test] fn step_output_substitution() { + let dir = tempdir().unwrap(); let mut step_outputs = HashMap::new(); let mut build_outputs = HashMap::new(); build_outputs.insert("version".to_string(), "1.2.3".to_string()); step_outputs.insert("build".to_string(), build_outputs); let text = "Version is ${{ steps.build.outputs.version }}"; - let result = preprocess_step_outputs(text, &step_outputs); + let result = + preprocess_expressions(text, dir.path(), &None, &step_outputs, &HashMap::new()) + .unwrap(); assert_eq!(result, "Version is 1.2.3"); } #[test] fn step_output_missing_returns_empty() { - let step_outputs = HashMap::new(); + let dir = tempdir().unwrap(); let text = "Value: ${{ steps.unknown.outputs.key }}"; - let result = preprocess_step_outputs(text, &step_outputs); + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new()) + .unwrap(); assert_eq!(result, "Value: "); } #[test] fn step_output_missing_key_returns_empty() { + let dir = tempdir().unwrap(); let mut step_outputs = HashMap::new(); step_outputs.insert("build".to_string(), HashMap::new()); let text = "${{ steps.build.outputs.missing }}"; - let result = preprocess_step_outputs(text, &step_outputs); + let result = + preprocess_expressions(text, dir.path(), &None, &step_outputs, &HashMap::new()) + .unwrap(); assert_eq!(result, ""); } + // -- env context via expression evaluator -- + #[test] fn env_context_substitution() { + let dir = tempdir().unwrap(); let mut env = HashMap::new(); env.insert("MY_VAR".to_string(), "hello".to_string()); let text = "Value: ${{ env.MY_VAR }}"; - let result = preprocess_env_context(text, &env); + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); assert_eq!(result, "Value: hello"); } #[test] fn env_context_missing_returns_empty() { - let env = HashMap::new(); + let dir = tempdir().unwrap(); let text = "${{ env.MISSING }}"; - let result = preprocess_env_context(text, &env); + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new()) + .unwrap(); assert_eq!(result, ""); } + // -- combined contexts -- + #[test] fn combined_substitutions() { let dir = tempdir().unwrap(); @@ -461,4 +470,175 @@ mod tests { preprocess_expressions(text, dir.path(), &Some(matrix), &step_outputs, &env).unwrap(); assert_eq!(result, "ubuntu-v1-true"); } + + // -- inputs context via expression evaluator -- + + #[test] + fn inputs_context_substitution() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("INPUT_TOOLCHAIN".to_string(), "stable".to_string()); + env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string()); + + let text = + "rustup toolchain install ${{ inputs.toolchain }} --component ${{ inputs.components }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); + assert_eq!( + result, + "rustup toolchain install stable --component rustfmt" + ); + } + + #[test] + fn inputs_context_hyphenated_name() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("INPUT_NODE_VERSION".to_string(), "18".to_string()); + + let text = "${{ inputs.node-version }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); + assert_eq!(result, "18"); + } + + #[test] + fn inputs_context_missing_returns_empty() { + let dir = tempdir().unwrap(); + + let text = "${{ inputs.missing }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new()) + .unwrap(); + assert_eq!(result, ""); + } + + // -- github context via expression evaluator -- + + #[test] + fn github_context_substitution() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string()); + env.insert("GITHUB_REF_NAME".to_string(), "main".to_string()); + + let text = "${{ github.repository }}/${{ github.ref_name }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); + assert_eq!(result, "owner/repo/main"); + } + + #[test] + fn github_context_missing_returns_empty() { + let dir = tempdir().unwrap(); + + let text = "${{ github.token }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new()) + .unwrap(); + assert_eq!(result, ""); + } + + // -- runner context via expression evaluator -- + + #[test] + fn runner_context_substitution() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("RUNNER_OS".to_string(), "Linux".to_string()); + env.insert("RUNNER_TEMP".to_string(), "/tmp/runner".to_string()); + + let text = "${{ runner.os }} ${{ runner.temp }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); + assert_eq!(result, "Linux /tmp/runner"); + } + + #[test] + fn runner_context_missing_returns_empty() { + let dir = tempdir().unwrap(); + + let text = "${{ runner.arch }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new()) + .unwrap(); + assert_eq!(result, ""); + } + + // -- all contexts via preprocess_expressions -- + + #[test] + fn preprocess_expressions_includes_inputs_and_github() { + let dir = tempdir().unwrap(); + + let mut env = HashMap::new(); + env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string()); + env.insert("GITHUB_REPOSITORY".to_string(), "foo/bar".to_string()); + env.insert("RUNNER_OS".to_string(), "Linux".to_string()); + + let text = "${{ inputs.toolchain }}-${{ github.repository }}-${{ runner.os }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); + assert_eq!(result, "nightly-foo/bar-Linux"); + } + + #[test] + fn preprocess_expressions_unknown_context_returns_empty() { + let dir = tempdir().unwrap(); + + let text = "echo ${{ unknown_context.value }}"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new()) + .unwrap(); + assert_eq!(result, "echo "); + } + + // -- complex expressions -- + + #[test] + fn preprocess_expressions_evaluates_complex_expression() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string()); + + let mut step_outputs = HashMap::new(); + let mut parse_out = HashMap::new(); + parse_out.insert("toolchain".to_string(), "nightly".to_string()); + step_outputs.insert("parse".to_string(), parse_out); + + // This is the dtolnay/rust-toolchain pattern that triggered the original bug + let text = "rustup toolchain install nightly${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}"; + let result = preprocess_expressions(text, dir.path(), &None, &step_outputs, &env).unwrap(); + assert_eq!(result, "rustup toolchain install nightly --allow-downgrade"); + } + + #[test] + fn preprocess_expressions_evaluates_comparison_to_empty() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string()); + + let mut step_outputs = HashMap::new(); + let mut parse_out = HashMap::new(); + parse_out.insert("toolchain".to_string(), "stable".to_string()); + step_outputs.insert("parse".to_string(), parse_out); + + let text = "rustup toolchain install stable${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}"; + let result = preprocess_expressions(text, dir.path(), &None, &step_outputs, &env).unwrap(); + // stable != nightly, so the expression evaluates to '' + assert_eq!(result, "rustup toolchain install stable"); + } + + #[test] + fn preprocess_expressions_no_spaces() { + let dir = tempdir().unwrap(); + let mut env = HashMap::new(); + env.insert("RUNNER_OS".to_string(), "Linux".to_string()); + + // dtolnay/rust-toolchain uses ${{runner.os}} without spaces + let text = "if [[ ${{runner.os}} == macOS ]]; then echo mac; fi"; + let result = + preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap(); + assert_eq!(result, "if [[ Linux == macOS ]]; then echo mac; fi"); + } }