use super::TaskResult;
use crate::{Error, Result};
use std::collections::HashMap;
const OUTPUT_REF_PREFIX: &str = "cuenv:ref:";
const IMAGE_REF_PREFIX: &str = "cuenv:image-ref:";
const PASSTHROUGH_PREFIX: &str = "cuenv:passthrough:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskOutputField {
Stdout,
Stderr,
ExitCode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskOutputRef {
pub task: String,
pub output: TaskOutputField,
}
impl TaskOutputRef {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
let rest = s.strip_prefix(OUTPUT_REF_PREFIX)?;
let last_colon = rest.rfind(':')?;
let task = &rest[..last_colon];
let output_str = &rest[last_colon + 1..];
if task.is_empty() {
return None;
}
let output = match output_str {
"stdout" => TaskOutputField::Stdout,
"stderr" => TaskOutputField::Stderr,
"exitCode" => TaskOutputField::ExitCode,
_ => return None,
};
Some(Self {
task: task.to_string(),
output,
})
}
#[must_use]
pub fn to_placeholder(&self) -> String {
let output_str = match self.output {
TaskOutputField::Stdout => "stdout",
TaskOutputField::Stderr => "stderr",
TaskOutputField::ExitCode => "exitCode",
};
format!("{OUTPUT_REF_PREFIX}{}:{output_str}", self.task)
}
}
pub type OutputRefDep = (String, String);
pub fn process_output_refs(value: &mut serde_json::Value) -> Vec<OutputRefDep> {
let mut deps = Vec::new();
if let Some(tasks) = value.get_mut("tasks") {
process_task_node(tasks, "", &mut deps);
}
deps
}
fn process_task_node(
value: &mut serde_json::Value,
current_task: &str,
deps: &mut Vec<OutputRefDep>,
) {
match value {
serde_json::Value::Object(obj) => {
let is_task = obj.contains_key("command") || obj.contains_key("script");
if is_task {
obj.remove("stdout");
obj.remove("stderr");
obj.remove("exitCode");
if let Some(serde_json::Value::Array(args)) = obj.get_mut("args") {
for arg in args.iter_mut() {
if let Some(placeholder) = try_extract_output_ref(arg) {
if let Some(parsed) = TaskOutputRef::parse(&placeholder) {
deps.push((current_task.to_string(), parsed.task.clone()));
}
*arg = serde_json::Value::String(placeholder);
}
}
}
if let Some(serde_json::Value::Object(env)) = obj.get_mut("env") {
let keys: Vec<String> = env.keys().cloned().collect();
for key in keys {
let Some(env_val) = env.get_mut(&key) else {
continue;
};
if let Some(placeholder) = try_extract_output_ref(env_val) {
if let Some(parsed) = TaskOutputRef::parse(&placeholder) {
deps.push((current_task.to_string(), parsed.task.clone()));
}
*env_val = serde_json::Value::String(placeholder);
} else if let Some(placeholder) = try_extract_passthrough(env_val, &key) {
*env_val = serde_json::Value::String(placeholder);
}
}
}
return;
}
let is_group = obj
.get("type")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "group");
if is_group {
let child_keys: Vec<String> = obj
.keys()
.filter(|k| {
!matches!(
k.as_str(),
"type" | "dependsOn" | "maxConcurrency" | "description"
)
})
.cloned()
.collect();
for key in child_keys {
let child_task = if current_task.is_empty() {
key.clone()
} else {
format!("{current_task}.{key}")
};
if let Some(child) = obj.get_mut(&key) {
process_task_node(child, &child_task, deps);
}
}
return;
}
let keys: Vec<String> = obj.keys().cloned().collect();
for key in keys {
let child_task = if current_task.is_empty() {
key.clone()
} else {
format!("{current_task}.{key}")
};
if let Some(child) = obj.get_mut(&key) {
process_task_node(child, &child_task, deps);
}
}
}
serde_json::Value::Array(arr) => {
for (i, element) in arr.iter_mut().enumerate() {
let child_task = format!("{current_task}[{i}]");
process_task_node(element, &child_task, deps);
}
}
_ => {}
}
}
fn try_extract_passthrough(value: &serde_json::Value, env_key: &str) -> Option<String> {
let obj = value.as_object()?;
let is_passthrough = obj
.get("cuenvPassthrough")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_passthrough {
return None;
}
let var_name = obj.get("name").and_then(|v| v.as_str()).unwrap_or(env_key);
Some(format!("{PASSTHROUGH_PREFIX}{var_name}"))
}
#[must_use]
pub fn parse_passthrough(s: &str) -> Option<&str> {
s.strip_prefix(PASSTHROUGH_PREFIX)
}
fn try_extract_output_ref(value: &serde_json::Value) -> Option<String> {
let obj = value.as_object()?;
let is_ref = obj
.get("cuenvOutputRef")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_ref {
return None;
}
if let Some(task) = obj.get("cuenvTask").and_then(|v| v.as_str()) {
let output = obj.get("cuenvOutput")?.as_str()?;
let output_field = match output {
"stdout" => TaskOutputField::Stdout,
"stderr" => TaskOutputField::Stderr,
"exitCode" => TaskOutputField::ExitCode,
_ => return None,
};
let r = TaskOutputRef {
task: task.to_string(),
output: output_field,
};
return Some(r.to_placeholder());
}
if let Some(image) = obj.get("cuenvImage").and_then(|v| v.as_str()) {
let output = obj.get("cuenvOutput")?.as_str()?;
if output != "ref" && output != "digest" {
return None;
}
return Some(format!("{IMAGE_REF_PREFIX}{image}:{output}"));
}
None
}
#[must_use]
pub fn has_output_refs(args: &[String], env: &HashMap<String, serde_json::Value>) -> bool {
let has_ref = |s: &str| s.starts_with(OUTPUT_REF_PREFIX) || s.starts_with(IMAGE_REF_PREFIX);
args.iter().any(|a| has_ref(a)) || env.values().any(|v| v.as_str().is_some_and(has_ref))
}
pub struct OutputRefResolver<'a> {
pub task_name: &'a str,
pub results: &'a HashMap<String, TaskResult>,
}
impl<'a> OutputRefResolver<'a> {
pub fn resolve(
&self,
args: &mut [String],
env: &mut HashMap<String, serde_json::Value>,
) -> Result<()> {
for arg in args.iter_mut() {
if let Some(resolved) = resolve_single_ref(self.task_name, arg, self.results)? {
*arg = resolved;
}
}
for (_env_key, env_val) in env.iter_mut() {
if let Some(s) = env_val.as_str()
&& let Some(resolved) = resolve_single_ref(self.task_name, s, self.results)?
{
*env_val = serde_json::Value::String(resolved);
}
}
Ok(())
}
}
fn resolve_single_ref(
task_name: &str,
value: &str,
results: &HashMap<String, TaskResult>,
) -> Result<Option<String>> {
let Some(output_ref) = TaskOutputRef::parse(value) else {
return Ok(None);
};
if output_ref.output == TaskOutputField::ExitCode {
return Err(Error::configuration(format!(
"Task '{}': cannot use exitCode of '{}' in args/env (exitCode is an integer, not a string)",
task_name, output_ref.task
)));
}
let result = results.get(&output_ref.task).ok_or_else(|| {
Error::configuration(format!(
"Task '{}': references output of '{}', but that task has not completed",
task_name, output_ref.task
))
})?;
if !result.success {
return Err(Error::task_failed(
&output_ref.task,
result.exit_code.unwrap_or(-1),
&result.stdout,
&result.stderr,
));
}
let resolved = match output_ref.output {
TaskOutputField::Stdout => result.stdout.trim().to_string(),
TaskOutputField::Stderr => result.stderr.trim().to_string(),
TaskOutputField::ExitCode => unreachable!("handled above"),
};
Ok(Some(resolved))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_stdout_ref() {
let r = TaskOutputRef::parse("cuenv:ref:tmpdir:stdout").unwrap();
assert_eq!(r.task, "tmpdir");
assert_eq!(r.output, TaskOutputField::Stdout);
}
#[test]
fn parse_valid_stderr_ref() {
let r = TaskOutputRef::parse("cuenv:ref:build:stderr").unwrap();
assert_eq!(r.task, "build");
assert_eq!(r.output, TaskOutputField::Stderr);
}
#[test]
fn parse_valid_exit_code_ref() {
let r = TaskOutputRef::parse("cuenv:ref:check:exitCode").unwrap();
assert_eq!(r.task, "check");
assert_eq!(r.output, TaskOutputField::ExitCode);
}
#[test]
fn parse_dotted_task_name() {
let r = TaskOutputRef::parse("cuenv:ref:check.lint:stdout").unwrap();
assert_eq!(r.task, "check.lint");
assert_eq!(r.output, TaskOutputField::Stdout);
}
#[test]
fn parse_bracketed_task_name() {
let r = TaskOutputRef::parse("cuenv:ref:pipeline[0]:stdout").unwrap();
assert_eq!(r.task, "pipeline[0]");
assert_eq!(r.output, TaskOutputField::Stdout);
}
#[test]
fn parse_non_ref_string() {
assert!(TaskOutputRef::parse("hello world").is_none());
assert!(TaskOutputRef::parse("").is_none());
assert!(TaskOutputRef::parse("cuenv:ref:").is_none());
assert!(TaskOutputRef::parse("cuenv:ref::stdout").is_none());
}
#[test]
fn parse_fqdn_task_name() {
let r = TaskOutputRef::parse("cuenv:ref:task:myproject:build:stdout").unwrap();
assert_eq!(r.task, "task:myproject:build");
assert_eq!(r.output, TaskOutputField::Stdout);
}
#[test]
fn roundtrip_fqdn_placeholder() {
let r = TaskOutputRef {
task: "task:myproject:build".to_string(),
output: TaskOutputField::Stderr,
};
let placeholder = r.to_placeholder();
assert_eq!(placeholder, "cuenv:ref:task:myproject:build:stderr");
let parsed = TaskOutputRef::parse(&placeholder).unwrap();
assert_eq!(parsed, r);
}
#[test]
fn parse_invalid_output_field() {
assert!(TaskOutputRef::parse("cuenv:ref:task:invalid").is_none());
}
#[test]
fn roundtrip_placeholder() {
let r = TaskOutputRef {
task: "tmpdir".to_string(),
output: TaskOutputField::Stdout,
};
let placeholder = r.to_placeholder();
assert_eq!(placeholder, "cuenv:ref:tmpdir:stdout");
let parsed = TaskOutputRef::parse(&placeholder).unwrap();
assert_eq!(parsed, r);
}
#[test]
fn extract_valid_ref_object() {
let val = serde_json::json!({
"cuenvOutputRef": true,
"cuenvTask": "tmpdir",
"cuenvOutput": "stdout"
});
let result = try_extract_output_ref(&val).unwrap();
assert_eq!(result, "cuenv:ref:tmpdir:stdout");
}
#[test]
fn extract_non_ref_object() {
let val = serde_json::json!({ "command": "echo" });
assert!(try_extract_output_ref(&val).is_none());
}
#[test]
fn extract_ref_false() {
let val = serde_json::json!({
"cuenvOutputRef": false,
"cuenvTask": "tmpdir",
"cuenvOutput": "stdout"
});
assert!(try_extract_output_ref(&val).is_none());
}
#[test]
fn extract_string_value() {
let val = serde_json::json!("just a string");
assert!(try_extract_output_ref(&val).is_none());
}
#[test]
fn process_replaces_args_refs() {
let mut value = serde_json::json!({
"tasks": {
"tmpdir": {
"command": "mktemp",
"args": ["-d"],
"stdout": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stdout" },
"stderr": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stderr" },
"exitCode": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "exitCode" }
},
"work": {
"command": "echo",
"args": [
{ "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stdout" }
]
}
}
});
let deps = process_output_refs(&mut value);
let work_args = value["tasks"]["work"]["args"].as_array().unwrap();
assert_eq!(work_args[0].as_str().unwrap(), "cuenv:ref:tmpdir:stdout");
assert!(value["tasks"]["tmpdir"].get("stdout").is_none());
assert!(value["tasks"]["tmpdir"].get("stderr").is_none());
assert!(value["tasks"]["tmpdir"].get("exitCode").is_none());
assert_eq!(deps.len(), 1);
assert_eq!(deps[0], ("work".to_string(), "tmpdir".to_string()));
}
#[test]
fn process_replaces_env_refs() {
let mut value = serde_json::json!({
"tasks": {
"tmpdir": {
"command": "mktemp",
"args": ["-d"]
},
"work": {
"command": "ls",
"env": {
"TEMP_DIR": { "cuenvOutputRef": true, "cuenvTask": "tmpdir", "cuenvOutput": "stdout" }
}
}
}
});
let deps = process_output_refs(&mut value);
let env_val = value["tasks"]["work"]["env"]["TEMP_DIR"].as_str().unwrap();
assert_eq!(env_val, "cuenv:ref:tmpdir:stdout");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0], ("work".to_string(), "tmpdir".to_string()));
}
#[test]
fn process_handles_sequences() {
let mut value = serde_json::json!({
"tasks": {
"pipeline": [
{ "command": "mktemp", "args": ["-d"] },
{
"command": "echo",
"args": [
{ "cuenvOutputRef": true, "cuenvTask": "pipeline[0]", "cuenvOutput": "stdout" }
]
}
]
}
});
let deps = process_output_refs(&mut value);
let step1_args = value["tasks"]["pipeline"][1]["args"].as_array().unwrap();
assert_eq!(
step1_args[0].as_str().unwrap(),
"cuenv:ref:pipeline[0]:stdout"
);
assert_eq!(deps.len(), 1);
assert_eq!(
deps[0],
("pipeline[1]".to_string(), "pipeline[0]".to_string())
);
}
#[test]
fn process_handles_groups() {
let mut value = serde_json::json!({
"tasks": {
"check": {
"type": "group",
"lint": {
"command": "cargo",
"args": ["clippy"]
},
"test": {
"command": "cargo",
"args": ["test"]
}
}
}
});
let deps = process_output_refs(&mut value);
assert!(deps.is_empty());
assert!(value["tasks"]["check"]["lint"].get("stdout").is_none());
}
#[test]
fn process_multiple_refs_in_args() {
let mut value = serde_json::json!({
"tasks": {
"a": { "command": "echo", "args": ["hello"] },
"b": { "command": "echo", "args": ["world"] },
"c": {
"command": "echo",
"args": [
{ "cuenvOutputRef": true, "cuenvTask": "a", "cuenvOutput": "stdout" },
{ "cuenvOutputRef": true, "cuenvTask": "b", "cuenvOutput": "stdout" }
]
}
}
});
let deps = process_output_refs(&mut value);
assert_eq!(deps.len(), 2);
assert!(deps.contains(&("c".to_string(), "a".to_string())));
assert!(deps.contains(&("c".to_string(), "b".to_string())));
}
#[test]
fn process_refs_in_both_args_and_env() {
let mut value = serde_json::json!({
"tasks": {
"src": { "command": "echo", "args": ["data"] },
"dst": {
"command": "echo",
"args": [
{ "cuenvOutputRef": true, "cuenvTask": "src", "cuenvOutput": "stdout" }
],
"env": {
"DATA": { "cuenvOutputRef": true, "cuenvTask": "src", "cuenvOutput": "stderr" }
}
}
}
});
let deps = process_output_refs(&mut value);
assert_eq!(deps.len(), 2);
}
fn make_result(name: &str, stdout: &str, stderr: &str, exit_code: i32) -> TaskResult {
TaskResult {
name: name.to_string(),
stdout: stdout.to_string(),
stderr: stderr.to_string(),
exit_code: Some(exit_code),
success: exit_code == 0,
}
}
fn resolver(results: &HashMap<String, TaskResult>) -> OutputRefResolver<'_> {
OutputRefResolver {
task_name: "work",
results,
}
}
#[test]
fn resolve_stdout_in_args() {
let mut args = vec!["cuenv:ref:tmpdir:stdout".to_string()];
let mut env = HashMap::new();
let mut results = HashMap::new();
results.insert(
"tmpdir".to_string(),
make_result("tmpdir", "/tmp/abc\n", "", 0),
);
resolver(&results).resolve(&mut args, &mut env).unwrap();
assert_eq!(args[0], "/tmp/abc"); }
#[test]
fn resolve_stderr_in_env() {
let mut args = Vec::new();
let mut env = HashMap::new();
env.insert(
"ERR".to_string(),
serde_json::Value::String("cuenv:ref:check:stderr".to_string()),
);
let mut results = HashMap::new();
results.insert(
"check".to_string(),
make_result("check", "", " warning \n", 0),
);
resolver(&results).resolve(&mut args, &mut env).unwrap();
assert_eq!(env["ERR"].as_str().unwrap(), "warning");
}
#[test]
fn resolve_non_ref_strings_unchanged() {
let mut args = vec!["hello".to_string(), "--flag".to_string()];
let mut env = HashMap::new();
env.insert(
"FOO".to_string(),
serde_json::Value::String("bar".to_string()),
);
let results = HashMap::new();
resolver(&results).resolve(&mut args, &mut env).unwrap();
assert_eq!(args, vec!["hello", "--flag"]);
assert_eq!(env["FOO"].as_str().unwrap(), "bar");
}
#[test]
fn resolve_missing_task_errors() {
let mut args = vec!["cuenv:ref:nonexistent:stdout".to_string()];
let mut env = HashMap::new();
let results = HashMap::new();
let err = resolver(&results).resolve(&mut args, &mut env).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("nonexistent"));
assert!(msg.contains("not completed"));
}
#[test]
fn resolve_failed_task_errors() {
let mut args = vec!["cuenv:ref:failing:stdout".to_string()];
let mut env = HashMap::new();
let mut results = HashMap::new();
results.insert(
"failing".to_string(),
make_result("failing", "", "error!", 1),
);
let err = resolver(&results).resolve(&mut args, &mut env).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("failing") || msg.contains("failed"));
}
#[test]
fn resolve_exit_code_in_args_errors() {
let mut args = vec!["cuenv:ref:check:exitCode".to_string()];
let mut env = HashMap::new();
let mut results = HashMap::new();
results.insert("check".to_string(), make_result("check", "", "", 0));
let err = resolver(&results).resolve(&mut args, &mut env).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("exitCode"));
assert!(msg.contains("integer"));
}
#[test]
fn resolve_empty_stdout() {
let mut args = vec!["cuenv:ref:quiet:stdout".to_string()];
let mut env = HashMap::new();
let mut results = HashMap::new();
results.insert("quiet".to_string(), make_result("quiet", "", "", 0));
resolver(&results).resolve(&mut args, &mut env).unwrap();
assert_eq!(args[0], ""); }
#[test]
fn resolve_trimming_behavior() {
let mut args = vec!["cuenv:ref:padded:stdout".to_string()];
let mut env = HashMap::new();
let mut results = HashMap::new();
results.insert(
"padded".to_string(),
make_result("padded", " hello world \n\n", "", 0),
);
resolver(&results).resolve(&mut args, &mut env).unwrap();
assert_eq!(args[0], "hello world");
}
}