Skip to main content

gha_expression_proof/
eval.rs

1use crate::ast::{AccessSegment, BinaryOp, Expr, UnaryOp};
2use crate::model::{
3    Check, EvaluationReceipt, ReceiptSummary, SCHEMA_VERSION, ToolInfo, value_type,
4};
5use crate::parser::{parse_expression, unwrap_expression};
6use crate::template::render_template;
7use crate::value::{GhaValue, loose_compare, loose_equal, string_for_render};
8use crate::{TOOL_NAME, TOOL_VERSION};
9use anyhow::{Context, Result, anyhow, bail, ensure};
10use camino::{Utf8Path, Utf8PathBuf};
11use chrono::Utc;
12use globset::{Glob, GlobSetBuilder};
13use serde_json::{Map, Value};
14use sha2::{Digest, Sha256};
15use std::cmp::Ordering;
16use std::collections::BTreeSet;
17use std::fs;
18use walkdir::WalkDir;
19
20#[derive(Debug, Clone)]
21pub struct EvaluationOptions {
22    pub context: Value,
23    pub workspace: Option<Utf8PathBuf>,
24    pub if_condition: bool,
25    pub job_status: JobStatus,
26}
27
28impl Default for EvaluationOptions {
29    fn default() -> Self {
30        Self {
31            context: Value::Object(Map::new()),
32            workspace: None,
33            if_condition: false,
34            job_status: JobStatus::Success,
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum JobStatus {
41    Success,
42    Failure,
43    Cancelled,
44}
45
46#[derive(Debug, Default)]
47pub struct ContextBuilder {
48    root: Map<String, Value>,
49}
50
51impl ContextBuilder {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn insert_context(&mut self, name: impl Into<String>, value: Value) {
57        self.root.insert(name.into(), value);
58    }
59
60    pub fn insert_root_object(&mut self, value: Value) -> Result<()> {
61        let Value::Object(object) = value else {
62            bail!("root context file must contain a JSON object");
63        };
64        for (key, value) in object {
65            self.root.insert(key, value);
66        }
67        Ok(())
68    }
69
70    pub fn insert_eventsmith_github_context(&mut self, value: Value) -> Result<()> {
71        let Value::Object(_) = value else {
72            bail!("github context file must contain a JSON object");
73        };
74        self.root.insert("github".to_owned(), value);
75        Ok(())
76    }
77
78    pub fn insert_github_event(&mut self, event: Value) {
79        let github = self
80            .root
81            .entry("github".to_owned())
82            .or_insert_with(|| Value::Object(Map::new()));
83        if let Value::Object(github) = github {
84            github.insert("event".to_owned(), event);
85        }
86    }
87
88    pub fn insert_context_value(&mut self, name: &str, value: Value) -> Result<()> {
89        ensure!(
90            is_context_name(name),
91            "context name must start with a letter or '_' and contain only alphanumeric, '_' or '-' characters"
92        );
93        self.root.insert(name.to_owned(), value);
94        Ok(())
95    }
96
97    pub fn build(self) -> Value {
98        Value::Object(self.root)
99    }
100}
101
102pub fn evaluate_expression(expression: &str, options: &EvaluationOptions) -> EvaluationReceipt {
103    let normalized = unwrap_expression(expression);
104    let mut checks = Vec::new();
105    let mut functions = Vec::new();
106    let mut references = Vec::new();
107    let mut result = None;
108    let mut result_string = None;
109    let mut result_type = None;
110    let mut truthy = None;
111
112    match parse_expression(&normalized) {
113        Ok(expr) => {
114            checks.push(Check::pass(
115                "expression.syntax",
116                "expression parsed successfully",
117            ));
118            expr.collect_functions(&mut functions);
119            expr.collect_roots(&mut references);
120            sort_dedupe(&mut functions);
121            sort_dedupe(&mut references);
122
123            if options.if_condition && !expr.contains_status_function() {
124                checks.push(Check::pass(
125                    "expression.if.default_status",
126                    "applied implicit success() status check for if-condition mode",
127                ));
128            } else {
129                checks.push(Check::skip(
130                    "expression.if.default_status",
131                    "implicit success() status check not applied",
132                ));
133            }
134
135            let mut evaluator = Evaluator::new(options);
136            let evaluation = evaluator.eval_with_if_default(&expr);
137            checks.extend(evaluator.into_checks());
138
139            match evaluation {
140                Ok(value) => {
141                    checks.push(Check::pass(
142                        "expression.evaluate",
143                        "expression evaluated successfully",
144                    ));
145                    truthy = Some(value.truthy());
146                    result_type = Some(value_type(&value.json).to_owned());
147                    result_string = Some(string_for_render(&value.json));
148                    result = Some(value.json);
149                }
150                Err(error) => {
151                    checks.push(Check::fail("expression.evaluate", error.to_string()));
152                }
153            }
154        }
155        Err(error) => {
156            checks.push(Check::fail("expression.syntax", error.to_string()));
157            checks.push(Check::skip(
158                "expression.if.default_status",
159                "expression did not parse",
160            ));
161            checks.push(Check::skip(
162                "expression.evaluate",
163                "expression did not parse",
164            ));
165        }
166    }
167
168    let contexts = context_names(&options.context);
169    receipt(ReceiptParts {
170        mode: "expression",
171        expression: Some(normalized),
172        template: None,
173        rendered: None,
174        result,
175        result_string,
176        result_type,
177        truthy,
178        contexts,
179        functions,
180        references,
181        checks,
182    })
183}
184
185pub fn evaluate_template(template: &str, options: &EvaluationOptions) -> EvaluationReceipt {
186    let mut checks = Vec::new();
187    let mut functions = Vec::new();
188    let mut references = Vec::new();
189
190    match render_template(template, options, &mut functions, &mut references) {
191        Ok(rendered) => {
192            sort_dedupe(&mut functions);
193            sort_dedupe(&mut references);
194            checks.push(Check::pass(
195                "template.syntax",
196                "template parsed successfully",
197            ));
198            checks.push(Check::pass(
199                "template.evaluate",
200                "template expressions evaluated successfully",
201            ));
202            receipt(ReceiptParts {
203                mode: "template",
204                expression: None,
205                template: Some(template.to_owned()),
206                rendered: Some(rendered),
207                result: None,
208                result_string: None,
209                result_type: None,
210                truthy: None,
211                contexts: context_names(&options.context),
212                functions,
213                references,
214                checks,
215            })
216        }
217        Err(error) => {
218            checks.push(Check::fail("template.evaluate", error.to_string()));
219            receipt(ReceiptParts {
220                mode: "template",
221                expression: None,
222                template: Some(template.to_owned()),
223                rendered: None,
224                result: None,
225                result_string: None,
226                result_type: None,
227                truthy: None,
228                contexts: context_names(&options.context),
229                functions,
230                references,
231                checks,
232            })
233        }
234    }
235}
236
237pub(crate) struct Evaluator<'a> {
238    options: &'a EvaluationOptions,
239    checks: Vec<Check>,
240    hash_files_used: bool,
241}
242
243impl<'a> Evaluator<'a> {
244    pub(crate) fn new(options: &'a EvaluationOptions) -> Self {
245        Self {
246            options,
247            checks: Vec::new(),
248            hash_files_used: false,
249        }
250    }
251
252    pub(crate) fn eval_for_template(&mut self, expr: &Expr) -> Result<GhaValue> {
253        self.eval(expr)
254    }
255
256    fn eval_with_if_default(&mut self, expr: &Expr) -> Result<GhaValue> {
257        let value = self.eval(expr)?;
258        if self.options.if_condition && !expr.contains_status_function() {
259            Ok(GhaValue::new(Value::Bool(
260                self.status_success() && value.truthy(),
261            )))
262        } else {
263            Ok(value)
264        }
265    }
266
267    fn into_checks(mut self) -> Vec<Check> {
268        if self.hash_files_used {
269            self.checks.push(Check::pass(
270                "expression.hash_files",
271                "hashFiles() evaluated in offline workspace mode",
272            ));
273        } else {
274            self.checks.push(Check::skip(
275                "expression.hash_files",
276                "hashFiles() was not used",
277            ));
278        }
279        self.checks
280    }
281
282    fn eval(&mut self, expr: &Expr) -> Result<GhaValue> {
283        match expr {
284            Expr::Literal(value) => Ok(GhaValue::new(value.clone())),
285            Expr::Variable(name) => Ok(self.variable(name)),
286            Expr::Unary {
287                op: UnaryOp::Not,
288                expr,
289            } => Ok(GhaValue::new(Value::Bool(!self.eval(expr)?.truthy()))),
290            Expr::Binary { op, left, right } => self.binary(*op, left, right),
291            Expr::Call { name, args } => self.call(name, args),
292            Expr::Access { base, segment } => {
293                let base = self.eval(base)?;
294                self.access(base, segment)
295            }
296        }
297    }
298
299    fn variable(&self, name: &str) -> GhaValue {
300        self.options
301            .context
302            .get(name)
303            .cloned()
304            .map(|value| GhaValue::with_origin(value, name.to_owned()))
305            .unwrap_or_else(GhaValue::missing)
306    }
307
308    fn binary(&mut self, op: BinaryOp, left: &Expr, right: &Expr) -> Result<GhaValue> {
309        match op {
310            BinaryOp::And => {
311                let left = self.eval(left)?;
312                if left.truthy() {
313                    self.eval(right)
314                } else {
315                    Ok(left)
316                }
317            }
318            BinaryOp::Or => {
319                let left = self.eval(left)?;
320                if left.truthy() {
321                    Ok(left)
322                } else {
323                    self.eval(right)
324                }
325            }
326            BinaryOp::Eq | BinaryOp::Ne => {
327                let left = self.eval(left)?;
328                let right = self.eval(right)?;
329                let equal = loose_equal(&left, &right);
330                Ok(GhaValue::new(Value::Bool(if matches!(op, BinaryOp::Eq) {
331                    equal
332                } else {
333                    !equal
334                })))
335            }
336            BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
337                let left = self.eval(left)?;
338                let right = self.eval(right)?;
339                let ordering = loose_compare(&left, &right);
340                let value = matches!(
341                    (op, ordering),
342                    (BinaryOp::Lt, Some(Ordering::Less))
343                        | (BinaryOp::Le, Some(Ordering::Less | Ordering::Equal))
344                        | (BinaryOp::Gt, Some(Ordering::Greater))
345                        | (BinaryOp::Ge, Some(Ordering::Greater | Ordering::Equal))
346                );
347                Ok(GhaValue::new(Value::Bool(value)))
348            }
349        }
350    }
351
352    fn access(&mut self, base: GhaValue, segment: &AccessSegment) -> Result<GhaValue> {
353        match segment {
354            AccessSegment::Property(property) => Ok(access_property(base, property)),
355            AccessSegment::Wildcard => Ok(wildcard(base)),
356            AccessSegment::Index(index) => {
357                let index = self.eval(index)?;
358                Ok(access_index(base, &index.json))
359            }
360        }
361    }
362
363    fn call(&mut self, name: &str, args: &[Expr]) -> Result<GhaValue> {
364        match name.to_ascii_lowercase().as_str() {
365            "contains" => {
366                ensure!(args.len() == 2, "contains() expects 2 arguments");
367                let search = self.eval(&args[0])?;
368                let item = self.eval(&args[1])?;
369                Ok(GhaValue::new(Value::Bool(contains(
370                    &search.json,
371                    &item.json,
372                ))))
373            }
374            "startswith" => {
375                ensure!(args.len() == 2, "startsWith() expects 2 arguments");
376                let search = self.eval(&args[0])?;
377                let item = self.eval(&args[1])?;
378                Ok(GhaValue::new(Value::Bool(
379                    lowercase_string(&search.json).starts_with(&lowercase_string(&item.json)),
380                )))
381            }
382            "endswith" => {
383                ensure!(args.len() == 2, "endsWith() expects 2 arguments");
384                let search = self.eval(&args[0])?;
385                let item = self.eval(&args[1])?;
386                Ok(GhaValue::new(Value::Bool(
387                    lowercase_string(&search.json).ends_with(&lowercase_string(&item.json)),
388                )))
389            }
390            "format" => {
391                ensure!(args.len() >= 2, "format() expects at least 2 arguments");
392                let template = self.eval(&args[0])?;
393                let replacements = args[1..]
394                    .iter()
395                    .map(|arg| self.eval(arg).map(|value| string_for_render(&value.json)))
396                    .collect::<Result<Vec<_>>>()?;
397                Ok(GhaValue::new(Value::String(format_function(
398                    &string_for_render(&template.json),
399                    &replacements,
400                )?)))
401            }
402            "join" => {
403                ensure!(
404                    args.len() == 1 || args.len() == 2,
405                    "join() expects 1 or 2 arguments"
406                );
407                let value = self.eval(&args[0])?;
408                let separator = if let Some(arg) = args.get(1) {
409                    string_for_render(&self.eval(arg)?.json)
410                } else {
411                    ",".to_owned()
412                };
413                Ok(GhaValue::new(Value::String(join_function(
414                    &value.json,
415                    &separator,
416                ))))
417            }
418            "tojson" => {
419                ensure!(args.len() == 1, "toJSON() expects 1 argument");
420                Ok(GhaValue::new(Value::String(serde_json::to_string_pretty(
421                    &self.eval(&args[0])?.json,
422                )?)))
423            }
424            "fromjson" => {
425                ensure!(args.len() == 1, "fromJSON() expects 1 argument");
426                let value = self.eval(&args[0])?;
427                let raw = string_for_render(&value.json);
428                Ok(GhaValue::new(
429                    serde_json::from_str(&raw)
430                        .with_context(|| "fromJSON() input is not valid JSON")?,
431                ))
432            }
433            "hashfiles" => {
434                ensure!(!args.is_empty(), "hashFiles() expects at least 1 argument");
435                let patterns = args
436                    .iter()
437                    .map(|arg| self.eval(arg).map(|value| string_for_render(&value.json)))
438                    .collect::<Result<Vec<_>>>()?;
439                self.hash_files_used = true;
440                Ok(GhaValue::new(Value::String(hash_files(
441                    self.options.workspace.as_deref(),
442                    &patterns,
443                )?)))
444            }
445            "case" => self.case_function(args),
446            "success" => {
447                ensure!(args.is_empty(), "success() expects no arguments");
448                Ok(GhaValue::new(Value::Bool(self.status_success())))
449            }
450            "failure" => {
451                ensure!(args.is_empty(), "failure() expects no arguments");
452                Ok(GhaValue::new(Value::Bool(matches!(
453                    self.options.job_status,
454                    JobStatus::Failure
455                ))))
456            }
457            "cancelled" => {
458                ensure!(args.is_empty(), "cancelled() expects no arguments");
459                Ok(GhaValue::new(Value::Bool(matches!(
460                    self.options.job_status,
461                    JobStatus::Cancelled
462                ))))
463            }
464            "always" => {
465                ensure!(args.is_empty(), "always() expects no arguments");
466                Ok(GhaValue::new(Value::Bool(true)))
467            }
468            other => bail!("unsupported function `{other}`"),
469        }
470    }
471
472    fn case_function(&mut self, args: &[Expr]) -> Result<GhaValue> {
473        ensure!(
474            args.len() >= 3 && args.len() % 2 == 1,
475            "case() expects predicate/value pairs followed by a default"
476        );
477
478        for pair in args[..args.len() - 1].chunks(2) {
479            if self.eval(&pair[0])?.truthy() {
480                return self.eval(&pair[1]);
481            }
482        }
483
484        self.eval(args.last().expect("validated non-empty args"))
485    }
486
487    fn status_success(&self) -> bool {
488        matches!(self.options.job_status, JobStatus::Success)
489    }
490}
491
492fn access_property(base: GhaValue, property: &str) -> GhaValue {
493    match base.json {
494        Value::Object(object) => object
495            .get(property)
496            .cloned()
497            .map(|value| {
498                let origin = base
499                    .origin
500                    .as_ref()
501                    .map(|origin| format!("{origin}.{property}"))
502                    .unwrap_or_else(|| property.to_owned());
503                GhaValue::with_origin(value, origin)
504            })
505            .unwrap_or_else(GhaValue::missing),
506        Value::Array(values) => {
507            let mapped = values
508                .into_iter()
509                .map(|value| access_property(GhaValue::new(value), property).json)
510                .collect::<Vec<_>>();
511            GhaValue::new(Value::Array(mapped))
512        }
513        _ => GhaValue::missing(),
514    }
515}
516
517fn access_index(base: GhaValue, index: &Value) -> GhaValue {
518    match (base.json, index) {
519        (Value::Object(object), Value::String(key)) => object
520            .get(key)
521            .cloned()
522            .map(|value| GhaValue::with_origin(value, key.clone()))
523            .unwrap_or_else(GhaValue::missing),
524        (Value::Array(values), Value::Number(index)) => index
525            .as_u64()
526            .and_then(|index| values.get(index as usize).cloned())
527            .map(GhaValue::new)
528            .unwrap_or_else(GhaValue::missing),
529        (Value::Array(values), Value::String(index)) => index
530            .parse::<usize>()
531            .ok()
532            .and_then(|index| values.get(index).cloned())
533            .map(GhaValue::new)
534            .unwrap_or_else(GhaValue::missing),
535        _ => GhaValue::missing(),
536    }
537}
538
539fn wildcard(base: GhaValue) -> GhaValue {
540    match base.json {
541        Value::Array(values) => GhaValue::new(Value::Array(values)),
542        Value::Object(object) => GhaValue::new(Value::Array(object.into_values().collect())),
543        _ => GhaValue::new(Value::Array(Vec::new())),
544    }
545}
546
547fn contains(search: &Value, item: &Value) -> bool {
548    let needle = lowercase_string(item);
549    match search {
550        Value::Array(values) => values
551            .iter()
552            .any(|value| lowercase_string(value).eq_ignore_ascii_case(&needle)),
553        _ => lowercase_string(search).contains(&needle),
554    }
555}
556
557fn lowercase_string(value: &Value) -> String {
558    string_for_render(value).to_ascii_lowercase()
559}
560
561fn format_function(template: &str, replacements: &[String]) -> Result<String> {
562    let chars = template.chars().collect::<Vec<_>>();
563    let mut out = String::new();
564    let mut i = 0;
565
566    while i < chars.len() {
567        match chars[i] {
568            '{' if chars.get(i + 1) == Some(&'{') => {
569                out.push('{');
570                i += 2;
571            }
572            '}' if chars.get(i + 1) == Some(&'}') => {
573                out.push('}');
574                i += 2;
575            }
576            '{' => {
577                i += 1;
578                let start = i;
579                while matches!(chars.get(i), Some(ch) if ch.is_ascii_digit()) {
580                    i += 1;
581                }
582                ensure!(
583                    chars.get(i) == Some(&'}'),
584                    "format() placeholder is not closed"
585                );
586                let index = chars[start..i]
587                    .iter()
588                    .collect::<String>()
589                    .parse::<usize>()?;
590                out.push_str(
591                    replacements
592                        .get(index)
593                        .ok_or_else(|| anyhow!("format() placeholder {{{index}}} has no value"))?,
594                );
595                i += 1;
596            }
597            ch => {
598                out.push(ch);
599                i += 1;
600            }
601        }
602    }
603
604    Ok(out)
605}
606
607fn join_function(value: &Value, separator: &str) -> String {
608    match value {
609        Value::Array(values) => values
610            .iter()
611            .map(string_for_render)
612            .collect::<Vec<_>>()
613            .join(separator),
614        _ => string_for_render(value),
615    }
616}
617
618fn hash_files(workspace: Option<&Utf8Path>, patterns: &[String]) -> Result<String> {
619    let workspace = workspace.context("hashFiles() requires --workspace")?;
620    let mut include = GlobSetBuilder::new();
621    let mut exclude = GlobSetBuilder::new();
622    let mut include_count = 0usize;
623
624    for pattern in patterns {
625        let pattern = pattern.trim();
626        ensure!(!pattern.is_empty(), "hashFiles() pattern cannot be empty");
627        let (negated, pattern) = pattern
628            .strip_prefix('!')
629            .map(|pattern| (true, pattern))
630            .unwrap_or((false, pattern));
631        let pattern = normalize_glob(pattern);
632        let glob =
633            Glob::new(&pattern).with_context(|| format!("invalid glob pattern {pattern}"))?;
634        if negated {
635            exclude.add(glob);
636        } else {
637            include_count += 1;
638            include.add(glob);
639        }
640    }
641
642    ensure!(
643        include_count > 0,
644        "hashFiles() requires at least one include pattern"
645    );
646
647    let include = include.build()?;
648    let exclude = exclude.build()?;
649    let mut matches = BTreeSet::new();
650
651    for entry in WalkDir::new(workspace).follow_links(false) {
652        let entry = entry?;
653        if !entry.file_type().is_file() {
654            continue;
655        }
656        let path = Utf8Path::from_path(entry.path()).context("workspace path is not UTF-8")?;
657        let rel = path.strip_prefix(workspace)?;
658        let rel_string = rel.as_str().replace('\\', "/");
659        if include.is_match(&rel_string) && !exclude.is_match(&rel_string) {
660            matches.insert(path.to_owned());
661        }
662    }
663
664    if matches.is_empty() {
665        return Ok(String::new());
666    }
667
668    let mut final_hash = Sha256::new();
669    for path in matches {
670        let bytes = fs::read(&path).with_context(|| format!("reading {path}"))?;
671        final_hash.update(Sha256::digest(&bytes));
672    }
673    Ok(format!("{:x}", final_hash.finalize()))
674}
675
676fn normalize_glob(pattern: &str) -> String {
677    let pattern = pattern.trim_start_matches('/').replace('\\', "/");
678    if pattern.starts_with("**/") || pattern.contains('/') {
679        pattern
680    } else {
681        format!("**/{pattern}")
682    }
683}
684
685fn context_names(context: &Value) -> Vec<String> {
686    match context {
687        Value::Object(object) => object.keys().cloned().collect(),
688        _ => Vec::new(),
689    }
690}
691
692struct ReceiptParts {
693    mode: &'static str,
694    expression: Option<String>,
695    template: Option<String>,
696    rendered: Option<String>,
697    result: Option<Value>,
698    result_string: Option<String>,
699    result_type: Option<String>,
700    truthy: Option<bool>,
701    contexts: Vec<String>,
702    functions: Vec<String>,
703    references: Vec<String>,
704    checks: Vec<Check>,
705}
706
707fn receipt(parts: ReceiptParts) -> EvaluationReceipt {
708    let summary = ReceiptSummary::from_checks(&parts.checks);
709    EvaluationReceipt {
710        schema_version: SCHEMA_VERSION,
711        tool: ToolInfo {
712            name: TOOL_NAME.to_owned(),
713            version: TOOL_VERSION.to_owned(),
714        },
715        checked_at: Utc::now(),
716        mode: parts.mode.to_owned(),
717        expression: parts.expression,
718        template: parts.template,
719        rendered: parts.rendered,
720        result: parts.result,
721        result_string: parts.result_string,
722        result_type: parts.result_type,
723        truthy: parts.truthy,
724        contexts: parts.contexts,
725        functions: parts.functions,
726        references: parts.references,
727        summary,
728        checks: parts.checks,
729    }
730}
731
732fn sort_dedupe(values: &mut Vec<String>) {
733    values.sort();
734    values.dedup();
735}
736
737fn is_context_name(name: &str) -> bool {
738    let mut chars = name.chars();
739    let Some(first) = chars.next() else {
740        return false;
741    };
742    (first.is_ascii_alphabetic() || first == '_')
743        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use serde_json::json;
750
751    fn options() -> EvaluationOptions {
752        let context = json!({
753            "github": {
754                "ref": "refs/heads/main",
755                "event_name": "push",
756                "event": {
757                    "issue": {
758                        "labels": [
759                            {"name": "bug"},
760                            {"name": "help wanted"}
761                        ]
762                    }
763                }
764            },
765            "env": {
766                "continue": "true",
767                "time": "3"
768            }
769        });
770        EvaluationOptions {
771            context,
772            ..EvaluationOptions::default()
773        }
774    }
775
776    #[test]
777    fn evaluates_common_expression() {
778        let receipt = evaluate_expression(
779            "github.ref == 'refs/heads/main' && contains(github.event.issue.labels.*.name, 'BUG')",
780            &options(),
781        );
782        assert_eq!(receipt.summary.failed, 0);
783        assert_eq!(receipt.result, Some(Value::Bool(true)));
784    }
785
786    #[test]
787    fn from_json_converts_strings() {
788        let receipt = evaluate_expression("fromJSON(env.time) > 2", &options());
789        assert_eq!(receipt.result, Some(Value::Bool(true)));
790    }
791
792    #[test]
793    fn case_is_lazy() {
794        let receipt = evaluate_expression(
795            "case(github.ref == 'refs/heads/main', 'prod', fromJSON('bad'), 'bad', 'dev')",
796            &options(),
797        );
798        assert_eq!(receipt.summary.failed, 0);
799        assert_eq!(receipt.result, Some(Value::String("prod".to_owned())));
800    }
801
802    #[test]
803    fn if_mode_applies_success_by_default() {
804        let mut options = options();
805        options.if_condition = true;
806        options.job_status = JobStatus::Failure;
807        let receipt = evaluate_expression("github.ref == 'refs/heads/main'", &options);
808        assert_eq!(receipt.result, Some(Value::Bool(false)));
809    }
810}