use crate::ast::{AccessSegment, BinaryOp, Expr, UnaryOp};
use crate::model::{
Check, EvaluationReceipt, ReceiptSummary, SCHEMA_VERSION, ToolInfo, value_type,
};
use crate::parser::{parse_expression, unwrap_expression};
use crate::template::render_template;
use crate::value::{GhaValue, loose_compare, loose_equal, string_for_render};
use crate::{TOOL_NAME, TOOL_VERSION};
use anyhow::{Context, Result, anyhow, bail, ensure};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::Utc;
use globset::{Glob, GlobSetBuilder};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::fs;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct EvaluationOptions {
pub context: Value,
pub workspace: Option<Utf8PathBuf>,
pub if_condition: bool,
pub job_status: JobStatus,
}
impl Default for EvaluationOptions {
fn default() -> Self {
Self {
context: Value::Object(Map::new()),
workspace: None,
if_condition: false,
job_status: JobStatus::Success,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
Success,
Failure,
Cancelled,
}
#[derive(Debug, Default)]
pub struct ContextBuilder {
root: Map<String, Value>,
}
impl ContextBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn insert_context(&mut self, name: impl Into<String>, value: Value) {
self.root.insert(name.into(), value);
}
pub fn insert_root_object(&mut self, value: Value) -> Result<()> {
let Value::Object(object) = value else {
bail!("root context file must contain a JSON object");
};
for (key, value) in object {
self.root.insert(key, value);
}
Ok(())
}
pub fn insert_eventsmith_github_context(&mut self, value: Value) -> Result<()> {
let Value::Object(_) = value else {
bail!("github context file must contain a JSON object");
};
self.root.insert("github".to_owned(), value);
Ok(())
}
pub fn insert_github_event(&mut self, event: Value) {
let github = self
.root
.entry("github".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(github) = github {
github.insert("event".to_owned(), event);
}
}
pub fn insert_context_value(&mut self, name: &str, value: Value) -> Result<()> {
ensure!(
is_context_name(name),
"context name must start with a letter or '_' and contain only alphanumeric, '_' or '-' characters"
);
self.root.insert(name.to_owned(), value);
Ok(())
}
pub fn build(self) -> Value {
Value::Object(self.root)
}
}
pub fn evaluate_expression(expression: &str, options: &EvaluationOptions) -> EvaluationReceipt {
let normalized = unwrap_expression(expression);
let mut checks = Vec::new();
let mut functions = Vec::new();
let mut references = Vec::new();
let mut result = None;
let mut result_string = None;
let mut result_type = None;
let mut truthy = None;
match parse_expression(&normalized) {
Ok(expr) => {
checks.push(Check::pass(
"expression.syntax",
"expression parsed successfully",
));
expr.collect_functions(&mut functions);
expr.collect_roots(&mut references);
sort_dedupe(&mut functions);
sort_dedupe(&mut references);
if options.if_condition && !expr.contains_status_function() {
checks.push(Check::pass(
"expression.if.default_status",
"applied implicit success() status check for if-condition mode",
));
} else {
checks.push(Check::skip(
"expression.if.default_status",
"implicit success() status check not applied",
));
}
let mut evaluator = Evaluator::new(options);
let evaluation = evaluator.eval_with_if_default(&expr);
checks.extend(evaluator.into_checks());
match evaluation {
Ok(value) => {
checks.push(Check::pass(
"expression.evaluate",
"expression evaluated successfully",
));
truthy = Some(value.truthy());
result_type = Some(value_type(&value.json).to_owned());
result_string = Some(string_for_render(&value.json));
result = Some(value.json);
}
Err(error) => {
checks.push(Check::fail("expression.evaluate", error.to_string()));
}
}
}
Err(error) => {
checks.push(Check::fail("expression.syntax", error.to_string()));
checks.push(Check::skip(
"expression.if.default_status",
"expression did not parse",
));
checks.push(Check::skip(
"expression.evaluate",
"expression did not parse",
));
}
}
let contexts = context_names(&options.context);
receipt(ReceiptParts {
mode: "expression",
expression: Some(normalized),
template: None,
rendered: None,
result,
result_string,
result_type,
truthy,
contexts,
functions,
references,
checks,
})
}
pub fn evaluate_template(template: &str, options: &EvaluationOptions) -> EvaluationReceipt {
let mut checks = Vec::new();
let mut functions = Vec::new();
let mut references = Vec::new();
match render_template(template, options, &mut functions, &mut references) {
Ok(rendered) => {
sort_dedupe(&mut functions);
sort_dedupe(&mut references);
checks.push(Check::pass(
"template.syntax",
"template parsed successfully",
));
checks.push(Check::pass(
"template.evaluate",
"template expressions evaluated successfully",
));
receipt(ReceiptParts {
mode: "template",
expression: None,
template: Some(template.to_owned()),
rendered: Some(rendered),
result: None,
result_string: None,
result_type: None,
truthy: None,
contexts: context_names(&options.context),
functions,
references,
checks,
})
}
Err(error) => {
checks.push(Check::fail("template.evaluate", error.to_string()));
receipt(ReceiptParts {
mode: "template",
expression: None,
template: Some(template.to_owned()),
rendered: None,
result: None,
result_string: None,
result_type: None,
truthy: None,
contexts: context_names(&options.context),
functions,
references,
checks,
})
}
}
}
pub(crate) struct Evaluator<'a> {
options: &'a EvaluationOptions,
checks: Vec<Check>,
hash_files_used: bool,
}
impl<'a> Evaluator<'a> {
pub(crate) fn new(options: &'a EvaluationOptions) -> Self {
Self {
options,
checks: Vec::new(),
hash_files_used: false,
}
}
pub(crate) fn eval_for_template(&mut self, expr: &Expr) -> Result<GhaValue> {
self.eval(expr)
}
fn eval_with_if_default(&mut self, expr: &Expr) -> Result<GhaValue> {
let value = self.eval(expr)?;
if self.options.if_condition && !expr.contains_status_function() {
Ok(GhaValue::new(Value::Bool(
self.status_success() && value.truthy(),
)))
} else {
Ok(value)
}
}
fn into_checks(mut self) -> Vec<Check> {
if self.hash_files_used {
self.checks.push(Check::pass(
"expression.hash_files",
"hashFiles() evaluated in offline workspace mode",
));
} else {
self.checks.push(Check::skip(
"expression.hash_files",
"hashFiles() was not used",
));
}
self.checks
}
fn eval(&mut self, expr: &Expr) -> Result<GhaValue> {
match expr {
Expr::Literal(value) => Ok(GhaValue::new(value.clone())),
Expr::Variable(name) => Ok(self.variable(name)),
Expr::Unary {
op: UnaryOp::Not,
expr,
} => Ok(GhaValue::new(Value::Bool(!self.eval(expr)?.truthy()))),
Expr::Binary { op, left, right } => self.binary(*op, left, right),
Expr::Call { name, args } => self.call(name, args),
Expr::Access { base, segment } => {
let base = self.eval(base)?;
self.access(base, segment)
}
}
}
fn variable(&self, name: &str) -> GhaValue {
self.options
.context
.get(name)
.cloned()
.map(|value| GhaValue::with_origin(value, name.to_owned()))
.unwrap_or_else(GhaValue::missing)
}
fn binary(&mut self, op: BinaryOp, left: &Expr, right: &Expr) -> Result<GhaValue> {
match op {
BinaryOp::And => {
let left = self.eval(left)?;
if left.truthy() {
self.eval(right)
} else {
Ok(left)
}
}
BinaryOp::Or => {
let left = self.eval(left)?;
if left.truthy() {
Ok(left)
} else {
self.eval(right)
}
}
BinaryOp::Eq | BinaryOp::Ne => {
let left = self.eval(left)?;
let right = self.eval(right)?;
let equal = loose_equal(&left, &right);
Ok(GhaValue::new(Value::Bool(if matches!(op, BinaryOp::Eq) {
equal
} else {
!equal
})))
}
BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
let left = self.eval(left)?;
let right = self.eval(right)?;
let ordering = loose_compare(&left, &right);
let value = matches!(
(op, ordering),
(BinaryOp::Lt, Some(Ordering::Less))
| (BinaryOp::Le, Some(Ordering::Less | Ordering::Equal))
| (BinaryOp::Gt, Some(Ordering::Greater))
| (BinaryOp::Ge, Some(Ordering::Greater | Ordering::Equal))
);
Ok(GhaValue::new(Value::Bool(value)))
}
}
}
fn access(&mut self, base: GhaValue, segment: &AccessSegment) -> Result<GhaValue> {
match segment {
AccessSegment::Property(property) => Ok(access_property(base, property)),
AccessSegment::Wildcard => Ok(wildcard(base)),
AccessSegment::Index(index) => {
let index = self.eval(index)?;
Ok(access_index(base, &index.json))
}
}
}
fn call(&mut self, name: &str, args: &[Expr]) -> Result<GhaValue> {
match name.to_ascii_lowercase().as_str() {
"contains" => {
ensure!(args.len() == 2, "contains() expects 2 arguments");
let search = self.eval(&args[0])?;
let item = self.eval(&args[1])?;
Ok(GhaValue::new(Value::Bool(contains(
&search.json,
&item.json,
))))
}
"startswith" => {
ensure!(args.len() == 2, "startsWith() expects 2 arguments");
let search = self.eval(&args[0])?;
let item = self.eval(&args[1])?;
Ok(GhaValue::new(Value::Bool(
lowercase_string(&search.json).starts_with(&lowercase_string(&item.json)),
)))
}
"endswith" => {
ensure!(args.len() == 2, "endsWith() expects 2 arguments");
let search = self.eval(&args[0])?;
let item = self.eval(&args[1])?;
Ok(GhaValue::new(Value::Bool(
lowercase_string(&search.json).ends_with(&lowercase_string(&item.json)),
)))
}
"format" => {
ensure!(args.len() >= 2, "format() expects at least 2 arguments");
let template = self.eval(&args[0])?;
let replacements = args[1..]
.iter()
.map(|arg| self.eval(arg).map(|value| string_for_render(&value.json)))
.collect::<Result<Vec<_>>>()?;
Ok(GhaValue::new(Value::String(format_function(
&string_for_render(&template.json),
&replacements,
)?)))
}
"join" => {
ensure!(
args.len() == 1 || args.len() == 2,
"join() expects 1 or 2 arguments"
);
let value = self.eval(&args[0])?;
let separator = if let Some(arg) = args.get(1) {
string_for_render(&self.eval(arg)?.json)
} else {
",".to_owned()
};
Ok(GhaValue::new(Value::String(join_function(
&value.json,
&separator,
))))
}
"tojson" => {
ensure!(args.len() == 1, "toJSON() expects 1 argument");
Ok(GhaValue::new(Value::String(serde_json::to_string_pretty(
&self.eval(&args[0])?.json,
)?)))
}
"fromjson" => {
ensure!(args.len() == 1, "fromJSON() expects 1 argument");
let value = self.eval(&args[0])?;
let raw = string_for_render(&value.json);
Ok(GhaValue::new(
serde_json::from_str(&raw)
.with_context(|| "fromJSON() input is not valid JSON")?,
))
}
"hashfiles" => {
ensure!(!args.is_empty(), "hashFiles() expects at least 1 argument");
let patterns = args
.iter()
.map(|arg| self.eval(arg).map(|value| string_for_render(&value.json)))
.collect::<Result<Vec<_>>>()?;
self.hash_files_used = true;
Ok(GhaValue::new(Value::String(hash_files(
self.options.workspace.as_deref(),
&patterns,
)?)))
}
"case" => self.case_function(args),
"success" => {
ensure!(args.is_empty(), "success() expects no arguments");
Ok(GhaValue::new(Value::Bool(self.status_success())))
}
"failure" => {
ensure!(args.is_empty(), "failure() expects no arguments");
Ok(GhaValue::new(Value::Bool(matches!(
self.options.job_status,
JobStatus::Failure
))))
}
"cancelled" => {
ensure!(args.is_empty(), "cancelled() expects no arguments");
Ok(GhaValue::new(Value::Bool(matches!(
self.options.job_status,
JobStatus::Cancelled
))))
}
"always" => {
ensure!(args.is_empty(), "always() expects no arguments");
Ok(GhaValue::new(Value::Bool(true)))
}
other => bail!("unsupported function `{other}`"),
}
}
fn case_function(&mut self, args: &[Expr]) -> Result<GhaValue> {
ensure!(
args.len() >= 3 && args.len() % 2 == 1,
"case() expects predicate/value pairs followed by a default"
);
for pair in args[..args.len() - 1].chunks(2) {
if self.eval(&pair[0])?.truthy() {
return self.eval(&pair[1]);
}
}
self.eval(args.last().expect("validated non-empty args"))
}
fn status_success(&self) -> bool {
matches!(self.options.job_status, JobStatus::Success)
}
}
fn access_property(base: GhaValue, property: &str) -> GhaValue {
match base.json {
Value::Object(object) => object
.get(property)
.cloned()
.map(|value| {
let origin = base
.origin
.as_ref()
.map(|origin| format!("{origin}.{property}"))
.unwrap_or_else(|| property.to_owned());
GhaValue::with_origin(value, origin)
})
.unwrap_or_else(GhaValue::missing),
Value::Array(values) => {
let mapped = values
.into_iter()
.map(|value| access_property(GhaValue::new(value), property).json)
.collect::<Vec<_>>();
GhaValue::new(Value::Array(mapped))
}
_ => GhaValue::missing(),
}
}
fn access_index(base: GhaValue, index: &Value) -> GhaValue {
match (base.json, index) {
(Value::Object(object), Value::String(key)) => object
.get(key)
.cloned()
.map(|value| GhaValue::with_origin(value, key.clone()))
.unwrap_or_else(GhaValue::missing),
(Value::Array(values), Value::Number(index)) => index
.as_u64()
.and_then(|index| values.get(index as usize).cloned())
.map(GhaValue::new)
.unwrap_or_else(GhaValue::missing),
(Value::Array(values), Value::String(index)) => index
.parse::<usize>()
.ok()
.and_then(|index| values.get(index).cloned())
.map(GhaValue::new)
.unwrap_or_else(GhaValue::missing),
_ => GhaValue::missing(),
}
}
fn wildcard(base: GhaValue) -> GhaValue {
match base.json {
Value::Array(values) => GhaValue::new(Value::Array(values)),
Value::Object(object) => GhaValue::new(Value::Array(object.into_values().collect())),
_ => GhaValue::new(Value::Array(Vec::new())),
}
}
fn contains(search: &Value, item: &Value) -> bool {
let needle = lowercase_string(item);
match search {
Value::Array(values) => values
.iter()
.any(|value| lowercase_string(value).eq_ignore_ascii_case(&needle)),
_ => lowercase_string(search).contains(&needle),
}
}
fn lowercase_string(value: &Value) -> String {
string_for_render(value).to_ascii_lowercase()
}
fn format_function(template: &str, replacements: &[String]) -> Result<String> {
let chars = template.chars().collect::<Vec<_>>();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'{' if chars.get(i + 1) == Some(&'{') => {
out.push('{');
i += 2;
}
'}' if chars.get(i + 1) == Some(&'}') => {
out.push('}');
i += 2;
}
'{' => {
i += 1;
let start = i;
while matches!(chars.get(i), Some(ch) if ch.is_ascii_digit()) {
i += 1;
}
ensure!(
chars.get(i) == Some(&'}'),
"format() placeholder is not closed"
);
let index = chars[start..i]
.iter()
.collect::<String>()
.parse::<usize>()?;
out.push_str(
replacements
.get(index)
.ok_or_else(|| anyhow!("format() placeholder {{{index}}} has no value"))?,
);
i += 1;
}
ch => {
out.push(ch);
i += 1;
}
}
}
Ok(out)
}
fn join_function(value: &Value, separator: &str) -> String {
match value {
Value::Array(values) => values
.iter()
.map(string_for_render)
.collect::<Vec<_>>()
.join(separator),
_ => string_for_render(value),
}
}
fn hash_files(workspace: Option<&Utf8Path>, patterns: &[String]) -> Result<String> {
let workspace = workspace.context("hashFiles() requires --workspace")?;
let mut include = GlobSetBuilder::new();
let mut exclude = GlobSetBuilder::new();
let mut include_count = 0usize;
for pattern in patterns {
let pattern = pattern.trim();
ensure!(!pattern.is_empty(), "hashFiles() pattern cannot be empty");
let (negated, pattern) = pattern
.strip_prefix('!')
.map(|pattern| (true, pattern))
.unwrap_or((false, pattern));
let pattern = normalize_glob(pattern);
let glob =
Glob::new(&pattern).with_context(|| format!("invalid glob pattern {pattern}"))?;
if negated {
exclude.add(glob);
} else {
include_count += 1;
include.add(glob);
}
}
ensure!(
include_count > 0,
"hashFiles() requires at least one include pattern"
);
let include = include.build()?;
let exclude = exclude.build()?;
let mut matches = BTreeSet::new();
for entry in WalkDir::new(workspace).follow_links(false) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let path = Utf8Path::from_path(entry.path()).context("workspace path is not UTF-8")?;
let rel = path.strip_prefix(workspace)?;
let rel_string = rel.as_str().replace('\\', "/");
if include.is_match(&rel_string) && !exclude.is_match(&rel_string) {
matches.insert(path.to_owned());
}
}
if matches.is_empty() {
return Ok(String::new());
}
let mut final_hash = Sha256::new();
for path in matches {
let bytes = fs::read(&path).with_context(|| format!("reading {path}"))?;
final_hash.update(Sha256::digest(&bytes));
}
Ok(format!("{:x}", final_hash.finalize()))
}
fn normalize_glob(pattern: &str) -> String {
let pattern = pattern.trim_start_matches('/').replace('\\', "/");
if pattern.starts_with("**/") || pattern.contains('/') {
pattern
} else {
format!("**/{pattern}")
}
}
fn context_names(context: &Value) -> Vec<String> {
match context {
Value::Object(object) => object.keys().cloned().collect(),
_ => Vec::new(),
}
}
struct ReceiptParts {
mode: &'static str,
expression: Option<String>,
template: Option<String>,
rendered: Option<String>,
result: Option<Value>,
result_string: Option<String>,
result_type: Option<String>,
truthy: Option<bool>,
contexts: Vec<String>,
functions: Vec<String>,
references: Vec<String>,
checks: Vec<Check>,
}
fn receipt(parts: ReceiptParts) -> EvaluationReceipt {
let summary = ReceiptSummary::from_checks(&parts.checks);
EvaluationReceipt {
schema_version: SCHEMA_VERSION,
tool: ToolInfo {
name: TOOL_NAME.to_owned(),
version: TOOL_VERSION.to_owned(),
},
checked_at: Utc::now(),
mode: parts.mode.to_owned(),
expression: parts.expression,
template: parts.template,
rendered: parts.rendered,
result: parts.result,
result_string: parts.result_string,
result_type: parts.result_type,
truthy: parts.truthy,
contexts: parts.contexts,
functions: parts.functions,
references: parts.references,
summary,
checks: parts.checks,
}
}
fn sort_dedupe(values: &mut Vec<String>) {
values.sort();
values.dedup();
}
fn is_context_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn options() -> EvaluationOptions {
let context = json!({
"github": {
"ref": "refs/heads/main",
"event_name": "push",
"event": {
"issue": {
"labels": [
{"name": "bug"},
{"name": "help wanted"}
]
}
}
},
"env": {
"continue": "true",
"time": "3"
}
});
EvaluationOptions {
context,
..EvaluationOptions::default()
}
}
#[test]
fn evaluates_common_expression() {
let receipt = evaluate_expression(
"github.ref == 'refs/heads/main' && contains(github.event.issue.labels.*.name, 'BUG')",
&options(),
);
assert_eq!(receipt.summary.failed, 0);
assert_eq!(receipt.result, Some(Value::Bool(true)));
}
#[test]
fn from_json_converts_strings() {
let receipt = evaluate_expression("fromJSON(env.time) > 2", &options());
assert_eq!(receipt.result, Some(Value::Bool(true)));
}
#[test]
fn case_is_lazy() {
let receipt = evaluate_expression(
"case(github.ref == 'refs/heads/main', 'prod', fromJSON('bad'), 'bad', 'dev')",
&options(),
);
assert_eq!(receipt.summary.failed, 0);
assert_eq!(receipt.result, Some(Value::String("prod".to_owned())));
}
#[test]
fn if_mode_applies_success_by_default() {
let mut options = options();
options.if_condition = true;
options.job_status = JobStatus::Failure;
let receipt = evaluate_expression("github.ref == 'refs/heads/main'", &options);
assert_eq!(receipt.result, Some(Value::Bool(false)));
}
}