Skip to main content

wrkflw_executor/
substitution.rs

1use lazy_static::lazy_static;
2use regex::Regex;
3use serde_yaml::Value;
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use std::path::Path;
7
8lazy_static! {
9    static ref MATRIX_PATTERN: Regex =
10        Regex::new(r"\$\{\{\s*matrix\.([a-zA-Z0-9_]+)\s*\}\}").unwrap();
11    static ref HASH_FILES_PATTERN: Regex =
12        Regex::new(r"\$\{\{\s*hashFiles\(([^)]+)\)\s*\}\}").unwrap();
13    /// Matches any `${{ ... }}` expression. Handles single `}` inside format
14    /// placeholders like `{0}` by requiring the closing `}}` pair.
15    static ref EXPRESSION_PATTERN: Regex =
16        Regex::new(r"\$\{\{(?:[^}]|\}[^}])*\}\}").unwrap();
17}
18
19/// Preprocesses a command string to replace GitHub-style matrix variable references
20/// with their values from the environment
21pub fn preprocess_command(command: &str, matrix_values: &HashMap<String, Value>) -> String {
22    // Replace matrix references like ${{ matrix.os }} with their values
23    let result = MATRIX_PATTERN.replace_all(command, |caps: &regex::Captures| {
24        let var_name = &caps[1];
25
26        // Get the value from matrix context
27        if let Some(value) = matrix_values.get(var_name) {
28            // Convert value to string
29            match value {
30                Value::String(s) => s.clone(),
31                Value::Number(n) => n.to_string(),
32                Value::Bool(b) => b.to_string(),
33                _ => format!("\\${{{{ matrix.{} }}}}", var_name), // Escape $ for shell
34            }
35        } else {
36            // Keep original if not found but escape $ to prevent shell errors
37            format!("\\${{{{ matrix.{} }}}}", var_name)
38        }
39    });
40
41    result.into_owned()
42}
43
44/// Apply variable substitution to step run commands
45pub fn process_step_run(run: &str, matrix_combination: &Option<HashMap<String, Value>>) -> String {
46    if let Some(matrix) = matrix_combination {
47        preprocess_command(run, matrix)
48    } else {
49        // Escape $ in GitHub expression syntax to prevent shell interpretation
50        MATRIX_PATTERN
51            .replace_all(run, |caps: &regex::Captures| {
52                let var_name = &caps[1];
53                format!("\\${{{{ matrix.{} }}}}", var_name)
54            })
55            .to_string()
56    }
57}
58
59/// Replace `${{ hashFiles(...) }}` expressions with the SHA-256 hash of matched files.
60///
61/// Accepts one or more comma-separated, quoted glob patterns. Files are matched
62/// relative to `workspace`, sorted lexicographically, and hashed in order to
63/// produce a deterministic digest — matching GitHub Actions behavior.
64///
65/// Returns `Err` if any matched file cannot be read.
66pub fn preprocess_hash_files(text: &str, workspace: &Path) -> Result<String, String> {
67    let mut error: Option<String> = None;
68    let result = HASH_FILES_PATTERN
69        .replace_all(text, |caps: &regex::Captures| {
70            if error.is_some() {
71                return String::new();
72            }
73            let args_raw = &caps[1];
74            match compute_hash_files(args_raw, workspace) {
75                Ok(hash) => hash,
76                Err(e) => {
77                    error = Some(e);
78                    String::new()
79                }
80            }
81        })
82        .into_owned();
83    match error {
84        Some(e) => Err(e),
85        None => Ok(result),
86    }
87}
88
89/// Compute a SHA-256 hash of the contents of all files matching the given glob patterns.
90///
91/// `args_raw` is the raw argument string inside `hashFiles(...)`, e.g.
92/// `'**/package-lock.json', '**/yarn.lock'`.
93///
94/// Returns `Ok(hash)` on success or `Err(message)` if any matched file cannot be read.
95fn compute_hash_files(args_raw: &str, workspace: &Path) -> Result<String, String> {
96    // Parse comma-separated, quoted patterns
97    let patterns: Vec<&str> = args_raw
98        .split(',')
99        .map(|s| s.trim().trim_matches('\'').trim_matches('"'))
100        .filter(|s| !s.is_empty())
101        .collect();
102
103    if patterns.is_empty() {
104        return Ok(String::new());
105    }
106
107    // Reject patterns containing path traversal components
108    for pattern in &patterns {
109        if pattern.split('/').any(|seg| seg == "..") {
110            return Err(format!(
111                "hashFiles: pattern '{}' contains '..' path traversal",
112                pattern
113            ));
114        }
115    }
116
117    // Collect all matching files, validating they stay within the workspace
118    let canonical_workspace = workspace
119        .canonicalize()
120        .map_err(|e| format!("hashFiles: cannot canonicalize workspace: {}", e))?;
121    let mut matched_files = Vec::new();
122    for pattern in &patterns {
123        let full_pattern = workspace.join(pattern).to_string_lossy().to_string();
124        if let Ok(entries) = glob::glob(&full_pattern) {
125            for entry in entries.flatten() {
126                if entry.is_file() && !entry.is_symlink() {
127                    // Verify the resolved file stays within the workspace
128                    // (prevents symlink traversal outside the repo).
129                    if let Ok(canonical) = entry.canonicalize() {
130                        if canonical.starts_with(&canonical_workspace) {
131                            matched_files.push(entry);
132                        }
133                    }
134                }
135            }
136        }
137    }
138
139    if matched_files.is_empty() {
140        // GHA returns the SHA-256 of empty input when no files match
141        return Ok(format!("{:x}", Sha256::new().finalize()));
142    }
143
144    // Sort for deterministic output (GHA sorts lexicographically)
145    matched_files.sort();
146    matched_files.dedup();
147
148    // Hash all file contents (stream to avoid loading large files into memory)
149    let mut hasher = Sha256::new();
150    for path in &matched_files {
151        let mut file = std::fs::File::open(path)
152            .map_err(|e| format!("hashFiles: could not read '{}': {}", path.display(), e))?;
153        std::io::copy(&mut file, &mut hasher)
154            .map_err(|e| format!("hashFiles: could not read '{}': {}", path.display(), e))?;
155    }
156
157    Ok(format!("{:x}", hasher.finalize()))
158}
159
160/// Apply all expression substitutions to a text string.
161///
162/// `hashFiles()` is resolved first (needs filesystem access), then all remaining
163/// `${{ ... }}` expressions are evaluated through the expression evaluator which
164/// handles env, inputs, github, runner, matrix, and steps contexts as well as
165/// operators and built-in functions.
166///
167/// Expressions that fail evaluation are replaced with empty string as a safety
168/// fallback, matching GitHub Actions behavior.
169///
170/// Returns `Err` if a `hashFiles()` expression fails (e.g. unreadable file).
171pub fn preprocess_expressions(
172    text: &str,
173    workspace: &Path,
174    ctx: &crate::expression::ExpressionContext<'_>,
175) -> Result<String, String> {
176    use crate::expression::evaluate;
177
178    // Resolve hashFiles first (needs filesystem access not available in the
179    // expression evaluator)
180    let result = preprocess_hash_files(text, workspace)?;
181
182    // Evaluate all remaining ${{ ... }} expressions through the expression evaluator
183    let result = EXPRESSION_PATTERN
184        .replace_all(&result, |caps: &regex::Captures| {
185            let full_match = &caps[0];
186            // Extract the inner expression (strip "${{" and "}}")
187            let inner = &full_match[3..full_match.len() - 2];
188            match evaluate(inner, ctx) {
189                Ok(val) => val.to_output_string(),
190                Err(e) => {
191                    wrkflw_logging::debug(&format!(
192                        "Expression evaluation failed for '{}': {} — substituting empty string",
193                        inner.trim(),
194                        e
195                    ));
196                    String::new()
197                }
198            }
199        })
200        .into_owned();
201
202    Ok(result)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::expression::ExpressionContext;
209    use std::fs;
210    use tempfile::tempdir;
211
212    /// Build an `ExpressionContext` from the fields that vary across tests;
213    /// all other fields default to empty/success.
214    fn make_ctx<'a>(
215        matrix: &'a Option<HashMap<String, Value>>,
216        step_outputs: &'a HashMap<String, HashMap<String, String>>,
217        env: &'a HashMap<String, String>,
218    ) -> ExpressionContext<'a> {
219        ExpressionContext {
220            env_context: env,
221            user_env: env,
222            step_outputs,
223            matrix_combination: matrix,
224            step_statuses: &EMPTY_STATUSES,
225            job_status: "success",
226            secrets_context: &EMPTY_SECRETS,
227            needs_context: &EMPTY_NEEDS,
228            needs_results: &EMPTY_NEEDS_RESULTS,
229        }
230    }
231
232    lazy_static::lazy_static! {
233        static ref EMPTY_STATUSES: HashMap<String, (String, String)> = HashMap::new();
234        static ref EMPTY_SECRETS: HashMap<String, String> = HashMap::new();
235        static ref EMPTY_NEEDS: HashMap<String, HashMap<String, String>> = HashMap::new();
236        static ref EMPTY_NEEDS_RESULTS: HashMap<String, String> = HashMap::new();
237    }
238
239    #[test]
240    fn test_preprocess_simple_matrix_vars() {
241        let mut matrix = HashMap::new();
242        matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
243        matrix.insert(
244            "node".to_string(),
245            Value::Number(serde_yaml::Number::from(14)),
246        );
247
248        let cmd = "echo \"Running on ${{ matrix.os }} with Node ${{ matrix.node }}\"";
249        let processed = preprocess_command(cmd, &matrix);
250
251        assert_eq!(processed, "echo \"Running on ubuntu-latest with Node 14\"");
252    }
253
254    #[test]
255    fn test_preprocess_with_missing_vars() {
256        let mut matrix = HashMap::new();
257        matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
258
259        let cmd = "echo \"Running on ${{ matrix.os }} with Node ${{ matrix.node }}\"";
260        let processed = preprocess_command(cmd, &matrix);
261
262        // Missing vars should be escaped
263        assert_eq!(
264            processed,
265            "echo \"Running on ubuntu-latest with Node \\${{ matrix.node }}\""
266        );
267    }
268
269    #[test]
270    fn test_preprocess_preserves_other_text() {
271        let mut matrix = HashMap::new();
272        matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
273
274        let cmd = "echo \"Starting job\" && echo \"OS: ${{ matrix.os }}\" && echo \"Done!\"";
275        let processed = preprocess_command(cmd, &matrix);
276
277        assert_eq!(
278            processed,
279            "echo \"Starting job\" && echo \"OS: ubuntu-latest\" && echo \"Done!\""
280        );
281    }
282
283    #[test]
284    fn test_process_without_matrix() {
285        let cmd = "echo \"Value: ${{ matrix.value }}\"";
286        let processed = process_step_run(cmd, &None);
287
288        assert_eq!(processed, "echo \"Value: \\${{ matrix.value }}\"");
289    }
290
291    #[test]
292    fn hash_files_single_pattern() {
293        let dir = tempdir().unwrap();
294        fs::write(dir.path().join("package-lock.json"), "lock-content").unwrap();
295        fs::write(dir.path().join("other.txt"), "other").unwrap();
296
297        let text = "${{ hashFiles('package-lock.json') }}";
298        let result = preprocess_hash_files(text, dir.path()).unwrap();
299
300        assert!(!result.is_empty());
301        assert!(!result.contains("hashFiles"));
302        // Hash should be 64 hex chars (SHA-256)
303        assert_eq!(result.len(), 64);
304    }
305
306    #[test]
307    fn hash_files_multiple_patterns() {
308        let dir = tempdir().unwrap();
309        fs::write(dir.path().join("a.lock"), "aaa").unwrap();
310        fs::write(dir.path().join("b.json"), "bbb").unwrap();
311
312        let text = "${{ hashFiles('*.lock', '*.json') }}";
313        let result = preprocess_hash_files(text, dir.path()).unwrap();
314
315        assert_eq!(result.len(), 64);
316    }
317
318    #[test]
319    fn hash_files_no_matches_returns_hash_of_empty() {
320        let dir = tempdir().unwrap();
321
322        let text = "${{ hashFiles('nonexistent-*.xyz') }}";
323        let result = preprocess_hash_files(text, dir.path()).unwrap();
324
325        // GHA returns SHA-256 of empty input when no files match
326        let expected = format!("{:x}", Sha256::new().finalize());
327        assert_eq!(result, expected);
328        assert_eq!(result.len(), 64);
329    }
330
331    #[test]
332    fn hash_files_deterministic() {
333        let dir = tempdir().unwrap();
334        fs::write(dir.path().join("a.txt"), "hello").unwrap();
335        fs::write(dir.path().join("b.txt"), "world").unwrap();
336
337        let text = "${{ hashFiles('*.txt') }}";
338        let r1 = preprocess_hash_files(text, dir.path()).unwrap();
339        let r2 = preprocess_hash_files(text, dir.path()).unwrap();
340
341        assert_eq!(r1, r2);
342    }
343
344    #[test]
345    fn hash_files_glob_recursive() {
346        let dir = tempdir().unwrap();
347        let sub = dir.path().join("sub");
348        fs::create_dir(&sub).unwrap();
349        fs::write(sub.join("deep.lock"), "deep-content").unwrap();
350
351        let text = "${{ hashFiles('**/deep.lock') }}";
352        let result = preprocess_hash_files(text, dir.path()).unwrap();
353
354        assert_eq!(result.len(), 64);
355    }
356
357    #[test]
358    fn hash_files_inline_with_other_text() {
359        let dir = tempdir().unwrap();
360        fs::write(dir.path().join("Cargo.lock"), "lockfile").unwrap();
361
362        let text = "cache-key-${{ hashFiles('Cargo.lock') }}-suffix";
363        let result = preprocess_hash_files(text, dir.path()).unwrap();
364
365        assert!(result.starts_with("cache-key-"));
366        assert!(result.ends_with("-suffix"));
367        assert!(!result.contains("hashFiles"));
368    }
369
370    #[test]
371    fn preprocess_expressions_combines_hash_and_matrix() {
372        let dir = tempdir().unwrap();
373        fs::write(dir.path().join("Cargo.lock"), "lockfile").unwrap();
374
375        let mut matrix = HashMap::new();
376        matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
377
378        let text = "${{ matrix.os }}-${{ hashFiles('Cargo.lock') }}";
379        let matrix = Some(matrix);
380        let empty_steps = HashMap::new();
381        let empty_env = HashMap::new();
382        let ctx = make_ctx(&matrix, &empty_steps, &empty_env);
383        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
384
385        assert!(result.starts_with("ubuntu-"));
386        assert!(!result.contains("hashFiles"));
387        assert!(!result.contains("matrix"));
388    }
389
390    #[test]
391    fn hash_files_rejects_path_traversal() {
392        let dir = tempdir().unwrap();
393        fs::write(dir.path().join("legit.txt"), "content").unwrap();
394
395        let text = "${{ hashFiles('../../etc/passwd') }}";
396        let result = preprocess_hash_files(text, dir.path());
397        assert!(result.is_err());
398        assert!(result.unwrap_err().contains("path traversal"));
399    }
400
401    #[test]
402    fn hash_files_rejects_mid_path_traversal() {
403        let dir = tempdir().unwrap();
404
405        let result = compute_hash_files("'subdir/../../etc/passwd'", dir.path());
406        assert!(result.is_err());
407        assert!(result.unwrap_err().contains("path traversal"));
408    }
409
410    // -- step outputs via expression evaluator --
411
412    #[test]
413    fn step_output_substitution() {
414        let dir = tempdir().unwrap();
415        let mut step_outputs = HashMap::new();
416        let mut build_outputs = HashMap::new();
417        build_outputs.insert("version".to_string(), "1.2.3".to_string());
418        step_outputs.insert("build".to_string(), build_outputs);
419
420        let text = "Version is ${{ steps.build.outputs.version }}";
421        let empty_env = HashMap::new();
422        let ctx = make_ctx(&None, &step_outputs, &empty_env);
423        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
424        assert_eq!(result, "Version is 1.2.3");
425    }
426
427    #[test]
428    fn step_output_missing_returns_empty() {
429        let dir = tempdir().unwrap();
430
431        let text = "Value: ${{ steps.unknown.outputs.key }}";
432        let empty_steps = HashMap::new();
433        let empty_env = HashMap::new();
434        let ctx = make_ctx(&None, &empty_steps, &empty_env);
435        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
436        assert_eq!(result, "Value: ");
437    }
438
439    #[test]
440    fn step_output_missing_key_returns_empty() {
441        let dir = tempdir().unwrap();
442        let mut step_outputs = HashMap::new();
443        step_outputs.insert("build".to_string(), HashMap::new());
444
445        let text = "${{ steps.build.outputs.missing }}";
446        let empty_env = HashMap::new();
447        let ctx = make_ctx(&None, &step_outputs, &empty_env);
448        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
449        assert_eq!(result, "");
450    }
451
452    // -- env context via expression evaluator --
453
454    #[test]
455    fn env_context_substitution() {
456        let dir = tempdir().unwrap();
457        let mut env = HashMap::new();
458        env.insert("MY_VAR".to_string(), "hello".to_string());
459
460        let text = "Value: ${{ env.MY_VAR }}";
461        let empty_steps = HashMap::new();
462        let ctx = make_ctx(&None, &empty_steps, &env);
463        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
464        assert_eq!(result, "Value: hello");
465    }
466
467    #[test]
468    fn env_context_missing_returns_empty() {
469        let dir = tempdir().unwrap();
470
471        let text = "${{ env.MISSING }}";
472        let empty_steps = HashMap::new();
473        let empty_env = HashMap::new();
474        let ctx = make_ctx(&None, &empty_steps, &empty_env);
475        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
476        assert_eq!(result, "");
477    }
478
479    // -- combined contexts --
480
481    #[test]
482    fn combined_substitutions() {
483        let dir = tempdir().unwrap();
484        fs::write(dir.path().join("lock"), "content").unwrap();
485
486        let mut matrix = HashMap::new();
487        matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
488
489        let mut step_outputs = HashMap::new();
490        let mut build_out = HashMap::new();
491        build_out.insert("tag".to_string(), "v1".to_string());
492        step_outputs.insert("build".to_string(), build_out);
493
494        let mut env = HashMap::new();
495        env.insert("CI".to_string(), "true".to_string());
496
497        let text = "${{ matrix.os }}-${{ steps.build.outputs.tag }}-${{ env.CI }}";
498        let matrix = Some(matrix);
499        let ctx = make_ctx(&matrix, &step_outputs, &env);
500        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
501        assert_eq!(result, "ubuntu-v1-true");
502    }
503
504    // -- inputs context via expression evaluator --
505
506    #[test]
507    fn inputs_context_substitution() {
508        let dir = tempdir().unwrap();
509        let mut env = HashMap::new();
510        env.insert("INPUT_TOOLCHAIN".to_string(), "stable".to_string());
511        env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
512
513        let text =
514            "rustup toolchain install ${{ inputs.toolchain }} --component ${{ inputs.components }}";
515        let empty_steps = HashMap::new();
516        let ctx = make_ctx(&None, &empty_steps, &env);
517        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
518        assert_eq!(
519            result,
520            "rustup toolchain install stable --component rustfmt"
521        );
522    }
523
524    #[test]
525    fn inputs_context_hyphenated_name() {
526        let dir = tempdir().unwrap();
527        let mut env = HashMap::new();
528        env.insert("INPUT_NODE_VERSION".to_string(), "18".to_string());
529
530        let text = "${{ inputs.node-version }}";
531        let empty_steps = HashMap::new();
532        let ctx = make_ctx(&None, &empty_steps, &env);
533        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
534        assert_eq!(result, "18");
535    }
536
537    #[test]
538    fn inputs_context_missing_returns_empty() {
539        let dir = tempdir().unwrap();
540
541        let text = "${{ inputs.missing }}";
542        let empty_steps = HashMap::new();
543        let empty_env = HashMap::new();
544        let ctx = make_ctx(&None, &empty_steps, &empty_env);
545        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
546        assert_eq!(result, "");
547    }
548
549    // -- github context via expression evaluator --
550
551    #[test]
552    fn github_context_substitution() {
553        let dir = tempdir().unwrap();
554        let mut env = HashMap::new();
555        env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
556        env.insert("GITHUB_REF_NAME".to_string(), "main".to_string());
557
558        let text = "${{ github.repository }}/${{ github.ref_name }}";
559        let empty_steps = HashMap::new();
560        let ctx = make_ctx(&None, &empty_steps, &env);
561        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
562        assert_eq!(result, "owner/repo/main");
563    }
564
565    #[test]
566    fn github_context_missing_returns_empty() {
567        let dir = tempdir().unwrap();
568
569        let text = "${{ github.token }}";
570        let empty_steps = HashMap::new();
571        let empty_env = HashMap::new();
572        let ctx = make_ctx(&None, &empty_steps, &empty_env);
573        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
574        assert_eq!(result, "");
575    }
576
577    // -- runner context via expression evaluator --
578
579    #[test]
580    fn runner_context_substitution() {
581        let dir = tempdir().unwrap();
582        let mut env = HashMap::new();
583        env.insert("RUNNER_OS".to_string(), "Linux".to_string());
584        env.insert("RUNNER_TEMP".to_string(), "/tmp/runner".to_string());
585
586        let text = "${{ runner.os }} ${{ runner.temp }}";
587        let empty_steps = HashMap::new();
588        let ctx = make_ctx(&None, &empty_steps, &env);
589        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
590        assert_eq!(result, "Linux /tmp/runner");
591    }
592
593    #[test]
594    fn runner_context_missing_returns_empty() {
595        let dir = tempdir().unwrap();
596
597        let text = "${{ runner.arch }}";
598        let empty_steps = HashMap::new();
599        let empty_env = HashMap::new();
600        let ctx = make_ctx(&None, &empty_steps, &empty_env);
601        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
602        assert_eq!(result, "");
603    }
604
605    // -- all contexts via preprocess_expressions --
606
607    #[test]
608    fn preprocess_expressions_includes_inputs_and_github() {
609        let dir = tempdir().unwrap();
610
611        let mut env = HashMap::new();
612        env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string());
613        env.insert("GITHUB_REPOSITORY".to_string(), "foo/bar".to_string());
614        env.insert("RUNNER_OS".to_string(), "Linux".to_string());
615
616        let text = "${{ inputs.toolchain }}-${{ github.repository }}-${{ runner.os }}";
617        let empty_steps = HashMap::new();
618        let ctx = make_ctx(&None, &empty_steps, &env);
619        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
620        assert_eq!(result, "nightly-foo/bar-Linux");
621    }
622
623    #[test]
624    fn preprocess_expressions_unknown_context_returns_empty() {
625        let dir = tempdir().unwrap();
626
627        let text = "echo ${{ unknown_context.value }}";
628        let empty_steps = HashMap::new();
629        let empty_env = HashMap::new();
630        let ctx = make_ctx(&None, &empty_steps, &empty_env);
631        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
632        assert_eq!(result, "echo ");
633    }
634
635    // -- complex expressions --
636
637    #[test]
638    fn preprocess_expressions_evaluates_complex_expression() {
639        let dir = tempdir().unwrap();
640        let mut env = HashMap::new();
641        env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
642
643        let mut step_outputs = HashMap::new();
644        let mut parse_out = HashMap::new();
645        parse_out.insert("toolchain".to_string(), "nightly".to_string());
646        step_outputs.insert("parse".to_string(), parse_out);
647
648        // This is the dtolnay/rust-toolchain pattern that triggered the original bug
649        let text = "rustup toolchain install nightly${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}";
650        let ctx = make_ctx(&None, &step_outputs, &env);
651        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
652        assert_eq!(result, "rustup toolchain install nightly --allow-downgrade");
653    }
654
655    #[test]
656    fn preprocess_expressions_evaluates_comparison_to_empty() {
657        let dir = tempdir().unwrap();
658        let mut env = HashMap::new();
659        env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
660
661        let mut step_outputs = HashMap::new();
662        let mut parse_out = HashMap::new();
663        parse_out.insert("toolchain".to_string(), "stable".to_string());
664        step_outputs.insert("parse".to_string(), parse_out);
665
666        let text = "rustup toolchain install stable${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}";
667        let ctx = make_ctx(&None, &step_outputs, &env);
668        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
669        // stable != nightly, so the expression evaluates to ''
670        assert_eq!(result, "rustup toolchain install stable");
671    }
672
673    #[test]
674    fn preprocess_expressions_no_spaces() {
675        let dir = tempdir().unwrap();
676        let mut env = HashMap::new();
677        env.insert("RUNNER_OS".to_string(), "Linux".to_string());
678
679        // dtolnay/rust-toolchain uses ${{runner.os}} without spaces
680        let text = "if [[ ${{runner.os}} == macOS ]]; then echo mac; fi";
681        let empty_steps = HashMap::new();
682        let ctx = make_ctx(&None, &empty_steps, &env);
683        let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
684        assert_eq!(result, "if [[ Linux == macOS ]]; then echo mac; fi");
685    }
686}