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}