Skip to main content

wrkflw_executor/
expression.rs

1//! GitHub Actions expression evaluator.
2//!
3//! Implements the expression language used inside `${{ }}` blocks in GitHub
4//! Actions workflows. Supports context references (`inputs.*`, `env.*`,
5//! `github.*`, `runner.*`, `matrix.*`, `steps.*.outputs.*`), operators
6//! (`==`, `!=`, `&&`, `||`, `!`, comparisons), string/number/boolean literals,
7//! and built-in functions (`contains`, `startsWith`, `endsWith`, `format`,
8//! `success`, `failure`, `always`, `cancelled`).
9
10use serde_yaml::Value;
11use std::collections::{HashMap, HashSet};
12
13// serde_json is used by toJSON() for robust string escaping.
14use serde_json;
15
16// ---------------------------------------------------------------------------
17// Value type
18// ---------------------------------------------------------------------------
19
20/// Runtime value in the GitHub Actions expression language.
21#[derive(Debug, Clone, PartialEq)]
22pub enum ExprValue {
23    String(String),
24    Number(f64),
25    Bool(bool),
26    Null,
27    /// A key-value map, used for context objects like `env`, `github`, etc.
28    Object(HashMap<String, ExprValue>),
29}
30
31impl ExprValue {
32    /// GitHub Actions truthiness: `false`, `0`, `""`, and `null` are falsy.
33    pub fn is_truthy(&self) -> bool {
34        match self {
35            ExprValue::Bool(b) => *b,
36            ExprValue::Number(n) => *n != 0.0 && !n.is_nan(),
37            ExprValue::String(s) => !s.is_empty(),
38            ExprValue::Null => false,
39            ExprValue::Object(_) => true,
40        }
41    }
42
43    /// Coerce to string for substitution output.
44    pub fn to_output_string(&self) -> String {
45        match self {
46            ExprValue::String(s) => s.clone(),
47            ExprValue::Number(n) => {
48                if n.is_finite() && *n == (*n as i64) as f64 {
49                    format!("{}", *n as i64)
50                } else {
51                    format!("{}", n)
52                }
53            }
54            ExprValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
55            ExprValue::Null => String::new(),
56            ExprValue::Object(map) => {
57                // GHA coerces objects to their JSON representation in string contexts.
58                let sorted: std::collections::BTreeMap<&String, serde_json::Value> =
59                    map.iter().map(|(k, v)| (k, expr_to_json(v))).collect();
60                serde_json::to_string_pretty(&sorted).unwrap_or_else(|_| "{}".to_string())
61            }
62        }
63    }
64}
65
66// ---------------------------------------------------------------------------
67// Tokenizer
68// ---------------------------------------------------------------------------
69
70#[derive(Debug, Clone, PartialEq)]
71enum Token {
72    Ident(String),
73    StringLit(String),
74    NumberLit(f64),
75    True,
76    False,
77    Null,
78    Dot,
79    LParen,
80    RParen,
81    Comma,
82    Eq,  // ==
83    Ne,  // !=
84    Lt,  // <
85    Le,  // <=
86    Gt,  // >
87    Ge,  // >=
88    And, // &&
89    Or,  // ||
90    Not, // !
91    Eof,
92}
93
94struct Tokenizer<'a> {
95    input: &'a str,
96    pos: usize, // byte offset into `input`
97}
98
99impl<'a> Tokenizer<'a> {
100    fn new(input: &'a str) -> Self {
101        Self { input, pos: 0 }
102    }
103
104    fn skip_whitespace(&mut self) {
105        let bytes = self.input.as_bytes();
106        while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
107            self.pos += 1;
108        }
109    }
110
111    fn tokenize(&mut self) -> Result<Vec<Token>, String> {
112        let mut tokens = Vec::new();
113        loop {
114            self.skip_whitespace();
115            if self.pos >= self.input.len() {
116                tokens.push(Token::Eof);
117                return Ok(tokens);
118            }
119            let bytes = self.input.as_bytes();
120            let ch = bytes[self.pos] as char;
121            match ch {
122                '.' => {
123                    tokens.push(Token::Dot);
124                    self.pos += 1;
125                }
126                '(' => {
127                    tokens.push(Token::LParen);
128                    self.pos += 1;
129                }
130                ')' => {
131                    tokens.push(Token::RParen);
132                    self.pos += 1;
133                }
134                ',' => {
135                    tokens.push(Token::Comma);
136                    self.pos += 1;
137                }
138                '=' => {
139                    if self.peek_next_byte() == Some(b'=') {
140                        tokens.push(Token::Eq);
141                        self.pos += 2;
142                    } else {
143                        return Err(format!("unexpected '=' at position {}", self.pos));
144                    }
145                }
146                '!' => {
147                    if self.peek_next_byte() == Some(b'=') {
148                        tokens.push(Token::Ne);
149                        self.pos += 2;
150                    } else {
151                        tokens.push(Token::Not);
152                        self.pos += 1;
153                    }
154                }
155                '<' => {
156                    if self.peek_next_byte() == Some(b'=') {
157                        tokens.push(Token::Le);
158                        self.pos += 2;
159                    } else {
160                        tokens.push(Token::Lt);
161                        self.pos += 1;
162                    }
163                }
164                '>' => {
165                    if self.peek_next_byte() == Some(b'=') {
166                        tokens.push(Token::Ge);
167                        self.pos += 2;
168                    } else {
169                        tokens.push(Token::Gt);
170                        self.pos += 1;
171                    }
172                }
173                '&' => {
174                    if self.peek_next_byte() == Some(b'&') {
175                        tokens.push(Token::And);
176                        self.pos += 2;
177                    } else {
178                        return Err(format!("unexpected '&' at position {}", self.pos));
179                    }
180                }
181                '|' => {
182                    if self.peek_next_byte() == Some(b'|') {
183                        tokens.push(Token::Or);
184                        self.pos += 2;
185                    } else {
186                        return Err(format!("unexpected '|' at position {}", self.pos));
187                    }
188                }
189                '\'' => {
190                    tokens.push(self.read_string()?);
191                }
192                c if c.is_ascii_digit() => {
193                    tokens.push(self.read_number()?);
194                }
195                c if c.is_ascii_alphabetic() || c == '_' => {
196                    let ident = self.read_ident();
197                    tokens.push(match ident.as_str() {
198                        "true" => Token::True,
199                        "false" => Token::False,
200                        "null" => Token::Null,
201                        _ => Token::Ident(ident),
202                    });
203                }
204                _ => {
205                    // Decode the actual char at this position for the error message
206                    let actual_ch = self.input[self.pos..].chars().next().unwrap_or(ch);
207                    return Err(format!(
208                        "unexpected character '{}' at position {}",
209                        actual_ch, self.pos
210                    ));
211                }
212            }
213        }
214    }
215
216    /// Peek at the next byte (used only for ASCII operator lookahead).
217    fn peek_next_byte(&self) -> Option<u8> {
218        let bytes = self.input.as_bytes();
219        if self.pos + 1 < bytes.len() {
220            Some(bytes[self.pos + 1])
221        } else {
222            None
223        }
224    }
225
226    /// Read a single-quoted string literal, handling multi-byte UTF-8 correctly.
227    fn read_string(&mut self) -> Result<Token, String> {
228        self.pos += 1; // skip opening quote
229        let mut s = String::new();
230        while self.pos < self.input.len() {
231            // Iterate chars from current position to handle multi-byte correctly
232            let ch = self.input[self.pos..].chars().next().unwrap();
233            if ch == '\'' {
234                // Check for escaped quote ('')
235                let next_pos = self.pos + 1;
236                if next_pos < self.input.len() && self.input.as_bytes()[next_pos] == b'\'' {
237                    s.push('\'');
238                    self.pos += 2;
239                } else {
240                    self.pos += 1; // skip closing quote
241                    return Ok(Token::StringLit(s));
242                }
243            } else {
244                s.push(ch);
245                self.pos += ch.len_utf8();
246            }
247        }
248        Err("unterminated string literal".to_string())
249    }
250
251    fn read_number(&mut self) -> Result<Token, String> {
252        let start = self.pos;
253        let bytes = self.input.as_bytes();
254        while self.pos < bytes.len()
255            && (bytes[self.pos].is_ascii_digit() || bytes[self.pos] == b'.')
256        {
257            self.pos += 1;
258        }
259        let s = &self.input[start..self.pos];
260        let n: f64 = s
261            .parse()
262            .map_err(|e| format!("invalid number '{}': {}", s, e))?;
263        Ok(Token::NumberLit(n))
264    }
265
266    fn read_ident(&mut self) -> String {
267        let start = self.pos;
268        let bytes = self.input.as_bytes();
269        while self.pos < bytes.len() {
270            let ch = bytes[self.pos];
271            if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-' {
272                self.pos += 1;
273            } else {
274                break;
275            }
276        }
277        self.input[start..self.pos].to_string()
278    }
279}
280
281/// If `key` is a `GITHUB_*` env var that belongs on GHA's `github.*` expression
282/// context, returns the stripped + lowercased suffix used as the object key
283/// (`GITHUB_SHA` → `"sha"`). Returns `None` for non-`GITHUB_*` keys, the bare
284/// `GITHUB_` prefix, and runner-internal env vars that real GHA does not expose
285/// on the `github` context.
286///
287/// The excluded suffixes fall into two groups, both seeded by
288/// `environment.rs::create_github_context`:
289///   - File-path vars for the workflow-command protocol (`GITHUB_OUTPUT`,
290///     `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STEP_SUMMARY`) — these point at
291///     local tempfiles; leaking them diverges from real GHA and leaks paths.
292///   - CI-detection vars (`GITHUB_ACTIONS`) — documented as default runner
293///     env, not as a `github.*` context property.
294///
295/// Update this function when new runner-internal `GITHUB_*` vars are seeded.
296pub(crate) fn github_context_suffix(key: &str) -> Option<String> {
297    let rest = key.strip_prefix("GITHUB_")?;
298    if rest.is_empty() {
299        return None;
300    }
301    let suffix = rest.to_ascii_lowercase();
302    if matches!(
303        suffix.as_str(),
304        "output" | "env" | "path" | "step_summary" | "actions"
305    ) {
306        return None;
307    }
308    Some(suffix)
309}
310
311// ---------------------------------------------------------------------------
312// Expression context
313// ---------------------------------------------------------------------------
314
315/// Provides variable resolution for expression evaluation.
316pub struct ExpressionContext<'a> {
317    pub env_context: &'a HashMap<String, String>,
318    pub step_outputs: &'a HashMap<String, HashMap<String, String>>,
319    pub matrix_combination: &'a Option<HashMap<String, Value>>,
320    /// Step ID → (outcome, conclusion) where values are "success", "failure", or "skipped".
321    /// `outcome` is the raw result before `continue-on-error`; `conclusion` is the effective result.
322    pub step_statuses: &'a HashMap<String, (String, String)>,
323    /// Current job status for `success()`/`failure()`/`cancelled()` builtins:
324    /// "success", "failure", or "cancelled".
325    pub job_status: &'a str,
326    /// Pre-resolved secrets for `secrets.*` context.
327    pub secrets_context: &'a HashMap<String, String>,
328    /// Job outputs from upstream jobs: `job_name -> { output_key -> output_value }`.
329    pub needs_context: &'a HashMap<String, HashMap<String, String>>,
330    /// Job results from upstream jobs: `job_name -> "success" | "failure" | "skipped"`.
331    pub needs_results: &'a HashMap<String, String>,
332    /// User-declared env vars only (merged workflow/job/step `env:` plus step-authored
333    /// `$GITHUB_ENV` writes). `env_context` holds the union of these and runner-seeded
334    /// vars; `user_env` is the user's slice, consumed by `toJSON(env)` / bare `env`.
335    ///
336    /// Invariant: every key inserted here must have been declared by the user (YAML
337    /// `env:` at any scope, or written to `$GITHUB_ENV` by a step). Runner-seeded
338    /// vars (GITHUB_*, RUNNER_*, INPUT_*, WRKFLW_*, CI, MATRIX_*) must NOT be inserted.
339    pub user_env: &'a HashMap<String, String>,
340}
341
342impl<'a> ExpressionContext<'a> {
343    /// Resolve a dotted context reference like `inputs.toolchain` or
344    /// `steps.build.outputs.version`.
345    fn resolve(&self, parts: &[String]) -> ExprValue {
346        if parts.is_empty() {
347            return ExprValue::Null;
348        }
349
350        let root = parts[0].as_str();
351        match root {
352            "inputs" if parts.len() == 2 => {
353                let env_key = format!("INPUT_{}", parts[1].to_uppercase().replace('-', "_"));
354                self.env_context
355                    .get(&env_key)
356                    .map(|v| ExprValue::String(v.clone()))
357                    .unwrap_or(ExprValue::Null)
358            }
359            "env" if parts.len() == 2 => self
360                .env_context
361                .get(&parts[1])
362                .map(|v| ExprValue::String(v.clone()))
363                .unwrap_or(ExprValue::Null),
364            "github" if parts.len() >= 2 => {
365                // Support nested github context like github.event.action,
366                // github.event.pull_request.number, etc.
367                // Map dotted path to GITHUB_ env var with underscores.
368                //
369                // LIMITATION: In real GitHub Actions, `github.event.*` is a deep
370                // JSON object parsed from the webhook payload (`$GITHUB_EVENT_PATH`).
371                // Here we approximate it via flat GITHUB_* environment variables,
372                // which works for simple top-level properties (e.g. `github.event.action`,
373                // `github.ref_name`) but will return Null for deeply-nested event
374                // properties that don't have a corresponding env var.
375                let env_key = format!("GITHUB_{}", parts[1..].join("_").to_uppercase());
376                self.env_context
377                    .get(&env_key)
378                    .map(|v| ExprValue::String(v.clone()))
379                    .unwrap_or(ExprValue::Null)
380            }
381            "runner" if parts.len() == 2 => {
382                let env_key = format!("RUNNER_{}", parts[1].to_uppercase());
383                self.env_context
384                    .get(&env_key)
385                    .map(|v| ExprValue::String(v.clone()))
386                    .unwrap_or(ExprValue::Null)
387            }
388            "matrix" if parts.len() == 2 => {
389                if let Some(matrix) = self.matrix_combination {
390                    matrix
391                        .get(&parts[1])
392                        .map(yaml_value_to_expr)
393                        .unwrap_or(ExprValue::Null)
394                } else {
395                    ExprValue::Null
396                }
397            }
398            "steps" if parts.len() == 4 && parts[2] == "outputs" => self
399                .step_outputs
400                .get(&parts[1])
401                .and_then(|m| m.get(&parts[3]))
402                .map(|v| ExprValue::String(v.clone()))
403                .unwrap_or(ExprValue::Null),
404            "needs" if parts.len() == 4 && parts[2] == "outputs" => self
405                .needs_context
406                .get(&parts[1])
407                .and_then(|m| m.get(&parts[3]))
408                .map(|v| ExprValue::String(v.clone()))
409                .unwrap_or(ExprValue::Null),
410            "needs" if parts.len() == 3 && parts[2] == "result" => self
411                .needs_results
412                .get(&parts[1])
413                .map(|v| ExprValue::String(v.clone()))
414                .unwrap_or(ExprValue::Null),
415            // jobs.* context — In real GitHub Actions, this is only available in
416            // workflow_call output mapping contexts, not in step expressions. We alias
417            // it to needs.* data here as a pragmatic approximation that covers the most
418            // common use case (reusable workflow outputs). Note: jobs.*.result does not
419            // exist in real GHA (only needs.*.result does), so we only support outputs.
420            "jobs" if parts.len() == 4 && parts[2] == "outputs" => self
421                .needs_context
422                .get(&parts[1])
423                .and_then(|m| m.get(&parts[3]))
424                .map(|v| ExprValue::String(v.clone()))
425                .unwrap_or(ExprValue::Null),
426            "secrets" if parts.len() == 2 => self
427                .secrets_context
428                .get(&parts[1])
429                .map(|v| ExprValue::String(v.clone()))
430                .unwrap_or(ExprValue::Null),
431            "steps" if parts.len() == 3 && parts[2] == "outcome" => self
432                .step_statuses
433                .get(&parts[1])
434                .map(|(outcome, _)| ExprValue::String(outcome.clone()))
435                .unwrap_or(ExprValue::Null),
436            "steps" if parts.len() == 3 && parts[2] == "conclusion" => self
437                .step_statuses
438                .get(&parts[1])
439                .map(|(_, conclusion)| ExprValue::String(conclusion.clone()))
440                .unwrap_or(ExprValue::Null),
441            // Bare context names — return the whole context as an Object so that
442            // `toJSON(env)` (and similar) can serialise it.
443            "steps" if parts.len() == 1 => {
444                // Collect all step IDs from both outputs and statuses maps.
445                let mut all_ids: HashSet<&String> = self.step_outputs.keys().collect();
446                all_ids.extend(self.step_statuses.keys());
447
448                let mut map = HashMap::new();
449                for step_id in all_ids {
450                    let mut step_obj = HashMap::new();
451
452                    // outputs sub-object (empty if no outputs recorded)
453                    let outputs_map: HashMap<String, ExprValue> = self
454                        .step_outputs
455                        .get(step_id)
456                        .map(|m| {
457                            m.iter()
458                                .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
459                                .collect()
460                        })
461                        .unwrap_or_default();
462                    step_obj.insert("outputs".to_string(), ExprValue::Object(outputs_map));
463
464                    // outcome + conclusion (only present if the step has a status)
465                    if let Some((outcome, conclusion)) = self.step_statuses.get(step_id) {
466                        step_obj.insert("outcome".to_string(), ExprValue::String(outcome.clone()));
467                        step_obj.insert(
468                            "conclusion".to_string(),
469                            ExprValue::String(conclusion.clone()),
470                        );
471                    }
472
473                    map.insert(step_id.clone(), ExprValue::Object(step_obj));
474                }
475                ExprValue::Object(map)
476            }
477            "env" if parts.len() == 1 => {
478                // Dump only user-declared env vars. `env_context` holds the union
479                // (user + runner-seeded); `user_env` is the user's slice tracked
480                // separately from the construction site downward. See the
481                // `user_env` field doc on `ExpressionContext`.
482                let map = self
483                    .user_env
484                    .iter()
485                    .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
486                    .collect();
487                ExprValue::Object(map)
488            }
489            "github" if parts.len() == 1 => {
490                // Build a flat object from GITHUB_* env vars by stripping the
491                // prefix and lowercasing the remainder, inverting the dotted-access
492                // mapping (`github.sha` → `GITHUB_SHA`). Runner-internal keys that
493                // aren't part of GHA's `github` context are filtered out inside
494                // `github_context_suffix`. Does not include a nested `event`
495                // sub-object — same documented limitation as the dotted-access arm
496                // above.
497                //
498                // KNOWN LIMITATION: the inverse of `toJSON(env)`'s prefix heuristic
499                // applies here — any user-defined env var starting with `GITHUB_`
500                // (e.g. the common `env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }`)
501                // will appear in this object. In particular `GITHUB_TOKEN`, if set,
502                // surfaces as `github.token` in plaintext; do not dump this object
503                // to untrusted sinks without a masking layer.
504                let map = self
505                    .env_context
506                    .iter()
507                    .filter_map(|(k, v)| {
508                        github_context_suffix(k).map(|key| (key, ExprValue::String(v.clone())))
509                    })
510                    .collect();
511                ExprValue::Object(map)
512            }
513            "needs" if parts.len() == 1 => {
514                // Collect all job IDs from both outputs and results maps.
515                let mut all_ids: HashSet<&String> = self.needs_context.keys().collect();
516                all_ids.extend(self.needs_results.keys());
517
518                let mut map = HashMap::new();
519                for job_id in all_ids {
520                    let mut job_obj = HashMap::new();
521
522                    // outputs sub-object (empty if no outputs recorded)
523                    let outputs_map: HashMap<String, ExprValue> = self
524                        .needs_context
525                        .get(job_id)
526                        .map(|m| {
527                            m.iter()
528                                .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
529                                .collect()
530                        })
531                        .unwrap_or_default();
532                    job_obj.insert("outputs".to_string(), ExprValue::Object(outputs_map));
533
534                    // result (only present if the job has a recorded result)
535                    if let Some(result) = self.needs_results.get(job_id) {
536                        job_obj.insert("result".to_string(), ExprValue::String(result.clone()));
537                    }
538
539                    map.insert(job_id.clone(), ExprValue::Object(job_obj));
540                }
541                ExprValue::Object(map)
542            }
543            "secrets" if parts.len() == 1 => {
544                // Wrap `secrets_context` as an Object so `toJSON(secrets)` can
545                // serialise it. Mirrors real GHA's `secrets` context shape
546                // (flat `{ name: value }` map).
547                //
548                // Values are returned in plaintext by design — same policy as
549                // `toJSON(github)` for `GITHUB_TOKEN`. Masking is a log-boundary
550                // concern handled by `wrkflw_secrets::SecretMasker` when wired in
551                // via `engine.rs`. Do not dump this object to untrusted sinks
552                // without routing through the masker.
553                let map = self
554                    .secrets_context
555                    .iter()
556                    .map(|(k, v)| (k.clone(), ExprValue::String(v.clone())))
557                    .collect();
558                ExprValue::Object(map)
559            }
560            "matrix" if parts.len() == 1 => {
561                // Wrap the current matrix combination as an Object so
562                // `toJSON(matrix)` can serialise it. Values are converted via
563                // `yaml_value_to_expr`, identical to the dotted-access arm
564                // above, so bare and dotted forms agree on per-value shape.
565                //
566                // Asymmetric with the other bare-context arms on purpose: when
567                // `matrix_combination` is `None` (non-matrix job), return `Null`
568                // rather than an empty `Object`. `None` encodes "no matrix
569                // context exists" — which is what real GHA exposes for jobs
570                // without a matrix strategy. `Some(empty)` would still render
571                // as `{}`, preserving the Some/None distinction carried by the
572                // field's `Option<...>` type.
573                if let Some(matrix) = self.matrix_combination {
574                    let map = matrix
575                        .iter()
576                        .map(|(k, v)| (k.clone(), yaml_value_to_expr(v)))
577                        .collect();
578                    ExprValue::Object(map)
579                } else {
580                    ExprValue::Null
581                }
582            }
583            _ => ExprValue::Null,
584        }
585    }
586}
587
588fn yaml_value_to_expr(v: &Value) -> ExprValue {
589    match v {
590        Value::String(s) => ExprValue::String(s.clone()),
591        Value::Number(n) => ExprValue::Number(n.as_f64().unwrap_or(0.0)),
592        Value::Bool(b) => ExprValue::Bool(*b),
593        Value::Null => ExprValue::Null,
594        _ => ExprValue::String(
595            serde_yaml::to_string(v)
596                .unwrap_or_else(|_| format!("{:?}", v))
597                .trim()
598                .to_string(),
599        ),
600    }
601}
602
603// ---------------------------------------------------------------------------
604// Parser + Evaluator (recursive descent)
605// ---------------------------------------------------------------------------
606
607struct Parser {
608    tokens: Vec<Token>,
609    pos: usize,
610}
611
612impl Parser {
613    fn new(tokens: Vec<Token>) -> Self {
614        Self { tokens, pos: 0 }
615    }
616
617    fn peek(&self) -> &Token {
618        self.tokens.get(self.pos).unwrap_or(&Token::Eof)
619    }
620
621    fn advance(&mut self) -> Token {
622        let tok = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof);
623        self.pos += 1;
624        tok
625    }
626
627    fn expect(&mut self, expected: &Token) -> Result<(), String> {
628        let tok = self.advance();
629        if &tok == expected {
630            Ok(())
631        } else {
632            Err(format!("expected {:?}, got {:?}", expected, tok))
633        }
634    }
635
636    // Grammar: expr = or_expr
637    fn parse_expr(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
638        self.parse_or(ctx)
639    }
640
641    // or_expr = and_expr ( '||' and_expr )*
642    fn parse_or(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
643        let mut left = self.parse_and(ctx)?;
644        while *self.peek() == Token::Or {
645            self.advance();
646            let right = self.parse_and(ctx)?;
647            // GitHub Actions || returns the first truthy value, or the last value
648            left = if left.is_truthy() { left } else { right };
649        }
650        Ok(left)
651    }
652
653    // and_expr = comparison ( '&&' comparison )*
654    fn parse_and(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
655        let mut left = self.parse_comparison(ctx)?;
656        while *self.peek() == Token::And {
657            self.advance();
658            let right = self.parse_comparison(ctx)?;
659            // GitHub Actions && returns the first falsy value, or the last value
660            left = if !left.is_truthy() { left } else { right };
661        }
662        Ok(left)
663    }
664
665    // comparison = unary ( ('==' | '!=' | '<' | '<=' | '>' | '>=') unary )?
666    fn parse_comparison(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
667        let left = self.parse_unary(ctx)?;
668        match self.peek().clone() {
669            Token::Eq => {
670                self.advance();
671                let right = self.parse_unary(ctx)?;
672                Ok(ExprValue::Bool(expr_eq(&left, &right)))
673            }
674            Token::Ne => {
675                self.advance();
676                let right = self.parse_unary(ctx)?;
677                Ok(ExprValue::Bool(!expr_eq(&left, &right)))
678            }
679            Token::Lt => {
680                self.advance();
681                let right = self.parse_unary(ctx)?;
682                Ok(ExprValue::Bool(
683                    expr_cmp(&left, &right) == Some(std::cmp::Ordering::Less),
684                ))
685            }
686            Token::Le => {
687                self.advance();
688                let right = self.parse_unary(ctx)?;
689                Ok(ExprValue::Bool(matches!(
690                    expr_cmp(&left, &right),
691                    Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
692                )))
693            }
694            Token::Gt => {
695                self.advance();
696                let right = self.parse_unary(ctx)?;
697                Ok(ExprValue::Bool(
698                    expr_cmp(&left, &right) == Some(std::cmp::Ordering::Greater),
699                ))
700            }
701            Token::Ge => {
702                self.advance();
703                let right = self.parse_unary(ctx)?;
704                Ok(ExprValue::Bool(matches!(
705                    expr_cmp(&left, &right),
706                    Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
707                )))
708            }
709            _ => Ok(left),
710        }
711    }
712
713    // unary = '!' unary | primary
714    fn parse_unary(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
715        if *self.peek() == Token::Not {
716            self.advance();
717            let val = self.parse_unary(ctx)?;
718            Ok(ExprValue::Bool(!val.is_truthy()))
719        } else {
720            self.parse_primary(ctx)
721        }
722    }
723
724    // primary = literal | '(' expr ')' | ident_or_call
725    fn parse_primary(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
726        match self.peek().clone() {
727            Token::StringLit(s) => {
728                self.advance();
729                Ok(ExprValue::String(s))
730            }
731            Token::NumberLit(n) => {
732                self.advance();
733                Ok(ExprValue::Number(n))
734            }
735            Token::True => {
736                self.advance();
737                Ok(ExprValue::Bool(true))
738            }
739            Token::False => {
740                self.advance();
741                Ok(ExprValue::Bool(false))
742            }
743            Token::Null => {
744                self.advance();
745                Ok(ExprValue::Null)
746            }
747            Token::LParen => {
748                self.advance();
749                let val = self.parse_expr(ctx)?;
750                self.expect(&Token::RParen)?;
751                Ok(val)
752            }
753            Token::Ident(_) => self.parse_ident_or_call(ctx),
754            Token::Not => self.parse_unary(ctx),
755            other => Err(format!("unexpected token: {:?}", other)),
756        }
757    }
758
759    // ident_or_call:
760    //   ident '(' args ')' => function call
761    //   ident ('.' ident)* => context reference
762    fn parse_ident_or_call(&mut self, ctx: &ExpressionContext) -> Result<ExprValue, String> {
763        let Token::Ident(name) = self.advance() else {
764            return Err("expected identifier".to_string());
765        };
766
767        // Function call?
768        if *self.peek() == Token::LParen {
769            self.advance(); // consume '('
770            let mut args = Vec::new();
771            if *self.peek() != Token::RParen {
772                args.push(self.parse_expr(ctx)?);
773                while *self.peek() == Token::Comma {
774                    self.advance();
775                    args.push(self.parse_expr(ctx)?);
776                }
777            }
778            self.expect(&Token::RParen)?;
779            return call_builtin(&name, &args, ctx);
780        }
781
782        // Context reference: ident.ident.ident...
783        let mut parts = vec![name];
784        while *self.peek() == Token::Dot {
785            self.advance(); // consume '.'
786            match self.advance() {
787                Token::Ident(part) => parts.push(part),
788                other => return Err(format!("expected identifier after '.', got {:?}", other)),
789            }
790        }
791
792        Ok(ctx.resolve(&parts))
793    }
794}
795
796// ---------------------------------------------------------------------------
797// Comparison helpers
798// ---------------------------------------------------------------------------
799
800fn expr_eq(a: &ExprValue, b: &ExprValue) -> bool {
801    // GitHub Actions does loose type coercion for ==
802    match (a, b) {
803        (ExprValue::Null, ExprValue::Null) => true,
804        (ExprValue::Null, _) | (_, ExprValue::Null) => false,
805        (ExprValue::Bool(a), ExprValue::Bool(b)) => a == b,
806        (ExprValue::Number(a), ExprValue::Number(b)) => (a - b).abs() < f64::EPSILON,
807        (ExprValue::String(a), ExprValue::String(b)) => a.eq_ignore_ascii_case(b),
808        // Coerce number to string for comparison
809        (ExprValue::String(s), ExprValue::Number(n))
810        | (ExprValue::Number(n), ExprValue::String(s)) => {
811            if let Ok(parsed) = s.parse::<f64>() {
812                (parsed - n).abs() < f64::EPSILON
813            } else {
814                false
815            }
816        }
817        // Coerce bool to number: true=1, false=0
818        (ExprValue::Bool(b), ExprValue::Number(n)) | (ExprValue::Number(n), ExprValue::Bool(b)) => {
819            let bv = if *b { 1.0 } else { 0.0 };
820            (bv - n).abs() < f64::EPSILON
821        }
822        (ExprValue::Bool(b), ExprValue::String(s)) | (ExprValue::String(s), ExprValue::Bool(b)) => {
823            // GitHub Actions coerces strings to booleans for comparison:
824            // "true" (case-insensitive) → true, everything else → false.
825            // This means `false == "random"` is true (both coerce to false).
826            let sv = s.eq_ignore_ascii_case("true");
827            *b == sv
828        }
829        // Objects are not comparable via ==
830        (ExprValue::Object(_), _) | (_, ExprValue::Object(_)) => false,
831    }
832}
833
834fn expr_cmp(a: &ExprValue, b: &ExprValue) -> Option<std::cmp::Ordering> {
835    match (a, b) {
836        (ExprValue::Number(a), ExprValue::Number(b)) => a.partial_cmp(b),
837        (ExprValue::String(a), ExprValue::String(b)) => {
838            Some(a.to_lowercase().cmp(&b.to_lowercase()))
839        }
840        // Objects are not orderable — comparisons like `env < env` yield None
841        // (meaning the comparison expression will evaluate to false).
842        (ExprValue::Object(_), _) | (_, ExprValue::Object(_)) => None,
843        _ => None,
844    }
845}
846
847// ---------------------------------------------------------------------------
848// Built-in functions
849// ---------------------------------------------------------------------------
850
851/// Convert an `ExprValue` to a `serde_json::Value` for JSON serialisation.
852fn expr_to_json(v: &ExprValue) -> serde_json::Value {
853    match v {
854        ExprValue::String(s) => serde_json::Value::String(s.clone()),
855        ExprValue::Number(n) => serde_json::json!(n),
856        ExprValue::Bool(b) => serde_json::Value::Bool(*b),
857        ExprValue::Null => serde_json::Value::Null,
858        ExprValue::Object(map) => {
859            let obj: serde_json::Map<String, serde_json::Value> = map
860                .iter()
861                .map(|(k, v)| (k.clone(), expr_to_json(v)))
862                .collect();
863            serde_json::Value::Object(obj)
864        }
865    }
866}
867
868fn call_builtin(
869    name: &str,
870    args: &[ExprValue],
871    ctx: &ExpressionContext,
872) -> Result<ExprValue, String> {
873    match name {
874        "contains" => {
875            if args.len() != 2 {
876                return Err("contains() requires 2 arguments".to_string());
877            }
878            let haystack = args[0].to_output_string().to_lowercase();
879            let needle = args[1].to_output_string().to_lowercase();
880            Ok(ExprValue::Bool(haystack.contains(&needle)))
881        }
882        "startsWith" | "startswith" => {
883            if args.len() != 2 {
884                return Err("startsWith() requires 2 arguments".to_string());
885            }
886            let s = args[0].to_output_string().to_lowercase();
887            let prefix = args[1].to_output_string().to_lowercase();
888            Ok(ExprValue::Bool(s.starts_with(&prefix)))
889        }
890        "endsWith" | "endswith" => {
891            if args.len() != 2 {
892                return Err("endsWith() requires 2 arguments".to_string());
893            }
894            let s = args[0].to_output_string().to_lowercase();
895            let suffix = args[1].to_output_string().to_lowercase();
896            Ok(ExprValue::Bool(s.ends_with(&suffix)))
897        }
898        "format" => {
899            if args.is_empty() {
900                return Err("format() requires at least 1 argument".to_string());
901            }
902            let fmt = args[0].to_output_string();
903            // Single-pass replacement to prevent arg content from being consumed
904            // by later placeholder substitutions (e.g. format('{0} {1}', '{1}', 'x')
905            // should produce '{1} x', not 'x x').
906            let mut result = String::with_capacity(fmt.len());
907            let mut chars = fmt.char_indices().peekable();
908            while let Some((i, ch)) = chars.next() {
909                if ch == '{' {
910                    // Look for {N} pattern
911                    let rest = &fmt[i + 1..];
912                    if let Some(close) = rest.find('}') {
913                        let inner = &rest[..close];
914                        if let Ok(idx) = inner.parse::<usize>() {
915                            if idx + 1 < args.len() {
916                                result.push_str(&args[idx + 1].to_output_string());
917                                // Skip past the closing '}'
918                                let skip_to = i + 1 + close + 1;
919                                while chars.peek().is_some_and(|(ci, _)| *ci < skip_to) {
920                                    chars.next();
921                                }
922                                continue;
923                            }
924                        }
925                    }
926                }
927                result.push(ch);
928            }
929            Ok(ExprValue::String(result))
930        }
931        "join" => {
932            if args.is_empty() || args.len() > 2 {
933                return Err("join() requires 1 or 2 arguments".to_string());
934            }
935            let sep = if args.len() == 2 {
936                args[1].to_output_string()
937            } else {
938                ",".to_string()
939            };
940            // Best-effort: just return the value as-is since we don't have arrays
941            Ok(ExprValue::String(
942                args[0].to_output_string().replace(',', &sep),
943            ))
944        }
945        "toJSON" | "tojson" => {
946            if args.len() != 1 {
947                return Err("toJSON() requires 1 argument".to_string());
948            }
949            match &args[0] {
950                ExprValue::String(s) => {
951                    // Use serde_json for robust escaping (handles control chars, null bytes, etc.)
952                    Ok(ExprValue::String(
953                        serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s)),
954                    ))
955                }
956                ExprValue::Number(n) => Ok(ExprValue::String(format!("{}", n))),
957                ExprValue::Bool(b) => Ok(ExprValue::String(format!("{}", b))),
958                ExprValue::Null => Ok(ExprValue::String("null".to_string())),
959                ExprValue::Object(map) => {
960                    // Serialize as sorted, pretty-printed JSON (matches GHA behaviour).
961                    // Use serde_json::Value so nested objects serialize correctly.
962                    let sorted: std::collections::BTreeMap<&String, serde_json::Value> =
963                        map.iter().map(|(k, v)| (k, expr_to_json(v))).collect();
964                    Ok(ExprValue::String(
965                        serde_json::to_string_pretty(&sorted).unwrap_or_else(|_| "{}".to_string()),
966                    ))
967                }
968            }
969        }
970        "fromJSON" | "fromjson" => {
971            if args.len() != 1 {
972                return Err("fromJSON() requires 1 argument".to_string());
973            }
974            let s = args[0].to_output_string();
975            // Basic parsing
976            match s.as_str() {
977                "null" => Ok(ExprValue::Null),
978                "true" => Ok(ExprValue::Bool(true)),
979                "false" => Ok(ExprValue::Bool(false)),
980                _ => {
981                    if let Ok(n) = s.parse::<f64>() {
982                        Ok(ExprValue::Number(n))
983                    } else {
984                        // Strip one layer of quotes if present
985                        let stripped = s
986                            .strip_prefix('"')
987                            .and_then(|s| s.strip_suffix('"'))
988                            .unwrap_or(&s);
989                        Ok(ExprValue::String(stripped.to_string()))
990                    }
991                }
992            }
993        }
994        // Status functions — consult job_status from context
995        "success" => Ok(ExprValue::Bool(ctx.job_status == "success")),
996        "failure" => Ok(ExprValue::Bool(ctx.job_status == "failure")),
997        "always" => Ok(ExprValue::Bool(true)),
998        "cancelled" => Ok(ExprValue::Bool(ctx.job_status == "cancelled")),
999        _ => {
1000            // Unknown function — return null rather than erroring
1001            Ok(ExprValue::Null)
1002        }
1003    }
1004}
1005
1006// ---------------------------------------------------------------------------
1007// Public API
1008// ---------------------------------------------------------------------------
1009
1010/// Evaluate a GitHub Actions expression string and return the result.
1011///
1012/// The expression should be the content inside `${{ ... }}` (without the
1013/// delimiters). Returns `Err` on parse/evaluation errors.
1014pub fn evaluate(expr: &str, ctx: &ExpressionContext) -> Result<ExprValue, String> {
1015    let trimmed = expr.trim();
1016    if trimmed.is_empty() {
1017        return Ok(ExprValue::Null);
1018    }
1019    let mut tokenizer = Tokenizer::new(trimmed);
1020    let tokens = tokenizer.tokenize()?;
1021    let mut parser = Parser::new(tokens);
1022    let result = parser.parse_expr(ctx)?;
1023    // Ensure we consumed all tokens
1024    if *parser.peek() != Token::Eof {
1025        return Err(format!(
1026            "unexpected token after expression: {:?}",
1027            parser.peek()
1028        ));
1029    }
1030    Ok(result)
1031}
1032
1033/// Evaluate a GitHub Actions expression and return it as a boolean.
1034///
1035/// Used for `if:` conditions. Strips `${{ }}` wrappers if present.
1036pub fn evaluate_as_bool(expr: &str, ctx: &ExpressionContext) -> Result<bool, String> {
1037    let trimmed = expr.trim();
1038    // Strip ${{ }} if present
1039    let inner = if trimmed.starts_with("${{") && trimmed.ends_with("}}") {
1040        &trimmed[3..trimmed.len() - 2]
1041    } else {
1042        trimmed
1043    };
1044    let val = evaluate(inner, ctx)?;
1045    Ok(val.is_truthy())
1046}
1047
1048// ---------------------------------------------------------------------------
1049// Tests
1050// ---------------------------------------------------------------------------
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    lazy_static::lazy_static! {
1057        static ref EMPTY_ENV: HashMap<String, String> = HashMap::new();
1058        static ref EMPTY_USER_ENV: HashMap<String, String> = HashMap::new();
1059        static ref EMPTY_STEPS: HashMap<String, HashMap<String, String>> = HashMap::new();
1060        static ref EMPTY_MATRIX: Option<HashMap<String, Value>> = None;
1061        static ref EMPTY_STATUSES: HashMap<String, (String, String)> = HashMap::new();
1062        static ref EMPTY_SECRETS: HashMap<String, String> = HashMap::new();
1063        static ref EMPTY_NEEDS: HashMap<String, HashMap<String, String>> = HashMap::new();
1064        static ref EMPTY_NEEDS_RESULTS: HashMap<String, String> = HashMap::new();
1065    }
1066
1067    fn empty_ctx() -> ExpressionContext<'static> {
1068        ExpressionContext {
1069            env_context: &EMPTY_ENV,
1070            step_outputs: &EMPTY_STEPS,
1071            matrix_combination: &EMPTY_MATRIX,
1072            step_statuses: &EMPTY_STATUSES,
1073            job_status: "success",
1074            secrets_context: &EMPTY_SECRETS,
1075            needs_context: &EMPTY_NEEDS,
1076            needs_results: &EMPTY_NEEDS_RESULTS,
1077            user_env: &EMPTY_USER_ENV,
1078        }
1079    }
1080
1081    /// Build an `ExpressionContext` from the fields that vary across tests;
1082    /// all other fields default to empty/success. The `env` map is wired into
1083    /// both `env_context` (for dotted `env.X` lookups) AND `user_env` (for
1084    /// `toJSON(env)` / bare `env`), so tests that don't distinguish the two
1085    /// populations continue to work with a single input map. Tests that need
1086    /// to distinguish user-declared from runner-seeded env should construct
1087    /// `ExpressionContext` directly.
1088    fn make_ctx<'a>(
1089        env: &'a HashMap<String, String>,
1090        steps: &'a HashMap<String, HashMap<String, String>>,
1091        matrix: &'a Option<HashMap<String, Value>>,
1092    ) -> ExpressionContext<'a> {
1093        ExpressionContext {
1094            env_context: env,
1095            step_outputs: steps,
1096            matrix_combination: matrix,
1097            step_statuses: &EMPTY_STATUSES,
1098            job_status: "success",
1099            secrets_context: &EMPTY_SECRETS,
1100            needs_context: &EMPTY_NEEDS,
1101            needs_results: &EMPTY_NEEDS_RESULTS,
1102            user_env: env,
1103        }
1104    }
1105
1106    // -- Literals --
1107
1108    #[test]
1109    fn eval_string_literal() {
1110        let ctx = empty_ctx();
1111        assert_eq!(
1112            evaluate("'hello'", &ctx).unwrap(),
1113            ExprValue::String("hello".to_string())
1114        );
1115    }
1116
1117    #[test]
1118    fn eval_empty_string_literal() {
1119        let ctx = empty_ctx();
1120        assert_eq!(
1121            evaluate("''", &ctx).unwrap(),
1122            ExprValue::String(String::new())
1123        );
1124    }
1125
1126    #[test]
1127    fn eval_number_literal() {
1128        let ctx = empty_ctx();
1129        assert_eq!(evaluate("42", &ctx).unwrap(), ExprValue::Number(42.0));
1130    }
1131
1132    #[test]
1133    fn eval_bool_literals() {
1134        let ctx = empty_ctx();
1135        assert_eq!(evaluate("true", &ctx).unwrap(), ExprValue::Bool(true));
1136        assert_eq!(evaluate("false", &ctx).unwrap(), ExprValue::Bool(false));
1137    }
1138
1139    #[test]
1140    fn eval_null_literal() {
1141        let ctx = empty_ctx();
1142        assert_eq!(evaluate("null", &ctx).unwrap(), ExprValue::Null);
1143    }
1144
1145    // -- Truthiness --
1146
1147    #[test]
1148    fn truthiness() {
1149        assert!(ExprValue::Bool(true).is_truthy());
1150        assert!(!ExprValue::Bool(false).is_truthy());
1151        assert!(ExprValue::Number(1.0).is_truthy());
1152        assert!(!ExprValue::Number(0.0).is_truthy());
1153        assert!(ExprValue::String("hello".to_string()).is_truthy());
1154        assert!(!ExprValue::String(String::new()).is_truthy());
1155        assert!(!ExprValue::Null.is_truthy());
1156    }
1157
1158    // -- Operators --
1159
1160    #[test]
1161    fn eval_equality() {
1162        let ctx = empty_ctx();
1163        assert_eq!(
1164            evaluate("'nightly' == 'nightly'", &ctx).unwrap(),
1165            ExprValue::Bool(true)
1166        );
1167        assert_eq!(
1168            evaluate("'nightly' == 'stable'", &ctx).unwrap(),
1169            ExprValue::Bool(false)
1170        );
1171        assert_eq!(
1172            evaluate("'nightly' != 'stable'", &ctx).unwrap(),
1173            ExprValue::Bool(true)
1174        );
1175    }
1176
1177    #[test]
1178    fn eval_bool_string_coercion() {
1179        let ctx = empty_ctx();
1180        // GitHub Actions coerces strings to booleans: "true" → true, everything else → false.
1181        // So false == "random" is true because "random" coerces to false.
1182        assert_eq!(
1183            evaluate("false == 'random'", &ctx).unwrap(),
1184            ExprValue::Bool(true)
1185        );
1186        assert_eq!(
1187            evaluate("true == 'true'", &ctx).unwrap(),
1188            ExprValue::Bool(true)
1189        );
1190        assert_eq!(
1191            evaluate("true == 'TRUE'", &ctx).unwrap(),
1192            ExprValue::Bool(true)
1193        );
1194        assert_eq!(
1195            evaluate("true == 'false'", &ctx).unwrap(),
1196            ExprValue::Bool(false)
1197        );
1198        assert_eq!(
1199            evaluate("false == 'false'", &ctx).unwrap(),
1200            ExprValue::Bool(true)
1201        );
1202    }
1203
1204    #[test]
1205    fn eval_case_insensitive_equality() {
1206        let ctx = empty_ctx();
1207        assert_eq!(
1208            evaluate("'Nightly' == 'nightly'", &ctx).unwrap(),
1209            ExprValue::Bool(true)
1210        );
1211    }
1212
1213    #[test]
1214    fn eval_number_comparison() {
1215        let ctx = empty_ctx();
1216        assert_eq!(evaluate("1 < 2", &ctx).unwrap(), ExprValue::Bool(true));
1217        assert_eq!(evaluate("2 >= 2", &ctx).unwrap(), ExprValue::Bool(true));
1218        assert_eq!(evaluate("3 <= 2", &ctx).unwrap(), ExprValue::Bool(false));
1219    }
1220
1221    #[test]
1222    fn eval_and_operator() {
1223        let ctx = empty_ctx();
1224        // && returns first falsy or last value
1225        assert_eq!(
1226            evaluate("true && 'hello'", &ctx).unwrap(),
1227            ExprValue::String("hello".to_string())
1228        );
1229        assert_eq!(
1230            evaluate("false && 'hello'", &ctx).unwrap(),
1231            ExprValue::Bool(false)
1232        );
1233        assert_eq!(
1234            evaluate("'' && 'hello'", &ctx).unwrap(),
1235            ExprValue::String(String::new())
1236        );
1237    }
1238
1239    #[test]
1240    fn eval_or_operator() {
1241        let ctx = empty_ctx();
1242        // || returns first truthy or last value
1243        assert_eq!(
1244            evaluate("'hi' || 'bye'", &ctx).unwrap(),
1245            ExprValue::String("hi".to_string())
1246        );
1247        assert_eq!(
1248            evaluate("'' || 'fallback'", &ctx).unwrap(),
1249            ExprValue::String("fallback".to_string())
1250        );
1251        assert_eq!(
1252            evaluate("false || ''", &ctx).unwrap(),
1253            ExprValue::String(String::new())
1254        );
1255    }
1256
1257    #[test]
1258    fn eval_not_operator() {
1259        let ctx = empty_ctx();
1260        assert_eq!(evaluate("!true", &ctx).unwrap(), ExprValue::Bool(false));
1261        assert_eq!(evaluate("!false", &ctx).unwrap(), ExprValue::Bool(true));
1262        assert_eq!(evaluate("!''", &ctx).unwrap(), ExprValue::Bool(true));
1263    }
1264
1265    #[test]
1266    fn eval_parentheses() {
1267        let ctx = empty_ctx();
1268        assert_eq!(
1269            evaluate("(true || false) && false", &ctx).unwrap(),
1270            ExprValue::Bool(false)
1271        );
1272    }
1273
1274    // -- Context resolution --
1275
1276    #[test]
1277    fn eval_inputs_context() {
1278        let mut env = HashMap::new();
1279        env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string());
1280        let empty_steps = HashMap::new();
1281        let ctx = make_ctx(&env, &empty_steps, &None);
1282
1283        assert_eq!(
1284            evaluate("inputs.toolchain", &ctx).unwrap(),
1285            ExprValue::String("nightly".to_string())
1286        );
1287    }
1288
1289    #[test]
1290    fn eval_env_context() {
1291        let mut env = HashMap::new();
1292        env.insert("MY_VAR".to_string(), "hello".to_string());
1293        let empty_steps = HashMap::new();
1294        let ctx = make_ctx(&env, &empty_steps, &None);
1295
1296        assert_eq!(
1297            evaluate("env.MY_VAR", &ctx).unwrap(),
1298            ExprValue::String("hello".to_string())
1299        );
1300    }
1301
1302    #[test]
1303    fn eval_github_context() {
1304        let mut env = HashMap::new();
1305        env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
1306        let empty_steps = HashMap::new();
1307        let ctx = make_ctx(&env, &empty_steps, &None);
1308
1309        assert_eq!(
1310            evaluate("github.repository", &ctx).unwrap(),
1311            ExprValue::String("owner/repo".to_string())
1312        );
1313    }
1314
1315    #[test]
1316    fn eval_steps_outputs() {
1317        let mut steps = HashMap::new();
1318        let mut build_out = HashMap::new();
1319        build_out.insert("version".to_string(), "1.2.3".to_string());
1320        steps.insert("build".to_string(), build_out);
1321        let empty_env = HashMap::new();
1322        let ctx = make_ctx(&empty_env, &steps, &None);
1323
1324        assert_eq!(
1325            evaluate("steps.build.outputs.version", &ctx).unwrap(),
1326            ExprValue::String("1.2.3".to_string())
1327        );
1328    }
1329
1330    #[test]
1331    fn eval_matrix_context() {
1332        let mut matrix = HashMap::new();
1333        matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
1334        let empty_env = HashMap::new();
1335        let empty_steps = HashMap::new();
1336        let matrix = Some(matrix);
1337        let ctx = make_ctx(&empty_env, &empty_steps, &matrix);
1338
1339        assert_eq!(
1340            evaluate("matrix.os", &ctx).unwrap(),
1341            ExprValue::String("ubuntu".to_string())
1342        );
1343    }
1344
1345    #[test]
1346    fn eval_missing_context_returns_null() {
1347        let ctx = empty_ctx();
1348        assert_eq!(
1349            evaluate("inputs.nonexistent", &ctx).unwrap(),
1350            ExprValue::Null
1351        );
1352    }
1353
1354    // -- Complex expressions (the dtolnay/rust-toolchain pattern) --
1355
1356    #[test]
1357    fn eval_rust_toolchain_pattern() {
1358        // ${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}
1359        let mut env = HashMap::new();
1360        env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
1361
1362        let mut steps = HashMap::new();
1363        let mut parse_out = HashMap::new();
1364        parse_out.insert("toolchain".to_string(), "nightly".to_string());
1365        steps.insert("parse".to_string(), parse_out);
1366
1367        let ctx = make_ctx(&env, &steps, &None);
1368
1369        let result = evaluate(
1370            "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
1371            &ctx,
1372        )
1373        .unwrap();
1374        assert_eq!(result, ExprValue::String(" --allow-downgrade".to_string()));
1375    }
1376
1377    #[test]
1378    fn eval_rust_toolchain_pattern_not_nightly() {
1379        let mut env = HashMap::new();
1380        env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
1381
1382        let mut steps = HashMap::new();
1383        let mut parse_out = HashMap::new();
1384        parse_out.insert("toolchain".to_string(), "stable".to_string());
1385        steps.insert("parse".to_string(), parse_out);
1386
1387        let ctx = make_ctx(&env, &steps, &None);
1388
1389        let result = evaluate(
1390            "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
1391            &ctx,
1392        )
1393        .unwrap();
1394        // 'stable' != 'nightly' → false, && short-circuits, || returns ''
1395        assert_eq!(result, ExprValue::String(String::new()));
1396    }
1397
1398    #[test]
1399    fn eval_rust_toolchain_pattern_no_components() {
1400        let env = HashMap::new(); // no INPUT_COMPONENTS
1401
1402        let mut steps = HashMap::new();
1403        let mut parse_out = HashMap::new();
1404        parse_out.insert("toolchain".to_string(), "nightly".to_string());
1405        steps.insert("parse".to_string(), parse_out);
1406
1407        let ctx = make_ctx(&env, &steps, &None);
1408
1409        let result = evaluate(
1410            "steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''",
1411            &ctx,
1412        )
1413        .unwrap();
1414        // toolchain == nightly → true, inputs.components → null (falsy), && returns null, || returns ''
1415        assert_eq!(result, ExprValue::String(String::new()));
1416    }
1417
1418    // -- Built-in functions --
1419
1420    #[test]
1421    fn eval_contains() {
1422        let ctx = empty_ctx();
1423        assert_eq!(
1424            evaluate("contains('Hello World', 'hello')", &ctx).unwrap(),
1425            ExprValue::Bool(true)
1426        );
1427        assert_eq!(
1428            evaluate("contains('Hello', 'xyz')", &ctx).unwrap(),
1429            ExprValue::Bool(false)
1430        );
1431    }
1432
1433    #[test]
1434    fn eval_starts_with() {
1435        let ctx = empty_ctx();
1436        assert_eq!(
1437            evaluate("startsWith('refs/heads/main', 'refs/heads')", &ctx).unwrap(),
1438            ExprValue::Bool(true)
1439        );
1440        assert_eq!(
1441            evaluate("startsWith('refs/tags/v1', 'refs/heads')", &ctx).unwrap(),
1442            ExprValue::Bool(false)
1443        );
1444    }
1445
1446    #[test]
1447    fn eval_ends_with() {
1448        let ctx = empty_ctx();
1449        assert_eq!(
1450            evaluate("endsWith('hello.txt', '.txt')", &ctx).unwrap(),
1451            ExprValue::Bool(true)
1452        );
1453    }
1454
1455    #[test]
1456    fn eval_format_function() {
1457        let ctx = empty_ctx();
1458        assert_eq!(
1459            evaluate("format('Hello {0}, you are {1}', 'world', 'great')", &ctx).unwrap(),
1460            ExprValue::String("Hello world, you are great".to_string())
1461        );
1462    }
1463
1464    #[test]
1465    fn eval_format_non_ascii() {
1466        let ctx = empty_ctx();
1467        assert_eq!(
1468            evaluate("format('{0} → {1}', 'a', 'b')", &ctx).unwrap(),
1469            ExprValue::String("a → b".to_string())
1470        );
1471    }
1472
1473    #[test]
1474    fn eval_format_out_of_bounds_placeholder_preserved() {
1475        let ctx = empty_ctx();
1476        // {5} references a non-existent arg — should be left as literal "{5}"
1477        assert_eq!(
1478            evaluate("format('{0} {5}', 'hi')", &ctx).unwrap(),
1479            ExprValue::String("hi {5}".to_string())
1480        );
1481    }
1482
1483    #[test]
1484    fn eval_format_arg_containing_placeholder_not_reinterpreted() {
1485        let ctx = empty_ctx();
1486        // format('{0} {1}', '{1}', 'x') should produce '{1} x', not 'x x'
1487        assert_eq!(
1488            evaluate("format('{0} {1}', '{1}', 'x')", &ctx).unwrap(),
1489            ExprValue::String("{1} x".to_string())
1490        );
1491    }
1492
1493    #[test]
1494    fn eval_status_functions() {
1495        let ctx = empty_ctx();
1496        assert_eq!(evaluate("success()", &ctx).unwrap(), ExprValue::Bool(true));
1497        assert_eq!(evaluate("failure()", &ctx).unwrap(), ExprValue::Bool(false));
1498        assert_eq!(evaluate("always()", &ctx).unwrap(), ExprValue::Bool(true));
1499        assert_eq!(
1500            evaluate("cancelled()", &ctx).unwrap(),
1501            ExprValue::Bool(false)
1502        );
1503    }
1504
1505    // -- evaluate_as_bool --
1506
1507    #[test]
1508    fn eval_as_bool_strips_delimiters() {
1509        let ctx = empty_ctx();
1510        assert!(evaluate_as_bool("${{ true }}", &ctx).unwrap());
1511        assert!(!evaluate_as_bool("${{ false }}", &ctx).unwrap());
1512    }
1513
1514    #[test]
1515    fn eval_as_bool_bare_expression() {
1516        let ctx = empty_ctx();
1517        assert!(evaluate_as_bool("true", &ctx).unwrap());
1518        assert!(!evaluate_as_bool("false", &ctx).unwrap());
1519    }
1520
1521    #[test]
1522    fn eval_as_bool_condition_with_context() {
1523        let mut env = HashMap::new();
1524        env.insert("GITHUB_REF".to_string(), "refs/tags/v1.0.0".to_string());
1525        let empty_steps = HashMap::new();
1526        let ctx = make_ctx(&env, &empty_steps, &None);
1527
1528        assert!(evaluate_as_bool("startsWith(github.ref, 'refs/tags/')", &ctx).unwrap());
1529    }
1530
1531    // -- Output string formatting --
1532
1533    #[test]
1534    fn output_string_formatting() {
1535        assert_eq!(ExprValue::String("hi".to_string()).to_output_string(), "hi");
1536        assert_eq!(ExprValue::Number(42.0).to_output_string(), "42");
1537        assert_eq!(ExprValue::Number(3.15).to_output_string(), "3.15");
1538        assert_eq!(ExprValue::Bool(true).to_output_string(), "true");
1539        assert_eq!(ExprValue::Null.to_output_string(), "");
1540    }
1541
1542    // -- Error cases --
1543
1544    #[test]
1545    fn eval_unterminated_string_errors() {
1546        let ctx = empty_ctx();
1547        assert!(evaluate("'unterminated", &ctx).is_err());
1548    }
1549
1550    #[test]
1551    fn eval_unexpected_token_errors() {
1552        let ctx = empty_ctx();
1553        assert!(evaluate("&&", &ctx).is_err());
1554    }
1555
1556    #[test]
1557    fn eval_empty_expression() {
1558        let ctx = empty_ctx();
1559        assert_eq!(evaluate("", &ctx).unwrap(), ExprValue::Null);
1560    }
1561
1562    #[test]
1563    fn unknown_step_id_returns_null() {
1564        let ctx = empty_ctx();
1565        assert_eq!(
1566            evaluate("steps.nonexistent.outcome", &ctx).unwrap(),
1567            ExprValue::Null
1568        );
1569        assert_eq!(
1570            evaluate("steps.nonexistent.conclusion", &ctx).unwrap(),
1571            ExprValue::Null
1572        );
1573    }
1574
1575    #[test]
1576    fn tojson_escapes_control_characters() {
1577        let ctx = empty_ctx();
1578        // Tab, newline, carriage return
1579        let result = evaluate("toJSON('line1\tindented\nline2\rend')", &ctx).unwrap();
1580        let s = result.to_output_string();
1581        assert!(s.contains("\\t"), "should escape tab: {}", s);
1582        assert!(s.contains("\\n"), "should escape newline: {}", s);
1583        assert!(s.contains("\\r"), "should escape carriage return: {}", s);
1584    }
1585
1586    #[test]
1587    fn tojson_escapes_quotes_and_backslash() {
1588        let ctx = empty_ctx();
1589        let result = evaluate(r#"toJSON('say "hello\world"')"#, &ctx).unwrap();
1590        let s = result.to_output_string();
1591        assert!(s.contains(r#"\""#), "should escape quotes: {}", s);
1592        assert!(s.contains(r"\\"), "should escape backslash: {}", s);
1593    }
1594
1595    #[test]
1596    fn tojson_handles_null_bytes() {
1597        let ctx = empty_ctx();
1598        // Null byte in string — serde_json encodes as \u0000
1599        let result = evaluate("toJSON('before\x00after')", &ctx).unwrap();
1600        let s = result.to_output_string();
1601        assert!(!s.contains('\0'), "should not contain raw null: {}", s);
1602    }
1603
1604    #[test]
1605    fn tojson_env_returns_object() {
1606        // env_context holds the union of user + runner env; user_env holds only
1607        // what the user declared. toJSON(env) dumps user_env.
1608        let mut user_env = HashMap::new();
1609        user_env.insert("MY_VAR".to_string(), "hello".to_string());
1610        user_env.insert("OTHER".to_string(), "world".to_string());
1611        let mut env_context = user_env.clone();
1612        env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1613        env_context.insert("RUNNER_OS".to_string(), "Linux".to_string());
1614        env_context.insert("INPUT_NAME".to_string(), "test".to_string());
1615        env_context.insert("CI".to_string(), "true".to_string());
1616        let ctx = ExpressionContext {
1617            env_context: &env_context,
1618            user_env: &user_env,
1619            step_outputs: &EMPTY_STEPS,
1620            matrix_combination: &EMPTY_MATRIX,
1621            step_statuses: &EMPTY_STATUSES,
1622            job_status: "success",
1623            secrets_context: &EMPTY_SECRETS,
1624            needs_context: &EMPTY_NEEDS,
1625            needs_results: &EMPTY_NEEDS_RESULTS,
1626        };
1627        let result = evaluate("toJSON(env)", &ctx).unwrap();
1628        let s = result.to_output_string();
1629        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1630        let obj = parsed.as_object().expect("should be a JSON object");
1631        assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
1632        assert_eq!(obj.get("OTHER").unwrap(), "world");
1633        assert!(
1634            obj.get("GITHUB_SHA").is_none(),
1635            "runner-seeded GITHUB_SHA should not leak into toJSON(env)"
1636        );
1637        assert!(
1638            obj.get("RUNNER_OS").is_none(),
1639            "runner-seeded RUNNER_OS should not leak into toJSON(env)"
1640        );
1641        assert!(
1642            obj.get("INPUT_NAME").is_none(),
1643            "runner-seeded INPUT_NAME should not leak into toJSON(env)"
1644        );
1645        assert!(
1646            obj.get("CI").is_none(),
1647            "runner-seeded CI should not leak into toJSON(env)"
1648        );
1649    }
1650
1651    #[test]
1652    fn tojson_env_sorted_keys() {
1653        let mut env = HashMap::new();
1654        env.insert("ZEBRA".to_string(), "z".to_string());
1655        env.insert("APPLE".to_string(), "a".to_string());
1656        env.insert("MANGO".to_string(), "m".to_string());
1657        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1658        let result = evaluate("toJSON(env)", &ctx).unwrap();
1659        let s = result.to_output_string();
1660        // Keys should appear in alphabetical order
1661        let apple_pos = s.find("APPLE").unwrap();
1662        let mango_pos = s.find("MANGO").unwrap();
1663        let zebra_pos = s.find("ZEBRA").unwrap();
1664        assert!(apple_pos < mango_pos, "APPLE should come before MANGO");
1665        assert!(mango_pos < zebra_pos, "MANGO should come before ZEBRA");
1666    }
1667
1668    #[test]
1669    fn bare_env_is_truthy() {
1670        let mut env = HashMap::new();
1671        env.insert("FOO".to_string(), "bar".to_string());
1672        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1673        // `env` alone should be truthy (it's an object)
1674        let result = evaluate("env", &ctx).unwrap();
1675        assert!(result.is_truthy());
1676    }
1677
1678    #[test]
1679    fn bare_env_to_output_string() {
1680        let mut env = HashMap::new();
1681        env.insert("FOO".to_string(), "bar".to_string());
1682        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1683        let result = evaluate("env", &ctx).unwrap();
1684        // GHA coerces objects to their JSON representation in string contexts.
1685        let s = result.to_output_string();
1686        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1687        let obj = parsed.as_object().expect("should be a JSON object");
1688        assert_eq!(obj.get("FOO").unwrap(), "bar");
1689    }
1690
1691    #[test]
1692    fn tojson_env_empty_when_only_internal_vars() {
1693        // env_context has runner-seeded vars; user_env is empty (user declared nothing).
1694        let mut env_context = HashMap::new();
1695        env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1696        env_context.insert("RUNNER_OS".to_string(), "Linux".to_string());
1697        env_context.insert("CI".to_string(), "true".to_string());
1698        let ctx = ExpressionContext {
1699            env_context: &env_context,
1700            user_env: &EMPTY_USER_ENV,
1701            step_outputs: &EMPTY_STEPS,
1702            matrix_combination: &EMPTY_MATRIX,
1703            step_statuses: &EMPTY_STATUSES,
1704            job_status: "success",
1705            secrets_context: &EMPTY_SECRETS,
1706            needs_context: &EMPTY_NEEDS,
1707            needs_results: &EMPTY_NEEDS_RESULTS,
1708        };
1709        let result = evaluate("toJSON(env)", &ctx).unwrap();
1710        let s = result.to_output_string();
1711        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1712        let obj = parsed.as_object().expect("should be a JSON object");
1713        assert!(
1714            obj.is_empty(),
1715            "should be empty when no user env is declared: {}",
1716            s
1717        );
1718    }
1719
1720    #[test]
1721    fn tojson_env_includes_user_var_with_internal_prefix() {
1722        // A user-declared `GITHUB_CUSTOM` (e.g. from `env: { GITHUB_CUSTOM: ... }`
1723        // in YAML) must appear in toJSON(env) — it's in user_env regardless of
1724        // what prefix the key happens to have. This is the bug the refactor fixes.
1725        let mut user_env = HashMap::new();
1726        user_env.insert("GITHUB_CUSTOM".to_string(), "user-val".to_string());
1727        user_env.insert("MY_VAR".to_string(), "hello".to_string());
1728        // env_context mirrors user_env for lookup coherence.
1729        let env_context = user_env.clone();
1730        let ctx = ExpressionContext {
1731            env_context: &env_context,
1732            user_env: &user_env,
1733            step_outputs: &EMPTY_STEPS,
1734            matrix_combination: &EMPTY_MATRIX,
1735            step_statuses: &EMPTY_STATUSES,
1736            job_status: "success",
1737            secrets_context: &EMPTY_SECRETS,
1738            needs_context: &EMPTY_NEEDS,
1739            needs_results: &EMPTY_NEEDS_RESULTS,
1740        };
1741        let result = evaluate("toJSON(env)", &ctx).unwrap();
1742        let s = result.to_output_string();
1743        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1744        let obj = parsed.as_object().expect("should be a JSON object");
1745        assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
1746        assert_eq!(
1747            obj.get("GITHUB_CUSTOM").unwrap(),
1748            "user-val",
1749            "user-declared var with GITHUB_ prefix must appear in toJSON(env)"
1750        );
1751    }
1752
1753    #[test]
1754    fn tojson_env_includes_user_declared_github_token() {
1755        // The canonical real-world case: `env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }`.
1756        // The token is user-declared and must appear in toJSON(env).
1757        let mut user_env = HashMap::new();
1758        user_env.insert("GITHUB_TOKEN".to_string(), "ghp_user_supplied".to_string());
1759        let mut env_context = user_env.clone();
1760        // Runner-seeded vars coexist in env_context but must not leak into toJSON(env).
1761        env_context.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1762        let ctx = ExpressionContext {
1763            env_context: &env_context,
1764            user_env: &user_env,
1765            step_outputs: &EMPTY_STEPS,
1766            matrix_combination: &EMPTY_MATRIX,
1767            step_statuses: &EMPTY_STATUSES,
1768            job_status: "success",
1769            secrets_context: &EMPTY_SECRETS,
1770            needs_context: &EMPTY_NEEDS,
1771            needs_results: &EMPTY_NEEDS_RESULTS,
1772        };
1773        let result = evaluate("toJSON(env)", &ctx).unwrap();
1774        let s = result.to_output_string();
1775        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1776        let obj = parsed.as_object().expect("should be a JSON object");
1777        assert_eq!(obj.get("GITHUB_TOKEN").unwrap(), "ghp_user_supplied");
1778        assert!(
1779            obj.get("GITHUB_SHA").is_none(),
1780            "process-inherited GITHUB_SHA must not appear in toJSON(env)"
1781        );
1782    }
1783
1784    #[test]
1785    fn tojson_env_empty_context() {
1786        let env = HashMap::new();
1787        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1788        let result = evaluate("toJSON(env)", &ctx).unwrap();
1789        let s = result.to_output_string();
1790        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1791        let obj = parsed.as_object().expect("should be a JSON object");
1792        assert!(obj.is_empty(), "should be empty with no env vars: {}", s);
1793    }
1794
1795    #[test]
1796    fn fromjson_tojson_env_produces_parseable_json() {
1797        // Note: fromJSON currently returns an ExprValue::String containing
1798        // the raw JSON text, not an ExprValue::Object. This test verifies
1799        // that the string output is valid, parseable JSON with expected keys.
1800        let mut env = HashMap::new();
1801        env.insert("MY_VAR".to_string(), "hello".to_string());
1802        env.insert("OTHER".to_string(), "world".to_string());
1803        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1804        let result = evaluate("fromJSON(toJSON(env))", &ctx).unwrap();
1805        let s = result.to_output_string();
1806        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1807        let obj = parsed.as_object().expect("should be a JSON object");
1808        assert_eq!(obj.get("MY_VAR").unwrap(), "hello");
1809        assert_eq!(obj.get("OTHER").unwrap(), "world");
1810    }
1811
1812    #[test]
1813    fn tojson_env_special_characters_in_values() {
1814        let mut env = HashMap::new();
1815        env.insert("QUOTED".to_string(), "he said \"hi\"".to_string());
1816        env.insert("NEWLINE".to_string(), "line1\nline2".to_string());
1817        env.insert("UNICODE".to_string(), "\u{1F600}".to_string());
1818        env.insert("BACKSLASH".to_string(), "path\\to\\file".to_string());
1819        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1820        let result = evaluate("toJSON(env)", &ctx).unwrap();
1821        let s = result.to_output_string();
1822        let parsed: serde_json::Value =
1823            serde_json::from_str(&s).expect("should be valid JSON despite special chars");
1824        let obj = parsed.as_object().expect("should be a JSON object");
1825        assert_eq!(obj.get("QUOTED").unwrap(), "he said \"hi\"");
1826        assert_eq!(obj.get("NEWLINE").unwrap(), "line1\nline2");
1827        assert_eq!(obj.get("UNICODE").unwrap(), "\u{1F600}");
1828        assert_eq!(obj.get("BACKSLASH").unwrap(), "path\\to\\file");
1829    }
1830
1831    // -- toJSON(github) tests --
1832
1833    #[test]
1834    fn tojson_github_returns_object() {
1835        let mut env = HashMap::new();
1836        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1837        env.insert("GITHUB_REF".to_string(), "refs/heads/main".to_string());
1838        env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
1839        // Unrelated vars should NOT appear in github object
1840        env.insert("MY_VAR".to_string(), "hello".to_string());
1841        env.insert("RUNNER_OS".to_string(), "Linux".to_string());
1842        env.insert("INPUT_NAME".to_string(), "test".to_string());
1843        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1844        let result = evaluate("toJSON(github)", &ctx).unwrap();
1845        let s = result.to_output_string();
1846        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1847        let obj = parsed.as_object().expect("should be a JSON object");
1848        assert_eq!(obj.get("sha").unwrap(), "abc123");
1849        assert_eq!(obj.get("ref").unwrap(), "refs/heads/main");
1850        assert_eq!(obj.get("repository").unwrap(), "owner/repo");
1851        assert!(
1852            obj.get("MY_VAR").is_none(),
1853            "should exclude non-GITHUB vars"
1854        );
1855        assert!(
1856            obj.get("RUNNER_OS").is_none(),
1857            "should exclude RUNNER_ vars"
1858        );
1859        assert!(
1860            obj.get("INPUT_NAME").is_none(),
1861            "should exclude INPUT_ vars"
1862        );
1863    }
1864
1865    #[test]
1866    fn tojson_github_empty_context() {
1867        let env = HashMap::new();
1868        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1869        let result = evaluate("toJSON(github)", &ctx).unwrap();
1870        let s = result.to_output_string();
1871        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1872        let obj = parsed.as_object().expect("should be a JSON object");
1873        assert!(obj.is_empty(), "should be empty with no env vars: {}", s);
1874    }
1875
1876    #[test]
1877    fn tojson_github_no_github_prefix() {
1878        let mut env = HashMap::new();
1879        env.insert("MY_VAR".to_string(), "hello".to_string());
1880        env.insert("CI".to_string(), "true".to_string());
1881        env.insert("RUNNER_OS".to_string(), "Linux".to_string());
1882        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1883        let result = evaluate("toJSON(github)", &ctx).unwrap();
1884        let s = result.to_output_string();
1885        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1886        let obj = parsed.as_object().expect("should be a JSON object");
1887        assert!(
1888            obj.is_empty(),
1889            "should be empty when no GITHUB_* vars exist: {}",
1890            s
1891        );
1892    }
1893
1894    #[test]
1895    fn tojson_github_sorted_keys() {
1896        let mut env = HashMap::new();
1897        env.insert("GITHUB_ZEBRA".to_string(), "z".to_string());
1898        env.insert("GITHUB_APPLE".to_string(), "a".to_string());
1899        env.insert("GITHUB_MANGO".to_string(), "m".to_string());
1900        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1901        let result = evaluate("toJSON(github)", &ctx).unwrap();
1902        let s = result.to_output_string();
1903        // Keys appear stripped + lowercased, in alphabetical order
1904        let apple_pos = s.find("apple").unwrap();
1905        let mango_pos = s.find("mango").unwrap();
1906        let zebra_pos = s.find("zebra").unwrap();
1907        assert!(apple_pos < mango_pos, "apple should come before mango");
1908        assert!(mango_pos < zebra_pos, "mango should come before zebra");
1909    }
1910
1911    #[test]
1912    fn bare_github_is_truthy() {
1913        let mut env = HashMap::new();
1914        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1915        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1916        // `github` alone should be truthy (it's an object)
1917        let result = evaluate("github", &ctx).unwrap();
1918        assert!(result.is_truthy());
1919    }
1920
1921    #[test]
1922    fn tojson_github_preserves_dotted_access() {
1923        // Regression guard: adding the bare-github arm must not shadow the
1924        // existing dotted-access arm (`github.sha` → GITHUB_SHA).
1925        let mut env = HashMap::new();
1926        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1927        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1928
1929        // Dotted access still works.
1930        let dotted = evaluate("github.sha", &ctx).unwrap();
1931        assert_eq!(dotted.to_output_string(), "abc123");
1932
1933        // Bare access returns the full object.
1934        let bare = evaluate("toJSON(github)", &ctx).unwrap();
1935        let s = bare.to_output_string();
1936        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1937        assert_eq!(parsed.get("sha").unwrap(), "abc123");
1938    }
1939
1940    #[test]
1941    fn tojson_github_special_characters_in_values() {
1942        let mut env = HashMap::new();
1943        env.insert(
1944            "GITHUB_EVENT_HEAD_COMMIT_MESSAGE".to_string(),
1945            "he said \"hi\"\nnew line".to_string(),
1946        );
1947        env.insert("GITHUB_WORKSPACE".to_string(), "C:\\Users\\dev".to_string());
1948        env.insert("GITHUB_ACTOR".to_string(), "\u{1F600}".to_string());
1949        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1950        let result = evaluate("toJSON(github)", &ctx).unwrap();
1951        let s = result.to_output_string();
1952        let parsed: serde_json::Value =
1953            serde_json::from_str(&s).expect("should be valid JSON despite special chars");
1954        let obj = parsed.as_object().expect("should be a JSON object");
1955        assert_eq!(
1956            obj.get("event_head_commit_message").unwrap(),
1957            "he said \"hi\"\nnew line"
1958        );
1959        assert_eq!(obj.get("workspace").unwrap(), "C:\\Users\\dev");
1960        assert_eq!(obj.get("actor").unwrap(), "\u{1F600}");
1961    }
1962
1963    #[test]
1964    fn fromjson_tojson_github_produces_parseable_json() {
1965        let mut env = HashMap::new();
1966        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1967        env.insert("GITHUB_REF".to_string(), "refs/heads/main".to_string());
1968        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1969        let result = evaluate("fromJSON(toJSON(github))", &ctx).unwrap();
1970        let s = result.to_output_string();
1971        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1972        let obj = parsed.as_object().expect("should be a JSON object");
1973        assert_eq!(obj.get("sha").unwrap(), "abc123");
1974        assert_eq!(obj.get("ref").unwrap(), "refs/heads/main");
1975    }
1976
1977    #[test]
1978    fn tojson_github_includes_token_in_plaintext() {
1979        // Documents current behavior: GITHUB_TOKEN (when present) surfaces as
1980        // `github.token`. No masking layer exists yet — callers must not dump
1981        // this object to untrusted sinks. Pin the behavior so any future change
1982        // (exclude, redact, route through a masker) is a deliberate decision.
1983        let mut env = HashMap::new();
1984        env.insert("GITHUB_TOKEN".to_string(), "ghs_secret".to_string());
1985        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
1986        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
1987        let result = evaluate("toJSON(github)", &ctx).unwrap();
1988        let s = result.to_output_string();
1989        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
1990        let obj = parsed.as_object().expect("should be a JSON object");
1991        assert_eq!(obj.get("token").unwrap(), "ghs_secret");
1992        assert_eq!(obj.get("sha").unwrap(), "abc123");
1993    }
1994
1995    #[test]
1996    fn tojson_github_ignores_prefix_only_key() {
1997        // The bare prefix `GITHUB_` (no suffix) would strip to an empty string
1998        // and emit `{"": "..."}` — nonsense output. It should be filtered out.
1999        let mut env = HashMap::new();
2000        env.insert("GITHUB_".to_string(), "weird".to_string());
2001        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
2002        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2003        let result = evaluate("toJSON(github)", &ctx).unwrap();
2004        let s = result.to_output_string();
2005        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2006        let obj = parsed.as_object().expect("should be a JSON object");
2007        assert!(obj.get("").is_none(), "empty-key entry must not appear");
2008        assert_eq!(obj.get("sha").unwrap(), "abc123");
2009        assert_eq!(obj.len(), 1);
2010    }
2011
2012    #[test]
2013    fn tojson_github_excludes_runner_internal_keys() {
2014        // environment.rs seeds two classes of runner-internal GITHUB_* vars that
2015        // aren't part of real GHA's `github` context:
2016        //   - workflow-command-protocol tempfile paths (GITHUB_OUTPUT / GITHUB_ENV
2017        //     / GITHUB_PATH / GITHUB_STEP_SUMMARY) — dropping these also avoids
2018        //     leaking local tempfile paths.
2019        //   - CI-detection (GITHUB_ACTIONS) — documented as default runner env,
2020        //     not as a `github.*` context property.
2021        // `toJSON(github)` must drop all of them.
2022        let mut env = HashMap::new();
2023        env.insert("GITHUB_OUTPUT".to_string(), "/tmp/out".to_string());
2024        env.insert("GITHUB_ENV".to_string(), "/tmp/env".to_string());
2025        env.insert("GITHUB_PATH".to_string(), "/tmp/path".to_string());
2026        env.insert(
2027            "GITHUB_STEP_SUMMARY".to_string(),
2028            "/tmp/summary".to_string(),
2029        );
2030        env.insert("GITHUB_ACTIONS".to_string(), "true".to_string());
2031        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
2032        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2033        let result = evaluate("toJSON(github)", &ctx).unwrap();
2034        let s = result.to_output_string();
2035        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2036        let obj = parsed.as_object().expect("should be a JSON object");
2037        assert!(obj.get("output").is_none(), "should exclude GITHUB_OUTPUT");
2038        assert!(obj.get("env").is_none(), "should exclude GITHUB_ENV");
2039        assert!(obj.get("path").is_none(), "should exclude GITHUB_PATH");
2040        assert!(
2041            obj.get("step_summary").is_none(),
2042            "should exclude GITHUB_STEP_SUMMARY"
2043        );
2044        assert!(
2045            obj.get("actions").is_none(),
2046            "should exclude GITHUB_ACTIONS (not a github-context property)"
2047        );
2048        assert_eq!(obj.get("sha").unwrap(), "abc123");
2049    }
2050
2051    #[test]
2052    fn tojson_github_includes_user_defined_github_prefixed_vars() {
2053        // Documents the prefix-heuristic's inverse limitation: a user-defined
2054        // `env: { GITHUB_FOO: bar }` contaminates the github object as
2055        // `github.foo`. Pin this so any future switch to a curated allowlist
2056        // is a deliberate change, not a silent behaviour flip.
2057        let mut env = HashMap::new();
2058        env.insert("GITHUB_SHA".to_string(), "abc123".to_string());
2059        env.insert("GITHUB_FOO".to_string(), "bar".to_string());
2060        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2061        let result = evaluate("toJSON(github)", &ctx).unwrap();
2062        let s = result.to_output_string();
2063        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2064        let obj = parsed.as_object().expect("should be a JSON object");
2065        assert_eq!(obj.get("foo").unwrap(), "bar");
2066        assert_eq!(obj.get("sha").unwrap(), "abc123");
2067    }
2068
2069    // -- toJSON(steps) tests --
2070
2071    /// Helper to build an ExpressionContext with step data.
2072    fn make_steps_ctx<'a>(
2073        step_outputs: &'a HashMap<String, HashMap<String, String>>,
2074        step_statuses: &'a HashMap<String, (String, String)>,
2075    ) -> ExpressionContext<'a> {
2076        ExpressionContext {
2077            env_context: &EMPTY_ENV,
2078            user_env: &EMPTY_USER_ENV,
2079            step_outputs,
2080            matrix_combination: &EMPTY_MATRIX,
2081            step_statuses,
2082            job_status: "success",
2083            secrets_context: &EMPTY_SECRETS,
2084            needs_context: &EMPTY_NEEDS,
2085            needs_results: &EMPTY_NEEDS_RESULTS,
2086        }
2087    }
2088
2089    #[test]
2090    fn tojson_steps_returns_nested_object() {
2091        let mut outputs = HashMap::new();
2092        let mut build_out = HashMap::new();
2093        build_out.insert("artifact".to_string(), "app.zip".to_string());
2094        build_out.insert("version".to_string(), "1.2.3".to_string());
2095        outputs.insert("build".to_string(), build_out);
2096
2097        let mut test_out = HashMap::new();
2098        test_out.insert("passed".to_string(), "true".to_string());
2099        outputs.insert("test".to_string(), test_out);
2100
2101        let mut statuses = HashMap::new();
2102        statuses.insert(
2103            "build".to_string(),
2104            ("success".to_string(), "success".to_string()),
2105        );
2106        statuses.insert(
2107            "test".to_string(),
2108            ("failure".to_string(), "failure".to_string()),
2109        );
2110
2111        let ctx = make_steps_ctx(&outputs, &statuses);
2112        let result = evaluate("toJSON(steps)", &ctx).unwrap();
2113        let s = result.to_output_string();
2114        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2115        let obj = parsed.as_object().expect("should be a JSON object");
2116
2117        // Check build step
2118        let build = obj.get("build").unwrap().as_object().unwrap();
2119        assert_eq!(build.get("outcome").unwrap(), "success");
2120        assert_eq!(build.get("conclusion").unwrap(), "success");
2121        let build_outputs = build.get("outputs").unwrap().as_object().unwrap();
2122        assert_eq!(build_outputs.get("artifact").unwrap(), "app.zip");
2123        assert_eq!(build_outputs.get("version").unwrap(), "1.2.3");
2124
2125        // Check test step
2126        let test = obj.get("test").unwrap().as_object().unwrap();
2127        assert_eq!(test.get("outcome").unwrap(), "failure");
2128        assert_eq!(test.get("conclusion").unwrap(), "failure");
2129        let test_outputs = test.get("outputs").unwrap().as_object().unwrap();
2130        assert_eq!(test_outputs.get("passed").unwrap(), "true");
2131    }
2132
2133    #[test]
2134    fn tojson_steps_empty_context() {
2135        let outputs = HashMap::new();
2136        let statuses = HashMap::new();
2137        let ctx = make_steps_ctx(&outputs, &statuses);
2138        let result = evaluate("toJSON(steps)", &ctx).unwrap();
2139        let s = result.to_output_string();
2140        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2141        let obj = parsed.as_object().expect("should be a JSON object");
2142        assert!(obj.is_empty(), "should be empty with no steps: {}", s);
2143    }
2144
2145    #[test]
2146    fn tojson_steps_sorted_keys() {
2147        let mut statuses = HashMap::new();
2148        statuses.insert(
2149            "zebra".to_string(),
2150            ("success".to_string(), "success".to_string()),
2151        );
2152        statuses.insert(
2153            "alpha".to_string(),
2154            ("success".to_string(), "success".to_string()),
2155        );
2156        statuses.insert(
2157            "middle".to_string(),
2158            ("success".to_string(), "success".to_string()),
2159        );
2160        let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
2161        let result = evaluate("toJSON(steps)", &ctx).unwrap();
2162        let s = result.to_output_string();
2163        let alpha_pos = s.find("alpha").unwrap();
2164        let middle_pos = s.find("middle").unwrap();
2165        let zebra_pos = s.find("zebra").unwrap();
2166        assert!(alpha_pos < middle_pos, "alpha should come before middle");
2167        assert!(middle_pos < zebra_pos, "middle should come before zebra");
2168    }
2169
2170    #[test]
2171    fn tojson_steps_status_without_outputs() {
2172        let mut statuses = HashMap::new();
2173        statuses.insert(
2174            "checkout".to_string(),
2175            ("success".to_string(), "success".to_string()),
2176        );
2177        let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
2178        let result = evaluate("toJSON(steps)", &ctx).unwrap();
2179        let s = result.to_output_string();
2180        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2181        let checkout = parsed.get("checkout").unwrap().as_object().unwrap();
2182        assert_eq!(checkout.get("outcome").unwrap(), "success");
2183        assert_eq!(checkout.get("conclusion").unwrap(), "success");
2184        let outputs = checkout.get("outputs").unwrap().as_object().unwrap();
2185        assert!(outputs.is_empty(), "outputs should be empty: {:?}", outputs);
2186    }
2187
2188    #[test]
2189    fn tojson_steps_outputs_without_status() {
2190        // Edge case: step has outputs but no recorded status yet.
2191        let mut outputs = HashMap::new();
2192        let mut step_out = HashMap::new();
2193        step_out.insert("result".to_string(), "42".to_string());
2194        outputs.insert("compute".to_string(), step_out);
2195        let statuses = HashMap::new();
2196        let ctx = make_steps_ctx(&outputs, &statuses);
2197        let result = evaluate("toJSON(steps)", &ctx).unwrap();
2198        let s = result.to_output_string();
2199        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2200        let compute = parsed.get("compute").unwrap().as_object().unwrap();
2201        // No outcome/conclusion fields when status is absent
2202        assert!(compute.get("outcome").is_none(), "should have no outcome");
2203        assert!(
2204            compute.get("conclusion").is_none(),
2205            "should have no conclusion"
2206        );
2207        let out = compute.get("outputs").unwrap().as_object().unwrap();
2208        assert_eq!(out.get("result").unwrap(), "42");
2209    }
2210
2211    #[test]
2212    fn bare_steps_is_truthy() {
2213        let mut statuses = HashMap::new();
2214        statuses.insert(
2215            "build".to_string(),
2216            ("success".to_string(), "success".to_string()),
2217        );
2218        let ctx = make_steps_ctx(&EMPTY_STEPS, &statuses);
2219        let result = evaluate("steps", &ctx).unwrap();
2220        assert!(result.is_truthy());
2221    }
2222
2223    #[test]
2224    fn tojson_steps_special_characters_in_outputs() {
2225        let mut outputs = HashMap::new();
2226        let mut step_out = HashMap::new();
2227        step_out.insert("msg".to_string(), "he said \"hi\"".to_string());
2228        step_out.insert("path".to_string(), "C:\\Users\\dev".to_string());
2229        step_out.insert("emoji".to_string(), "\u{1F680}".to_string());
2230        outputs.insert("deploy".to_string(), step_out);
2231        let mut statuses = HashMap::new();
2232        statuses.insert(
2233            "deploy".to_string(),
2234            ("success".to_string(), "success".to_string()),
2235        );
2236        let ctx = make_steps_ctx(&outputs, &statuses);
2237        let result = evaluate("toJSON(steps)", &ctx).unwrap();
2238        let s = result.to_output_string();
2239        let parsed: serde_json::Value =
2240            serde_json::from_str(&s).expect("should be valid JSON despite special chars");
2241        let deploy_outputs = parsed
2242            .get("deploy")
2243            .unwrap()
2244            .get("outputs")
2245            .unwrap()
2246            .as_object()
2247            .unwrap();
2248        assert_eq!(deploy_outputs.get("msg").unwrap(), "he said \"hi\"");
2249        assert_eq!(deploy_outputs.get("path").unwrap(), "C:\\Users\\dev");
2250        assert_eq!(deploy_outputs.get("emoji").unwrap(), "\u{1F680}");
2251    }
2252
2253    // -- toJSON(needs) tests --
2254
2255    /// Helper to build an ExpressionContext with needs data.
2256    fn make_needs_ctx<'a>(
2257        needs_context: &'a HashMap<String, HashMap<String, String>>,
2258        needs_results: &'a HashMap<String, String>,
2259    ) -> ExpressionContext<'a> {
2260        ExpressionContext {
2261            env_context: &EMPTY_ENV,
2262            user_env: &EMPTY_USER_ENV,
2263            step_outputs: &EMPTY_STEPS,
2264            matrix_combination: &EMPTY_MATRIX,
2265            step_statuses: &EMPTY_STATUSES,
2266            job_status: "success",
2267            secrets_context: &EMPTY_SECRETS,
2268            needs_context,
2269            needs_results,
2270        }
2271    }
2272
2273    #[test]
2274    fn tojson_needs_returns_nested_object() {
2275        let mut needs_ctx = HashMap::new();
2276        let mut build_out = HashMap::new();
2277        build_out.insert("artifact".to_string(), "app.zip".to_string());
2278        build_out.insert("version".to_string(), "1.2.3".to_string());
2279        needs_ctx.insert("build".to_string(), build_out);
2280
2281        let mut test_out = HashMap::new();
2282        test_out.insert("passed".to_string(), "true".to_string());
2283        needs_ctx.insert("test".to_string(), test_out);
2284
2285        let mut needs_res = HashMap::new();
2286        needs_res.insert("build".to_string(), "success".to_string());
2287        needs_res.insert("test".to_string(), "failure".to_string());
2288
2289        let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2290        let result = evaluate("toJSON(needs)", &ctx).unwrap();
2291        let s = result.to_output_string();
2292        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2293        let obj = parsed.as_object().expect("should be a JSON object");
2294
2295        // Check build job
2296        let build = obj.get("build").unwrap().as_object().unwrap();
2297        assert_eq!(build.get("result").unwrap(), "success");
2298        let build_outputs = build.get("outputs").unwrap().as_object().unwrap();
2299        assert_eq!(build_outputs.get("artifact").unwrap(), "app.zip");
2300        assert_eq!(build_outputs.get("version").unwrap(), "1.2.3");
2301
2302        // Check test job
2303        let test = obj.get("test").unwrap().as_object().unwrap();
2304        assert_eq!(test.get("result").unwrap(), "failure");
2305        let test_outputs = test.get("outputs").unwrap().as_object().unwrap();
2306        assert_eq!(test_outputs.get("passed").unwrap(), "true");
2307    }
2308
2309    #[test]
2310    fn tojson_needs_empty_context() {
2311        let needs_ctx = HashMap::new();
2312        let needs_res = HashMap::new();
2313        let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2314        let result = evaluate("toJSON(needs)", &ctx).unwrap();
2315        let s = result.to_output_string();
2316        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2317        let obj = parsed.as_object().expect("should be a JSON object");
2318        assert!(obj.is_empty(), "should be empty with no needs: {}", s);
2319    }
2320
2321    #[test]
2322    fn tojson_needs_sorted_keys() {
2323        let mut needs_res = HashMap::new();
2324        needs_res.insert("zebra".to_string(), "success".to_string());
2325        needs_res.insert("alpha".to_string(), "success".to_string());
2326        needs_res.insert("middle".to_string(), "success".to_string());
2327        let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
2328        let result = evaluate("toJSON(needs)", &ctx).unwrap();
2329        let s = result.to_output_string();
2330        let alpha_pos = s.find("alpha").unwrap();
2331        let middle_pos = s.find("middle").unwrap();
2332        let zebra_pos = s.find("zebra").unwrap();
2333        assert!(alpha_pos < middle_pos, "alpha should come before middle");
2334        assert!(middle_pos < zebra_pos, "middle should come before zebra");
2335    }
2336
2337    #[test]
2338    fn tojson_needs_result_without_outputs() {
2339        let mut needs_res = HashMap::new();
2340        needs_res.insert("lint".to_string(), "success".to_string());
2341        let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
2342        let result = evaluate("toJSON(needs)", &ctx).unwrap();
2343        let s = result.to_output_string();
2344        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2345        let lint = parsed.get("lint").unwrap().as_object().unwrap();
2346        assert_eq!(lint.get("result").unwrap(), "success");
2347        let outputs = lint.get("outputs").unwrap().as_object().unwrap();
2348        assert!(outputs.is_empty(), "outputs should be empty: {:?}", outputs);
2349    }
2350
2351    #[test]
2352    fn tojson_needs_outputs_without_result() {
2353        // Edge case: needs entry has outputs but no recorded result yet.
2354        let mut needs_ctx = HashMap::new();
2355        let mut job_out = HashMap::new();
2356        job_out.insert("value".to_string(), "42".to_string());
2357        needs_ctx.insert("compute".to_string(), job_out);
2358        let needs_res = HashMap::new();
2359        let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2360        let result = evaluate("toJSON(needs)", &ctx).unwrap();
2361        let s = result.to_output_string();
2362        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2363        let compute = parsed.get("compute").unwrap().as_object().unwrap();
2364        assert!(compute.get("result").is_none(), "should have no result");
2365        let out = compute.get("outputs").unwrap().as_object().unwrap();
2366        assert_eq!(out.get("value").unwrap(), "42");
2367    }
2368
2369    #[test]
2370    fn bare_needs_is_truthy() {
2371        let mut needs_res = HashMap::new();
2372        needs_res.insert("build".to_string(), "success".to_string());
2373        let ctx = make_needs_ctx(&EMPTY_NEEDS, &needs_res);
2374        let result = evaluate("needs", &ctx).unwrap();
2375        assert!(result.is_truthy());
2376    }
2377
2378    #[test]
2379    fn tojson_needs_special_characters_in_outputs() {
2380        let mut needs_ctx = HashMap::new();
2381        let mut job_out = HashMap::new();
2382        job_out.insert("msg".to_string(), "he said \"hi\"".to_string());
2383        job_out.insert("path".to_string(), "C:\\Users\\dev".to_string());
2384        job_out.insert("emoji".to_string(), "\u{1F680}".to_string());
2385        needs_ctx.insert("deploy".to_string(), job_out);
2386        let mut needs_res = HashMap::new();
2387        needs_res.insert("deploy".to_string(), "success".to_string());
2388        let ctx = make_needs_ctx(&needs_ctx, &needs_res);
2389        let result = evaluate("toJSON(needs)", &ctx).unwrap();
2390        let s = result.to_output_string();
2391        let parsed: serde_json::Value =
2392            serde_json::from_str(&s).expect("should be valid JSON despite special chars");
2393        let deploy_outputs = parsed
2394            .get("deploy")
2395            .unwrap()
2396            .get("outputs")
2397            .unwrap()
2398            .as_object()
2399            .unwrap();
2400        assert_eq!(deploy_outputs.get("msg").unwrap(), "he said \"hi\"");
2401        assert_eq!(deploy_outputs.get("path").unwrap(), "C:\\Users\\dev");
2402        assert_eq!(deploy_outputs.get("emoji").unwrap(), "\u{1F680}");
2403    }
2404
2405    // -- toJSON(secrets) tests --
2406
2407    /// Helper to build an ExpressionContext with secrets data.
2408    fn make_secrets_ctx(secrets: &HashMap<String, String>) -> ExpressionContext<'_> {
2409        ExpressionContext {
2410            env_context: &EMPTY_ENV,
2411            user_env: &EMPTY_USER_ENV,
2412            step_outputs: &EMPTY_STEPS,
2413            matrix_combination: &EMPTY_MATRIX,
2414            step_statuses: &EMPTY_STATUSES,
2415            job_status: "success",
2416            secrets_context: secrets,
2417            needs_context: &EMPTY_NEEDS,
2418            needs_results: &EMPTY_NEEDS_RESULTS,
2419        }
2420    }
2421
2422    #[test]
2423    fn tojson_secrets_returns_object() {
2424        let mut secrets = HashMap::new();
2425        secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
2426        secrets.insert("DEPLOY_KEY".to_string(), "xyz".to_string());
2427        let ctx = make_secrets_ctx(&secrets);
2428        let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2429        let s = result.to_output_string();
2430        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2431        let obj = parsed.as_object().expect("should be a JSON object");
2432        assert_eq!(obj.get("NPM_TOKEN").unwrap(), "abc");
2433        assert_eq!(obj.get("DEPLOY_KEY").unwrap(), "xyz");
2434        assert_eq!(obj.len(), 2);
2435    }
2436
2437    #[test]
2438    fn tojson_secrets_empty_context() {
2439        let secrets = HashMap::new();
2440        let ctx = make_secrets_ctx(&secrets);
2441        let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2442        let s = result.to_output_string();
2443        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2444        let obj = parsed.as_object().expect("should be a JSON object");
2445        assert!(obj.is_empty(), "should be empty with no secrets: {}", s);
2446    }
2447
2448    #[test]
2449    fn tojson_secrets_sorted_keys() {
2450        let mut secrets = HashMap::new();
2451        secrets.insert("ZEBRA".to_string(), "z".to_string());
2452        secrets.insert("APPLE".to_string(), "a".to_string());
2453        secrets.insert("MANGO".to_string(), "m".to_string());
2454        let ctx = make_secrets_ctx(&secrets);
2455        let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2456        let s = result.to_output_string();
2457        let apple_pos = s.find("APPLE").unwrap();
2458        let mango_pos = s.find("MANGO").unwrap();
2459        let zebra_pos = s.find("ZEBRA").unwrap();
2460        assert!(apple_pos < mango_pos, "APPLE should come before MANGO");
2461        assert!(mango_pos < zebra_pos, "MANGO should come before ZEBRA");
2462    }
2463
2464    #[test]
2465    fn tojson_secrets_preserves_special_characters() {
2466        let mut secrets = HashMap::new();
2467        secrets.insert("QUOTE".to_string(), "he said \"hi\"".to_string());
2468        secrets.insert("BACKSLASH".to_string(), "path\\to\\key".to_string());
2469        secrets.insert(
2470            "NEWLINE".to_string(),
2471            "-----BEGIN-----\nBODY\n-----END-----".to_string(),
2472        );
2473        let ctx = make_secrets_ctx(&secrets);
2474        let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2475        let s = result.to_output_string();
2476        let parsed: serde_json::Value =
2477            serde_json::from_str(&s).expect("should be valid JSON despite special chars");
2478        let obj = parsed.as_object().unwrap();
2479        assert_eq!(obj.get("QUOTE").unwrap(), "he said \"hi\"");
2480        assert_eq!(obj.get("BACKSLASH").unwrap(), "path\\to\\key");
2481        assert_eq!(
2482            obj.get("NEWLINE").unwrap(),
2483            "-----BEGIN-----\nBODY\n-----END-----"
2484        );
2485    }
2486
2487    #[test]
2488    fn fromjson_tojson_secrets_produces_parseable_json() {
2489        // `fromJSON` currently returns the raw JSON text as `ExprValue::String`
2490        // (same pattern as `fromjson_tojson_env_produces_parseable_json`). The
2491        // round-trip must preserve exact values so pipe-through-an-action use
2492        // cases work and so any future switch to value-masking here is a
2493        // deliberate decision.
2494        let mut secrets = HashMap::new();
2495        secrets.insert("NPM_TOKEN".to_string(), "npm_ABC123".to_string());
2496        secrets.insert("DEPLOY_KEY".to_string(), "deploy_XYZ".to_string());
2497        let ctx = make_secrets_ctx(&secrets);
2498        let result = evaluate("fromJSON(toJSON(secrets))", &ctx).unwrap();
2499        let s = result.to_output_string();
2500        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2501        let obj = parsed.as_object().expect("should be a JSON object");
2502        assert_eq!(obj.get("NPM_TOKEN").unwrap(), "npm_ABC123");
2503        assert_eq!(obj.get("DEPLOY_KEY").unwrap(), "deploy_XYZ");
2504    }
2505
2506    #[test]
2507    fn bare_secrets_is_truthy() {
2508        let mut secrets = HashMap::new();
2509        secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
2510        let ctx = make_secrets_ctx(&secrets);
2511        let result = evaluate("secrets", &ctx).unwrap();
2512        assert!(result.is_truthy());
2513    }
2514
2515    #[test]
2516    fn bare_secrets_does_not_shadow_dotted_access() {
2517        // Regression guard: the bare-`secrets` arm must not shadow the existing
2518        // `secrets.NAME` dotted-access arm.
2519        let mut secrets = HashMap::new();
2520        secrets.insert("NPM_TOKEN".to_string(), "abc".to_string());
2521        let ctx = make_secrets_ctx(&secrets);
2522        let result = evaluate("secrets.NPM_TOKEN", &ctx).unwrap();
2523        assert_eq!(result, ExprValue::String("abc".to_string()));
2524    }
2525
2526    #[test]
2527    fn tojson_secrets_returns_values_in_plaintext() {
2528        // Documents current behavior: secret values surface in plaintext inside
2529        // `toJSON(secrets)`. This matches real GHA; masking lives at the log
2530        // boundary via `wrkflw_secrets::SecretMasker`, not in the evaluator.
2531        // Pin the behavior so any future change (exclude, redact, route through
2532        // a masker at this layer) is a deliberate decision.
2533        let mut secrets = HashMap::new();
2534        secrets.insert("GITHUB_TOKEN".to_string(), "ghs_supersecret".to_string());
2535        let ctx = make_secrets_ctx(&secrets);
2536        let result = evaluate("toJSON(secrets)", &ctx).unwrap();
2537        let s = result.to_output_string();
2538        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2539        let obj = parsed.as_object().unwrap();
2540        assert_eq!(obj.get("GITHUB_TOKEN").unwrap(), "ghs_supersecret");
2541    }
2542
2543    // -- toJSON(matrix) tests --
2544
2545    #[test]
2546    fn tojson_matrix_returns_object() {
2547        let mut matrix = HashMap::new();
2548        matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
2549        matrix.insert("node".to_string(), Value::String("20".to_string()));
2550        let matrix = Some(matrix);
2551        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2552        let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2553        let s = result.to_output_string();
2554        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2555        let obj = parsed.as_object().expect("should be a JSON object");
2556        assert_eq!(obj.get("os").unwrap(), "ubuntu-latest");
2557        assert_eq!(obj.get("node").unwrap(), "20");
2558        assert_eq!(obj.len(), 2);
2559    }
2560
2561    #[test]
2562    fn tojson_matrix_no_matrix_returns_null() {
2563        // Non-matrix job: `matrix_combination = None`. Pins the Null-on-None
2564        // behaviour that's asymmetric with the other bare-context arms.
2565        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &EMPTY_MATRIX);
2566        let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2567        assert_eq!(result, ExprValue::String("null".to_string()));
2568    }
2569
2570    #[test]
2571    fn tojson_matrix_empty_combination() {
2572        // `Some(empty)` encodes "a matrix combination exists with zero keys".
2573        // Distinct from `None` (non-matrix job) and must render as `{}`.
2574        let matrix: Option<HashMap<String, Value>> = Some(HashMap::new());
2575        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2576        let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2577        let s = result.to_output_string();
2578        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2579        let obj = parsed.as_object().expect("should be a JSON object");
2580        assert!(obj.is_empty(), "should be empty with zero keys: {}", s);
2581    }
2582
2583    #[test]
2584    fn tojson_matrix_mixed_value_types() {
2585        // `yaml_value_to_expr` preserves native types for String/Number/Bool,
2586        // and `expr_to_json` serialises them as native JSON types — not
2587        // stringified. This must match the dotted-access arm's per-value shape.
2588        let mut matrix = HashMap::new();
2589        matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
2590        matrix.insert("node".to_string(), Value::Number(20.into()));
2591        matrix.insert("experimental".to_string(), Value::Bool(true));
2592        let matrix = Some(matrix);
2593        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2594        let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2595        let s = result.to_output_string();
2596        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2597        let obj = parsed.as_object().unwrap();
2598        assert_eq!(obj.get("os").unwrap(), "ubuntu");
2599        assert_eq!(obj.get("node").unwrap().as_f64().unwrap(), 20.0);
2600        assert!(obj.get("experimental").unwrap().as_bool().unwrap());
2601    }
2602
2603    #[test]
2604    fn tojson_matrix_sorted_keys() {
2605        let mut matrix = HashMap::new();
2606        matrix.insert("zebra".to_string(), Value::String("z".to_string()));
2607        matrix.insert("apple".to_string(), Value::String("a".to_string()));
2608        matrix.insert("mango".to_string(), Value::String("m".to_string()));
2609        let matrix = Some(matrix);
2610        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2611        let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2612        let s = result.to_output_string();
2613        let apple_pos = s.find("apple").unwrap();
2614        let mango_pos = s.find("mango").unwrap();
2615        let zebra_pos = s.find("zebra").unwrap();
2616        assert!(apple_pos < mango_pos, "apple should come before mango");
2617        assert!(mango_pos < zebra_pos, "mango should come before zebra");
2618    }
2619
2620    #[test]
2621    fn fromjson_tojson_matrix_produces_parseable_json() {
2622        let mut matrix = HashMap::new();
2623        matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
2624        matrix.insert("node".to_string(), Value::String("20".to_string()));
2625        let matrix = Some(matrix);
2626        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2627        let result = evaluate("fromJSON(toJSON(matrix))", &ctx).unwrap();
2628        let s = result.to_output_string();
2629        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2630        let obj = parsed.as_object().expect("should be a JSON object");
2631        assert_eq!(obj.get("os").unwrap(), "ubuntu-latest");
2632        assert_eq!(obj.get("node").unwrap(), "20");
2633    }
2634
2635    #[test]
2636    fn bare_matrix_is_truthy() {
2637        let mut matrix = HashMap::new();
2638        matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
2639        let matrix = Some(matrix);
2640        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2641        let result = evaluate("matrix", &ctx).unwrap();
2642        assert!(result.is_truthy());
2643    }
2644
2645    #[test]
2646    fn bare_matrix_when_none_is_null() {
2647        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &EMPTY_MATRIX);
2648        let result = evaluate("matrix", &ctx).unwrap();
2649        assert_eq!(result, ExprValue::Null);
2650        assert!(!result.is_truthy());
2651    }
2652
2653    #[test]
2654    fn bare_matrix_does_not_shadow_dotted_access() {
2655        // Regression guard: the bare-`matrix` arm must not shadow the existing
2656        // `matrix.<key>` dotted-access arm.
2657        let mut matrix = HashMap::new();
2658        matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
2659        let matrix = Some(matrix);
2660        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2661        let result = evaluate("matrix.os", &ctx).unwrap();
2662        assert_eq!(result, ExprValue::String("ubuntu-latest".to_string()));
2663    }
2664
2665    #[test]
2666    fn tojson_matrix_yaml_sequence_falls_through_to_string() {
2667        // Matrix values that aren't scalar (sequences, mappings) currently
2668        // render via `yaml_value_to_expr`'s fallback branch as a YAML-string.
2669        // Pins the current behaviour; a future switch to a nested structure
2670        // would be a deliberate decision.
2671        let mut matrix = HashMap::new();
2672        matrix.insert(
2673            "versions".to_string(),
2674            Value::Sequence(vec![
2675                Value::String("a".to_string()),
2676                Value::String("b".to_string()),
2677            ]),
2678        );
2679        let matrix = Some(matrix);
2680        let ctx = make_ctx(&EMPTY_ENV, &EMPTY_STEPS, &matrix);
2681        let result = evaluate("toJSON(matrix)", &ctx).unwrap();
2682        let s = result.to_output_string();
2683        let parsed: serde_json::Value = serde_json::from_str(&s).expect("should be valid JSON");
2684        let obj = parsed.as_object().expect("should be a JSON object");
2685        let versions = obj.get("versions").expect("versions key present");
2686        let rendered = versions.as_str().expect("rendered as JSON string");
2687        assert!(
2688            rendered.contains("- a") && rendered.contains("- b"),
2689            "expected YAML sequence rendering, got: {}",
2690            rendered
2691        );
2692    }
2693
2694    #[test]
2695    fn object_cmp_returns_none() {
2696        // Object comparisons via <, >, <=, >= should all evaluate to false
2697        // because expr_cmp returns None for Object values.
2698        let mut env = HashMap::new();
2699        env.insert("FOO".to_string(), "bar".to_string());
2700        let ctx = make_ctx(&env, &EMPTY_STEPS, &EMPTY_MATRIX);
2701        // These should all evaluate to false (Object is not orderable)
2702        let result = evaluate("env < env", &ctx).unwrap();
2703        assert!(!result.is_truthy(), "env < env should be false");
2704        let result = evaluate("env > env", &ctx).unwrap();
2705        assert!(!result.is_truthy(), "env > env should be false");
2706        let result = evaluate("env <= env", &ctx).unwrap();
2707        assert!(!result.is_truthy(), "env <= env should be false");
2708        let result = evaluate("env >= env", &ctx).unwrap();
2709        assert!(!result.is_truthy(), "env >= env should be false");
2710    }
2711}