Skip to main content

cuenv_core/tasks/
output_refs.rs

1//! Task output reference processing.
2//!
3//! Handles detection and resolution of `#TaskOutputRef` structs in task JSON.
4//! These structs are produced by CUE when tasks reference another task's
5//! `stdout`, `stderr`, or `exitCode` fields.
6//!
7//! # Processing Pipeline
8//!
9//! 1. CUE evaluates `tasks.tmpdir.stdout` → `{ cuenvOutputRef: true, cuenvTask: "tmpdir", cuenvOutput: "stdout" }`
10//! 2. [`process_output_refs`] walks raw JSON, replaces ref objects with placeholder strings
11//! 3. Task deserialization sees plain strings in `args`/`env` (`Vec<String>`)
12//! 4. [`OutputRefResolver::resolve`] replaces placeholder strings with actual values before execution
13
14use super::TaskResult;
15use crate::{Error, Result};
16use std::collections::HashMap;
17
18/// Prefix for placeholder strings that represent task output references.
19/// Format: `cuenv:ref:<task_name>:<output_field>`
20const OUTPUT_REF_PREFIX: &str = "cuenv:ref:";
21
22/// Prefix for placeholder strings that represent image output references.
23/// Format: `cuenv:image-ref:<image_name>:<ref|digest>`
24const IMAGE_REF_PREFIX: &str = "cuenv:image-ref:";
25
26/// Prefix for placeholder strings that represent host env passthrough.
27/// Format: `cuenv:passthrough:<var_name>`
28const PASSTHROUGH_PREFIX: &str = "cuenv:passthrough:";
29
30/// Which output field of a task is being referenced.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TaskOutputField {
33    Stdout,
34    Stderr,
35    ExitCode,
36}
37
38/// A parsed task output reference.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TaskOutputRef {
41    /// Name of the referenced task (e.g., "tmpdir", "pipeline[0]")
42    pub task: String,
43    /// Which output field is referenced
44    pub output: TaskOutputField,
45}
46
47impl TaskOutputRef {
48    /// Parse a placeholder string like `"cuenv:ref:tmpdir:stdout"`.
49    /// Returns `None` if the string is not a valid output ref placeholder.
50    #[must_use]
51    pub fn parse(s: &str) -> Option<Self> {
52        let rest = s.strip_prefix(OUTPUT_REF_PREFIX)?;
53        // Find the last ':' to split task name from output field.
54        // Task names may contain colons (e.g., FQDNs like "task:proj:build"),
55        // dots, and brackets. Output field names (stdout/stderr/exitCode) never
56        // contain colons, so rfind(':') reliably finds the boundary.
57        let last_colon = rest.rfind(':')?;
58        let task = &rest[..last_colon];
59        let output_str = &rest[last_colon + 1..];
60
61        if task.is_empty() {
62            return None;
63        }
64
65        let output = match output_str {
66            "stdout" => TaskOutputField::Stdout,
67            "stderr" => TaskOutputField::Stderr,
68            "exitCode" => TaskOutputField::ExitCode,
69            _ => return None,
70        };
71
72        Some(Self {
73            task: task.to_string(),
74            output,
75        })
76    }
77
78    /// Convert to a placeholder string.
79    #[must_use]
80    pub fn to_placeholder(&self) -> String {
81        let output_str = match self.output {
82            TaskOutputField::Stdout => "stdout",
83            TaskOutputField::Stderr => "stderr",
84            TaskOutputField::ExitCode => "exitCode",
85        };
86        format!("{OUTPUT_REF_PREFIX}{}:{output_str}", self.task)
87    }
88}
89
90/// A dependency pair: (task_that_references, task_being_referenced).
91pub type OutputRefDep = (String, String);
92
93/// Process task output references in raw JSON before deserialization.
94///
95/// Walks the `tasks` subtree of a CUE-evaluated JSON value and:
96/// 1. Replaces `#TaskOutputRef` objects in `args` and `env` with placeholder strings
97/// 2. Strips computed `stdout`/`stderr`/`exitCode` fields from task objects
98/// 3. Returns dependency pairs for auto-dependency inference
99///
100/// This must be called on raw `serde_json::Value` BEFORE deserializing into
101/// `Task` structs, because ref objects would fail `Vec<String>` deserialization.
102pub fn process_output_refs(value: &mut serde_json::Value) -> Vec<OutputRefDep> {
103    let mut deps = Vec::new();
104
105    if let Some(tasks) = value.get_mut("tasks") {
106        process_task_node(tasks, "", &mut deps);
107    }
108
109    deps
110}
111
112/// Recursively process a task node (Task, Group, or Sequence).
113fn process_task_node(
114    value: &mut serde_json::Value,
115    current_task: &str,
116    deps: &mut Vec<OutputRefDep>,
117) {
118    match value {
119        serde_json::Value::Object(obj) => {
120            // Check if this is a Task (has command or script)
121            let is_task = obj.contains_key("command") || obj.contains_key("script");
122
123            if is_task {
124                // Strip computed output ref fields (they're schema artifacts)
125                obj.remove("stdout");
126                obj.remove("stderr");
127                obj.remove("exitCode");
128
129                // Process args array
130                if let Some(serde_json::Value::Array(args)) = obj.get_mut("args") {
131                    for arg in args.iter_mut() {
132                        if let Some(placeholder) = try_extract_output_ref(arg) {
133                            if let Some(parsed) = TaskOutputRef::parse(&placeholder) {
134                                deps.push((current_task.to_string(), parsed.task.clone()));
135                            }
136                            *arg = serde_json::Value::String(placeholder);
137                        }
138                    }
139                }
140
141                // Process env map — replace output refs and passthrough objects with placeholders
142                if let Some(serde_json::Value::Object(env)) = obj.get_mut("env") {
143                    let keys: Vec<String> = env.keys().cloned().collect();
144                    for key in keys {
145                        let Some(env_val) = env.get_mut(&key) else {
146                            continue;
147                        };
148                        if let Some(placeholder) = try_extract_output_ref(env_val) {
149                            if let Some(parsed) = TaskOutputRef::parse(&placeholder) {
150                                deps.push((current_task.to_string(), parsed.task.clone()));
151                            }
152                            *env_val = serde_json::Value::String(placeholder);
153                        } else if let Some(placeholder) = try_extract_passthrough(env_val, &key) {
154                            *env_val = serde_json::Value::String(placeholder);
155                        }
156                    }
157                }
158
159                return;
160            }
161
162            // Check if this is a TaskGroup (type: "group")
163            let is_group = obj
164                .get("type")
165                .and_then(|v| v.as_str())
166                .is_some_and(|s| s == "group");
167
168            if is_group {
169                // Process group children
170                let child_keys: Vec<String> = obj
171                    .keys()
172                    .filter(|k| {
173                        !matches!(
174                            k.as_str(),
175                            "type" | "dependsOn" | "maxConcurrency" | "description"
176                        )
177                    })
178                    .cloned()
179                    .collect();
180
181                for key in child_keys {
182                    let child_task = if current_task.is_empty() {
183                        key.clone()
184                    } else {
185                        format!("{current_task}.{key}")
186                    };
187                    if let Some(child) = obj.get_mut(&key) {
188                        process_task_node(child, &child_task, deps);
189                    }
190                }
191                return;
192            }
193
194            // Named task children (top-level tasks map or nested struct)
195            let keys: Vec<String> = obj.keys().cloned().collect();
196            for key in keys {
197                let child_task = if current_task.is_empty() {
198                    key.clone()
199                } else {
200                    format!("{current_task}.{key}")
201                };
202                if let Some(child) = obj.get_mut(&key) {
203                    process_task_node(child, &child_task, deps);
204                }
205            }
206        }
207        serde_json::Value::Array(arr) => {
208            // Sequence: process each element
209            for (i, element) in arr.iter_mut().enumerate() {
210                let child_task = format!("{current_task}[{i}]");
211                process_task_node(element, &child_task, deps);
212            }
213        }
214        _ => {}
215    }
216}
217
218/// Try to extract an `#EnvPassthrough` from a JSON value.
219/// Returns the passthrough placeholder string if the value is a passthrough object.
220/// `env_key` is the map key, used as fallback when `name` is absent.
221fn try_extract_passthrough(value: &serde_json::Value, env_key: &str) -> Option<String> {
222    let obj = value.as_object()?;
223
224    let is_passthrough = obj
225        .get("cuenvPassthrough")
226        .and_then(|v| v.as_bool())
227        .unwrap_or(false);
228
229    if !is_passthrough {
230        return None;
231    }
232
233    let var_name = obj.get("name").and_then(|v| v.as_str()).unwrap_or(env_key);
234
235    Some(format!("{PASSTHROUGH_PREFIX}{var_name}"))
236}
237
238/// Parse a passthrough placeholder string, returning the host var name.
239#[must_use]
240pub fn parse_passthrough(s: &str) -> Option<&str> {
241    s.strip_prefix(PASSTHROUGH_PREFIX)
242}
243
244/// Try to extract a `#TaskOutputRef` or `#ImageOutputRef` from a JSON value.
245/// Returns the placeholder string if the value is a ref object, None otherwise.
246fn try_extract_output_ref(value: &serde_json::Value) -> Option<String> {
247    let obj = value.as_object()?;
248
249    // Check discriminator field
250    let is_ref = obj
251        .get("cuenvOutputRef")
252        .and_then(|v| v.as_bool())
253        .unwrap_or(false);
254
255    if !is_ref {
256        return None;
257    }
258
259    // Task output ref: { cuenvOutputRef: true, cuenvTask: "...", cuenvOutput: "stdout"|"stderr"|"exitCode" }
260    if let Some(task) = obj.get("cuenvTask").and_then(|v| v.as_str()) {
261        let output = obj.get("cuenvOutput")?.as_str()?;
262        let output_field = match output {
263            "stdout" => TaskOutputField::Stdout,
264            "stderr" => TaskOutputField::Stderr,
265            "exitCode" => TaskOutputField::ExitCode,
266            _ => return None,
267        };
268        let r = TaskOutputRef {
269            task: task.to_string(),
270            output: output_field,
271        };
272        return Some(r.to_placeholder());
273    }
274
275    // Image output ref: { cuenvOutputRef: true, cuenvImage: "...", cuenvOutput: "ref"|"digest" }
276    if let Some(image) = obj.get("cuenvImage").and_then(|v| v.as_str()) {
277        let output = obj.get("cuenvOutput")?.as_str()?;
278        if output != "ref" && output != "digest" {
279            return None;
280        }
281        return Some(format!("{IMAGE_REF_PREFIX}{image}:{output}"));
282    }
283
284    None
285}
286
287/// Returns `true` if any string in `args` or `env` contains an output ref placeholder.
288///
289/// Use this as a fast check to avoid cloning tasks that have no refs to resolve.
290#[must_use]
291pub fn has_output_refs(args: &[String], env: &HashMap<String, serde_json::Value>) -> bool {
292    let has_ref = |s: &str| s.starts_with(OUTPUT_REF_PREFIX) || s.starts_with(IMAGE_REF_PREFIX);
293    args.iter().any(|a| has_ref(a)) || env.values().any(|v| v.as_str().is_some_and(has_ref))
294}
295
296/// Context for resolving task output reference placeholders at runtime.
297pub struct OutputRefResolver<'a> {
298    /// Name of the task being resolved (for error messages)
299    pub task_name: &'a str,
300    /// Completed upstream task results to resolve references against
301    pub results: &'a HashMap<String, TaskResult>,
302}
303
304impl<'a> OutputRefResolver<'a> {
305    /// Resolve all output ref placeholder strings in a task's args and env.
306    ///
307    /// Called just before task execution. Replaces placeholder strings with
308    /// actual values from completed upstream tasks.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if:
313    /// - A referenced task has not completed (missing from results)
314    /// - A referenced task failed (non-zero exit code)
315    /// - An `exitCode` ref is used in a string context (exitCode is int-only)
316    pub fn resolve(
317        &self,
318        args: &mut [String],
319        env: &mut HashMap<String, serde_json::Value>,
320    ) -> Result<()> {
321        // Resolve args
322        for arg in args.iter_mut() {
323            if let Some(resolved) = resolve_single_ref(self.task_name, arg, self.results)? {
324                *arg = resolved;
325            }
326        }
327
328        // Resolve env values
329        for (_env_key, env_val) in env.iter_mut() {
330            if let Some(s) = env_val.as_str()
331                && let Some(resolved) = resolve_single_ref(self.task_name, s, self.results)?
332            {
333                *env_val = serde_json::Value::String(resolved);
334            }
335        }
336
337        Ok(())
338    }
339}
340
341/// Resolve a single placeholder string, returning the resolved value.
342/// Returns Ok(None) if the string is not a placeholder.
343fn resolve_single_ref(
344    task_name: &str,
345    value: &str,
346    results: &HashMap<String, TaskResult>,
347) -> Result<Option<String>> {
348    let Some(output_ref) = TaskOutputRef::parse(value) else {
349        return Ok(None);
350    };
351
352    // exitCode cannot be used in string context (args/env)
353    if output_ref.output == TaskOutputField::ExitCode {
354        return Err(Error::configuration(format!(
355            "Task '{}': cannot use exitCode of '{}' in args/env (exitCode is an integer, not a string)",
356            task_name, output_ref.task
357        )));
358    }
359
360    let result = results.get(&output_ref.task).ok_or_else(|| {
361        Error::configuration(format!(
362            "Task '{}': references output of '{}', but that task has not completed",
363            task_name, output_ref.task
364        ))
365    })?;
366
367    if !result.success {
368        return Err(Error::task_failed(
369            &output_ref.task,
370            result.exit_code.unwrap_or(-1),
371            &result.stdout,
372            &result.stderr,
373        ));
374    }
375
376    let resolved = match output_ref.output {
377        TaskOutputField::Stdout => result.stdout.trim().to_string(),
378        TaskOutputField::Stderr => result.stderr.trim().to_string(),
379        TaskOutputField::ExitCode => unreachable!("handled above"),
380    };
381
382    Ok(Some(resolved))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    // =========================================================================
390    // TaskOutputRef::parse tests
391    // =========================================================================
392
393    #[test]
394    fn parse_valid_stdout_ref() {
395        let r = TaskOutputRef::parse("cuenv:ref:tmpdir:stdout").unwrap();
396        assert_eq!(r.task, "tmpdir");
397        assert_eq!(r.output, TaskOutputField::Stdout);
398    }
399
400    #[test]
401    fn parse_valid_stderr_ref() {
402        let r = TaskOutputRef::parse("cuenv:ref:build:stderr").unwrap();
403        assert_eq!(r.task, "build");
404        assert_eq!(r.output, TaskOutputField::Stderr);
405    }
406
407    #[test]
408    fn parse_valid_exit_code_ref() {
409        let r = TaskOutputRef::parse("cuenv:ref:check:exitCode").unwrap();
410        assert_eq!(r.task, "check");
411        assert_eq!(r.output, TaskOutputField::ExitCode);
412    }
413
414    #[test]
415    fn parse_dotted_task_name() {
416        let r = TaskOutputRef::parse("cuenv:ref:check.lint:stdout").unwrap();
417        assert_eq!(r.task, "check.lint");
418        assert_eq!(r.output, TaskOutputField::Stdout);
419    }
420
421    #[test]
422    fn parse_bracketed_task_name() {
423        let r = TaskOutputRef::parse("cuenv:ref:pipeline[0]:stdout").unwrap();
424        assert_eq!(r.task, "pipeline[0]");
425        assert_eq!(r.output, TaskOutputField::Stdout);
426    }
427
428    #[test]
429    fn parse_non_ref_string() {
430        assert!(TaskOutputRef::parse("hello world").is_none());
431        assert!(TaskOutputRef::parse("").is_none());
432        assert!(TaskOutputRef::parse("cuenv:ref:").is_none());
433        assert!(TaskOutputRef::parse("cuenv:ref::stdout").is_none());
434    }
435
436    #[test]
437    fn parse_fqdn_task_name() {
438        // FQDN format: task:project_id:task_name — contains colons
439        let r = TaskOutputRef::parse("cuenv:ref:task:myproject:build:stdout").unwrap();
440        assert_eq!(r.task, "task:myproject:build");
441        assert_eq!(r.output, TaskOutputField::Stdout);
442    }
443
444    #[test]
445    fn roundtrip_fqdn_placeholder() {
446        let r = TaskOutputRef {
447            task: "task:myproject:build".to_string(),
448            output: TaskOutputField::Stderr,
449        };
450        let placeholder = r.to_placeholder();
451        assert_eq!(placeholder, "cuenv:ref:task:myproject:build:stderr");
452        let parsed = TaskOutputRef::parse(&placeholder).unwrap();
453        assert_eq!(parsed, r);
454    }
455
456    #[test]
457    fn parse_invalid_output_field() {
458        assert!(TaskOutputRef::parse("cuenv:ref:task:invalid").is_none());
459    }
460
461    #[test]
462    fn roundtrip_placeholder() {
463        let r = TaskOutputRef {
464            task: "tmpdir".to_string(),
465            output: TaskOutputField::Stdout,
466        };
467        let placeholder = r.to_placeholder();
468        assert_eq!(placeholder, "cuenv:ref:tmpdir:stdout");
469        let parsed = TaskOutputRef::parse(&placeholder).unwrap();
470        assert_eq!(parsed, r);
471    }
472
473    // =========================================================================
474    // try_extract_output_ref tests
475    // =========================================================================
476
477    #[test]
478    fn extract_valid_ref_object() {
479        let val = serde_json::json!({
480            "cuenvOutputRef": true,
481            "cuenvTask": "tmpdir",
482            "cuenvOutput": "stdout"
483        });
484        let result = try_extract_output_ref(&val).unwrap();
485        assert_eq!(result, "cuenv:ref:tmpdir:stdout");
486    }
487
488    #[test]
489    fn extract_non_ref_object() {
490        let val = serde_json::json!({ "command": "echo" });
491        assert!(try_extract_output_ref(&val).is_none());
492    }
493
494    #[test]
495    fn extract_ref_false() {
496        let val = serde_json::json!({
497            "cuenvOutputRef": false,
498            "cuenvTask": "tmpdir",
499            "cuenvOutput": "stdout"
500        });
501        assert!(try_extract_output_ref(&val).is_none());
502    }
503
504    #[test]
505    fn extract_string_value() {
506        let val = serde_json::json!("just a string");
507        assert!(try_extract_output_ref(&val).is_none());
508    }
509
510    // =========================================================================
511    // process_output_refs tests
512    // =========================================================================
513
514    #[test]
515    fn process_replaces_args_refs() {
516        let mut value = serde_json::json!({
517            "tasks": {
518                "tmpdir": {
519                    "command": "mktemp",
520                    "args": ["-d"],
521                    "stdout": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stdout" },
522                    "stderr": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stderr" },
523                    "exitCode": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "exitCode" }
524                },
525                "work": {
526                    "command": "echo",
527                    "args": [
528                        { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stdout" }
529                    ]
530                }
531            }
532        });
533
534        let deps = process_output_refs(&mut value);
535
536        // Args should be replaced with placeholder strings
537        let work_args = value["tasks"]["work"]["args"].as_array().unwrap();
538        assert_eq!(work_args[0].as_str().unwrap(), "cuenv:ref:tmpdir:stdout");
539
540        // stdout/stderr/exitCode should be stripped from task objects
541        assert!(value["tasks"]["tmpdir"].get("stdout").is_none());
542        assert!(value["tasks"]["tmpdir"].get("stderr").is_none());
543        assert!(value["tasks"]["tmpdir"].get("exitCode").is_none());
544
545        // Dependencies should be collected
546        assert_eq!(deps.len(), 1);
547        assert_eq!(deps[0], ("work".to_string(), "tmpdir".to_string()));
548    }
549
550    #[test]
551    fn process_replaces_env_refs() {
552        let mut value = serde_json::json!({
553            "tasks": {
554                "tmpdir": {
555                    "command": "mktemp",
556                    "args": ["-d"]
557                },
558                "work": {
559                    "command": "ls",
560                    "env": {
561                        "TEMP_DIR": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stdout" }
562                    }
563                }
564            }
565        });
566
567        let deps = process_output_refs(&mut value);
568
569        let env_val = value["tasks"]["work"]["env"]["TEMP_DIR"].as_str().unwrap();
570        assert_eq!(env_val, "cuenv:ref:tmpdir:stdout");
571        assert_eq!(deps.len(), 1);
572        assert_eq!(deps[0], ("work".to_string(), "tmpdir".to_string()));
573    }
574
575    #[test]
576    fn process_handles_sequences() {
577        let mut value = serde_json::json!({
578            "tasks": {
579                "pipeline": [
580                    { "command": "mktemp", "args": ["-d"] },
581                    {
582                        "command": "echo",
583                        "args": [
584                            { "cuenvOutputRef": true, "cuenvTask": "pipeline[0]", "cuenvOutput": "stdout" }
585                        ]
586                    }
587                ]
588            }
589        });
590
591        let deps = process_output_refs(&mut value);
592
593        let step1_args = value["tasks"]["pipeline"][1]["args"].as_array().unwrap();
594        assert_eq!(
595            step1_args[0].as_str().unwrap(),
596            "cuenv:ref:pipeline[0]:stdout"
597        );
598        assert_eq!(deps.len(), 1);
599        assert_eq!(
600            deps[0],
601            ("pipeline[1]".to_string(), "pipeline[0]".to_string())
602        );
603    }
604
605    #[test]
606    fn process_handles_groups() {
607        let mut value = serde_json::json!({
608            "tasks": {
609                "check": {
610                    "type": "group",
611                    "lint": {
612                        "command": "cargo",
613                        "args": ["clippy"]
614                    },
615                    "test": {
616                        "command": "cargo",
617                        "args": ["test"]
618                    }
619                }
620            }
621        });
622
623        let deps = process_output_refs(&mut value);
624        assert!(deps.is_empty());
625        // Verify stdout was stripped from group children
626        assert!(value["tasks"]["check"]["lint"].get("stdout").is_none());
627    }
628
629    #[test]
630    fn process_multiple_refs_in_args() {
631        let mut value = serde_json::json!({
632            "tasks": {
633                "a": { "command": "echo", "args": ["hello"] },
634                "b": { "command": "echo", "args": ["world"] },
635                "c": {
636                    "command": "echo",
637                    "args": [
638                        { "cuenvOutputRef": true, "cuenvTask": "a", "cuenvOutput": "stdout" },
639                        { "cuenvOutputRef": true, "cuenvTask": "b", "cuenvOutput": "stdout" }
640                    ]
641                }
642            }
643        });
644
645        let deps = process_output_refs(&mut value);
646        assert_eq!(deps.len(), 2);
647        assert!(deps.contains(&("c".to_string(), "a".to_string())));
648        assert!(deps.contains(&("c".to_string(), "b".to_string())));
649    }
650
651    #[test]
652    fn process_refs_in_both_args_and_env() {
653        let mut value = serde_json::json!({
654            "tasks": {
655                "src": { "command": "echo", "args": ["data"] },
656                "dst": {
657                    "command": "echo",
658                    "args": [
659                        { "cuenvOutputRef": true, "cuenvTask": "src", "cuenvOutput": "stdout" }
660                    ],
661                    "env": {
662                        "DATA": { "cuenvOutputRef": true, "cuenvTask": "src", "cuenvOutput": "stderr" }
663                    }
664                }
665            }
666        });
667
668        let deps = process_output_refs(&mut value);
669        // Both references should produce deps (deduplication is caller's concern)
670        assert_eq!(deps.len(), 2);
671    }
672
673    // =========================================================================
674    // OutputRefResolver tests
675    // =========================================================================
676
677    fn make_result(name: &str, stdout: &str, stderr: &str, exit_code: i32) -> TaskResult {
678        TaskResult {
679            name: name.to_string(),
680            stdout: stdout.to_string(),
681            stderr: stderr.to_string(),
682            exit_code: Some(exit_code),
683            success: exit_code == 0,
684        }
685    }
686
687    fn resolver(results: &HashMap<String, TaskResult>) -> OutputRefResolver<'_> {
688        OutputRefResolver {
689            task_name: "work",
690            results,
691        }
692    }
693
694    #[test]
695    fn resolve_stdout_in_args() {
696        let mut args = vec!["cuenv:ref:tmpdir:stdout".to_string()];
697        let mut env = HashMap::new();
698        let mut results = HashMap::new();
699        results.insert(
700            "tmpdir".to_string(),
701            make_result("tmpdir", "/tmp/abc\n", "", 0),
702        );
703
704        resolver(&results).resolve(&mut args, &mut env).unwrap();
705        assert_eq!(args[0], "/tmp/abc"); // trimmed
706    }
707
708    #[test]
709    fn resolve_stderr_in_env() {
710        let mut args = Vec::new();
711        let mut env = HashMap::new();
712        env.insert(
713            "ERR".to_string(),
714            serde_json::Value::String("cuenv:ref:check:stderr".to_string()),
715        );
716        let mut results = HashMap::new();
717        results.insert(
718            "check".to_string(),
719            make_result("check", "", "  warning  \n", 0),
720        );
721
722        resolver(&results).resolve(&mut args, &mut env).unwrap();
723        assert_eq!(env["ERR"].as_str().unwrap(), "warning");
724    }
725
726    #[test]
727    fn resolve_non_ref_strings_unchanged() {
728        let mut args = vec!["hello".to_string(), "--flag".to_string()];
729        let mut env = HashMap::new();
730        env.insert(
731            "FOO".to_string(),
732            serde_json::Value::String("bar".to_string()),
733        );
734        let results = HashMap::new();
735
736        resolver(&results).resolve(&mut args, &mut env).unwrap();
737        assert_eq!(args, vec!["hello", "--flag"]);
738        assert_eq!(env["FOO"].as_str().unwrap(), "bar");
739    }
740
741    #[test]
742    fn resolve_missing_task_errors() {
743        let mut args = vec!["cuenv:ref:nonexistent:stdout".to_string()];
744        let mut env = HashMap::new();
745        let results = HashMap::new();
746
747        let err = resolver(&results).resolve(&mut args, &mut env).unwrap_err();
748        let msg = err.to_string();
749        assert!(msg.contains("nonexistent"));
750        assert!(msg.contains("not completed"));
751    }
752
753    #[test]
754    fn resolve_failed_task_errors() {
755        let mut args = vec!["cuenv:ref:failing:stdout".to_string()];
756        let mut env = HashMap::new();
757        let mut results = HashMap::new();
758        results.insert(
759            "failing".to_string(),
760            make_result("failing", "", "error!", 1),
761        );
762
763        let err = resolver(&results).resolve(&mut args, &mut env).unwrap_err();
764        let msg = err.to_string();
765        assert!(msg.contains("failing") || msg.contains("failed"));
766    }
767
768    #[test]
769    fn resolve_exit_code_in_args_errors() {
770        let mut args = vec!["cuenv:ref:check:exitCode".to_string()];
771        let mut env = HashMap::new();
772        let mut results = HashMap::new();
773        results.insert("check".to_string(), make_result("check", "", "", 0));
774
775        let err = resolver(&results).resolve(&mut args, &mut env).unwrap_err();
776        let msg = err.to_string();
777        assert!(msg.contains("exitCode"));
778        assert!(msg.contains("integer"));
779    }
780
781    #[test]
782    fn resolve_empty_stdout() {
783        let mut args = vec!["cuenv:ref:quiet:stdout".to_string()];
784        let mut env = HashMap::new();
785        let mut results = HashMap::new();
786        results.insert("quiet".to_string(), make_result("quiet", "", "", 0));
787
788        resolver(&results).resolve(&mut args, &mut env).unwrap();
789        assert_eq!(args[0], ""); // empty after trim
790    }
791
792    #[test]
793    fn resolve_trimming_behavior() {
794        let mut args = vec!["cuenv:ref:padded:stdout".to_string()];
795        let mut env = HashMap::new();
796        let mut results = HashMap::new();
797        results.insert(
798            "padded".to_string(),
799            make_result("padded", "  hello world  \n\n", "", 0),
800        );
801
802        resolver(&results).resolve(&mut args, &mut env).unwrap();
803        assert_eq!(args[0], "hello world");
804    }
805}