1use super::TaskResult;
15use crate::{Error, Result};
16use std::collections::HashMap;
17
18const OUTPUT_REF_PREFIX: &str = "cuenv:ref:";
21
22const IMAGE_REF_PREFIX: &str = "cuenv:image-ref:";
25
26const PASSTHROUGH_PREFIX: &str = "cuenv:passthrough:";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TaskOutputField {
33 Stdout,
34 Stderr,
35 ExitCode,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TaskOutputRef {
41 pub task: String,
43 pub output: TaskOutputField,
45}
46
47impl TaskOutputRef {
48 #[must_use]
51 pub fn parse(s: &str) -> Option<Self> {
52 let rest = s.strip_prefix(OUTPUT_REF_PREFIX)?;
53 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 #[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
90pub type OutputRefDep = (String, String);
92
93pub 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
112fn 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 let is_task = obj.contains_key("command") || obj.contains_key("script");
122
123 if is_task {
124 obj.remove("stdout");
126 obj.remove("stderr");
127 obj.remove("exitCode");
128
129 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 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 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 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 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 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
218fn 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#[must_use]
240pub fn parse_passthrough(s: &str) -> Option<&str> {
241 s.strip_prefix(PASSTHROUGH_PREFIX)
242}
243
244fn try_extract_output_ref(value: &serde_json::Value) -> Option<String> {
247 let obj = value.as_object()?;
248
249 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 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 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#[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
296pub struct OutputRefResolver<'a> {
298 pub task_name: &'a str,
300 pub results: &'a HashMap<String, TaskResult>,
302}
303
304impl<'a> OutputRefResolver<'a> {
305 pub fn resolve(
317 &self,
318 args: &mut [String],
319 env: &mut HashMap<String, serde_json::Value>,
320 ) -> Result<()> {
321 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 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
341fn 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 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 #[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 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 #[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 #[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 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 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 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 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 assert_eq!(deps.len(), 2);
671 }
672
673 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"); }
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], ""); }
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}