1use std::borrow::Cow;
6use std::collections::BTreeMap;
7
8use crate::output::OutputData;
9use crate::value::Value;
10
11#[derive(Debug, Clone, PartialEq)]
19pub enum OutputPayload {
20 Text(String),
22 Bytes(Vec<u8>),
25}
26
27impl Default for OutputPayload {
28 fn default() -> Self {
29 OutputPayload::Text(String::new())
30 }
31}
32
33impl serde::Serialize for OutputPayload {
34 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
35 match self {
36 OutputPayload::Text(t) => serializer.serialize_str(t),
38 OutputPayload::Bytes(b) => crate::bytes::bytes_to_envelope(b).serialize(serializer),
39 }
40 }
41}
42
43impl<'de> serde::Deserialize<'de> for OutputPayload {
44 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45 let v = serde_json::Value::deserialize(deserializer)?;
46 match v {
47 serde_json::Value::String(s) => Ok(OutputPayload::Text(s)),
48 other => match crate::bytes::envelope_to_bytes(&other) {
49 Some(b) => Ok(OutputPayload::Bytes(b)),
50 None => Err(serde::de::Error::custom(
51 "ExecResult.out: expected a string or a base64 bytes envelope",
52 )),
53 },
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct LatchRequest {
75 pub nonce: String,
77 pub command: String,
80 pub paths: Vec<String>,
82 pub hint: String,
87 #[serde(default)]
92 pub tool: String,
93 #[serde(default)]
98 pub argv: Vec<String>,
99 pub ttl: u64,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct BinaryNotText {
106 pub len: usize,
108}
109
110impl std::fmt::Display for BinaryNotText {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 write!(
113 f,
114 "output is binary ({} bytes), not text — pipe through base64/xxd or redirect to a file",
115 self.len
116 )
117 }
118}
119
120impl std::error::Error for BinaryNotText {}
121
122#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
137#[non_exhaustive]
138pub struct ExecResult {
139 pub code: i64,
141 out: OutputPayload,
143 pub err: String,
145 pub data: Option<Value>,
148 output: Option<OutputData>,
150 pub did_spill: bool,
155 #[serde(skip_serializing_if = "Option::is_none")]
158 pub original_code: Option<i64>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub content_type: Option<String>,
163 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
168 pub baggage: BTreeMap<String, String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub latch: Option<Box<LatchRequest>>,
183}
184
185impl ExecResult {
186 pub fn success(out: impl Into<String>) -> Self {
188 Self {
189 code: 0,
190 out: OutputPayload::Text(out.into()),
191 err: String::new(),
192 data: None,
193 output: None,
194 did_spill: false,
195 original_code: None,
196 content_type: None,
197 baggage: BTreeMap::new(),
198 latch: None,
199 }
200 }
201
202 pub fn with_output(output: OutputData) -> Self {
207 match output.into_text() {
210 Ok(text) => Self::success(text),
211 Err(output) => Self {
212 code: 0,
213 out: OutputPayload::Text(String::new()),
214 err: String::new(),
215 data: None,
216 output: Some(output),
217 did_spill: false,
218 original_code: None,
219 content_type: None,
220 baggage: BTreeMap::new(),
221 latch: None,
222 },
223 }
224 }
225
226 pub fn success_bytes(bytes: Vec<u8>) -> Self {
228 let mut r = Self::success("");
229 r.out = OutputPayload::Bytes(bytes);
230 r
231 }
232
233 pub fn success_text_or_bytes(bytes: Vec<u8>) -> Self {
239 match String::from_utf8(bytes) {
240 Ok(text) => Self::success(text),
241 Err(e) => Self::success_bytes(e.into_bytes()),
242 }
243 }
244
245 pub fn success_data(data: Value) -> Self {
247 let out = value_to_json(&data).to_string();
248 Self {
249 code: 0,
250 out: OutputPayload::Text(out),
251 err: String::new(),
252 data: Some(data),
253 output: None,
254 did_spill: false,
255 original_code: None,
256 content_type: None,
257 baggage: BTreeMap::new(),
258 latch: None,
259 }
260 }
261
262 pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
271 Self {
272 code: 0,
273 out: OutputPayload::Text(out.into()),
274 err: String::new(),
275 data: Some(data),
276 output: None,
277 did_spill: false,
278 original_code: None,
279 content_type: None,
280 baggage: BTreeMap::new(),
281 latch: None,
282 }
283 }
284
285 pub fn failure(code: i64, err: impl Into<String>) -> Self {
287 Self {
288 code,
289 out: OutputPayload::Text(String::new()),
290 err: err.into(),
291 data: None,
292 output: None,
293 did_spill: false,
294 original_code: None,
295 content_type: None,
296 baggage: BTreeMap::new(),
297 latch: None,
298 }
299 }
300
301 pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
307 Self {
308 code,
309 out: OutputPayload::Text(stdout.into()),
310 err: stderr.into(),
311 data: None,
312 output: None,
313 did_spill: false,
314 original_code: None,
315 content_type: None,
316 baggage: BTreeMap::new(),
317 latch: None,
318 }
319 }
320
321 pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
326 Self {
327 code: 0,
328 out: OutputPayload::Text(text.into()),
329 err: String::new(),
330 data: None,
331 output: Some(output),
332 did_spill: false,
333 original_code: None,
334 content_type: None,
335 baggage: BTreeMap::new(),
336 latch: None,
337 }
338 }
339
340 pub fn from_parts(
342 code: i64,
343 out: String,
344 err: String,
345 data: Option<Value>,
346 ) -> Self {
347 Self {
348 code,
349 out: OutputPayload::Text(out),
350 err,
351 data,
352 output: None,
353 did_spill: false,
354 original_code: None,
355 content_type: None,
356 baggage: BTreeMap::new(),
357 latch: None,
358 }
359 }
360
361 pub fn with_code(mut self, code: i64) -> Self {
363 self.code = code;
364 self
365 }
366
367 pub fn text_out(&self) -> Cow<'_, str> {
382 match &self.out {
383 OutputPayload::Text(s) if !s.is_empty() => Cow::Borrowed(s),
384 OutputPayload::Bytes(b) => match std::str::from_utf8(b) {
385 Ok(s) => Cow::Borrowed(s),
386 Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
387 },
388 _ => match self.output {
390 Some(ref output) => Cow::Owned(output.to_canonical_string()),
391 None => Cow::Borrowed(""),
392 },
393 }
394 }
395
396 pub fn try_text_out(&self) -> Result<Cow<'_, str>, BinaryNotText> {
401 match &self.out {
402 OutputPayload::Bytes(b) => std::str::from_utf8(b)
403 .map(Cow::Borrowed)
404 .map_err(|_| BinaryNotText { len: b.len() }),
405 _ => Ok(self.text_out()),
406 }
407 }
408
409 pub fn out_bytes(&self) -> Option<&[u8]> {
411 match &self.out {
412 OutputPayload::Bytes(b) => Some(b),
413 OutputPayload::Text(_) => None,
414 }
415 }
416
417 pub fn is_bytes(&self) -> bool {
419 matches!(self.out, OutputPayload::Bytes(_))
420 }
421
422 pub fn output(&self) -> Option<&OutputData> {
424 self.output.as_ref()
425 }
426
427 pub fn has_output(&self) -> bool {
429 self.output.is_some()
430 }
431
432 pub fn set_out(&mut self, s: String) {
436 self.out = OutputPayload::Text(s);
437 }
438
439 pub fn set_out_bytes(&mut self, b: Vec<u8>) {
441 self.out = OutputPayload::Bytes(b);
442 }
443
444 pub fn push_out(&mut self, s: &str) {
446 match &mut self.out {
447 OutputPayload::Text(t) => t.push_str(s),
448 OutputPayload::Bytes(b) => b.extend_from_slice(s.as_bytes()),
449 }
450 }
451
452 pub fn clear_out(&mut self) {
454 self.out = OutputPayload::Text(String::new());
455 }
456
457 pub fn clear_stdout(&mut self) {
471 self.out = OutputPayload::Text(String::new());
472 self.output = None;
473 self.data = None;
474 }
475
476 pub fn set_output(&mut self, o: Option<OutputData>) {
478 self.output = o;
479 }
480
481 pub fn take_output(&mut self) -> Option<OutputData> {
483 self.output.take()
484 }
485
486 pub fn materialize(&mut self) {
489 if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
490 if let Some(ref output) = self.output {
491 self.out = OutputPayload::Text(output.to_canonical_string());
492 }
493 }
494 self.output = None;
495 }
496
497 pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
500 if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
501 self.output.take()
502 } else {
503 None
504 }
505 }
506
507 pub fn ok(&self) -> bool {
509 self.code == 0
510 }
511
512 pub fn latch_request(&self) -> Option<LatchRequest> {
522 self.latch.as_deref().cloned()
523 }
524
525 pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
527 self.content_type = Some(ct.into());
528 self
529 }
530
531}
532
533pub fn json_to_value(json: serde_json::Value) -> Value {
538 match json {
539 serde_json::Value::Null => Value::Null,
540 serde_json::Value::Bool(b) => Value::Bool(b),
541 serde_json::Value::Number(n) => {
542 if let Some(i) = n.as_i64() {
543 Value::Int(i)
544 } else if let Some(f) = n.as_f64() {
545 Value::Float(f)
546 } else {
547 Value::String(n.to_string())
548 }
549 }
550 serde_json::Value::String(s) => Value::String(s),
551 serde_json::Value::Object(_) => match crate::bytes::envelope_to_bytes(&json) {
554 Some(bytes) => Value::Bytes(bytes),
555 None => Value::Json(json),
556 },
557 serde_json::Value::Array(_) => Value::Json(json),
558 }
559}
560
561pub fn json_to_value_no_envelope(json: serde_json::Value) -> Value {
571 match json {
572 serde_json::Value::Null => Value::Null,
573 serde_json::Value::Bool(b) => Value::Bool(b),
574 serde_json::Value::Number(n) => {
575 if let Some(i) = n.as_i64() {
576 Value::Int(i)
577 } else if let Some(f) = n.as_f64() {
578 Value::Float(f)
579 } else {
580 Value::String(n.to_string())
581 }
582 }
583 serde_json::Value::String(s) => Value::String(s),
584 serde_json::Value::Object(_) | serde_json::Value::Array(_) => Value::Json(json),
587 }
588}
589
590pub fn value_to_json(value: &Value) -> serde_json::Value {
592 match value {
593 Value::Null => serde_json::Value::Null,
594 Value::Bool(b) => serde_json::Value::Bool(*b),
595 Value::Int(i) => serde_json::Value::Number((*i).into()),
596 Value::Float(f) => {
597 serde_json::Number::from_f64(*f)
601 .map(serde_json::Value::Number)
602 .unwrap_or_else(|| serde_json::Value::String(f.to_string()))
603 }
604 Value::String(s) => serde_json::Value::String(s.clone()),
605 Value::Json(json) => json.clone(),
606 Value::Bytes(data) => crate::bytes::bytes_to_envelope(data),
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn success_creates_ok_result() {
616 let result = ExecResult::success("hello world");
617 assert!(result.ok());
618 assert_eq!(result.code, 0);
619 assert_eq!(&*result.text_out(),"hello world");
620 assert!(result.err.is_empty());
621 }
622
623 #[test]
624 fn value_to_json_finite_float_is_number() {
625 assert_eq!(value_to_json(&Value::Float(3.5)), serde_json::json!(3.5));
626 }
627
628 #[test]
629 fn value_to_json_non_finite_float_serializes_to_string() {
630 assert_eq!(value_to_json(&Value::Float(f64::NAN)), serde_json::json!("NaN"));
632 assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), serde_json::json!("inf"));
633 assert_eq!(
634 value_to_json(&Value::Float(f64::NEG_INFINITY)),
635 serde_json::json!("-inf")
636 );
637 assert_ne!(value_to_json(&Value::Float(f64::NAN)), serde_json::Value::Null);
639 }
640
641 #[test]
642 fn failure_creates_non_ok_result() {
643 let result = ExecResult::failure(1, "command not found");
644 assert!(!result.ok());
645 assert_eq!(result.code, 1);
646 assert_eq!(result.err, "command not found");
647 }
648
649 #[test]
650 fn success_does_not_sniff_json_stdout() {
651 let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
654 assert!(result.data.is_none());
655 assert_eq!(&*result.text_out(),r#"{"count": 42, "items": ["a", "b"]}"#);
656 }
657
658 #[test]
659 fn from_output_does_not_sniff_json_stdout() {
660 let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
661 assert!(result.data.is_none());
662 assert_eq!(&*result.text_out(),"[1, 2, 3]");
663 }
664
665 #[test]
666 fn non_json_stdout_has_no_data() {
667 let result = ExecResult::success("just plain text");
668 assert!(result.data.is_none());
669 }
670
671 #[test]
672 fn success_data_creates_result_with_value() {
673 let value = Value::String("test data".into());
674 let result = ExecResult::success_data(value.clone());
675 assert!(result.ok());
676 assert_eq!(result.data, Some(value));
677 }
678
679 #[test]
680 fn did_spill_defaults_to_false() {
681 assert!(!ExecResult::success("hi").did_spill);
682 assert!(!ExecResult::failure(1, "err").did_spill);
683 assert!(!ExecResult::from_output(0, "out", "err").did_spill);
684 }
685
686 #[test]
687 fn did_spill_is_serialized() {
688 let mut result = ExecResult::success("hi");
689 result.did_spill = true;
690 let json = serde_json::to_string(&result).unwrap();
691 assert!(json.contains("\"did_spill\":true"));
692 }
693
694 #[test]
695 fn original_code_omitted_when_none() {
696 let result = ExecResult::success("hi");
697 let json = serde_json::to_string(&result).unwrap();
698 assert!(!json.contains("original_code"));
699 }
700
701 #[test]
702 fn original_code_present_when_set() {
703 let mut result = ExecResult::success("hi");
704 result.original_code = Some(0);
705 let json = serde_json::to_string(&result).unwrap();
706 assert!(json.contains("\"original_code\":0"));
707 }
708
709 #[test]
710 fn default_is_empty_success() {
711 let result = ExecResult::default();
712 assert!(result.ok());
713 assert!(result.text_out().is_empty());
714 assert!(result.data.is_none());
715 assert!(result.content_type.is_none());
716 assert!(result.baggage.is_empty());
717 }
718
719 #[test]
720 fn from_parts_creates_result() {
721 let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
722 assert_eq!(result.code, 42);
723 assert_eq!(&*result.text_out(),"out");
724 assert_eq!(result.err, "err");
725 assert!(result.data.is_none());
726 assert!(result.output.is_none());
727 }
728
729 #[test]
730 fn with_code_sets_code() {
731 let result = ExecResult::success("hi").with_code(42);
732 assert_eq!(result.code, 42);
733 assert_eq!(&*result.text_out(),"hi");
734 }
735
736 #[test]
737 fn output_getter() {
738 use crate::output::{OutputData, OutputNode};
739 let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
741 let result = ExecResult::with_output(nodes);
742 assert!(result.output().is_some());
743 assert!(result.has_output());
744
745 let text_result = ExecResult::with_output(OutputData::text("test"));
747 assert!(!text_result.has_output());
748 assert_eq!(&*text_result.text_out(), "test");
749
750 let plain = ExecResult::success("text");
751 assert!(plain.output().is_none());
752 assert!(!plain.has_output());
753 }
754
755 #[test]
756 fn set_out_and_push_out_and_clear_out() {
757 let mut result = ExecResult::success("");
758 result.set_out("hello".into());
759 assert_eq!(&*result.text_out(),"hello");
760 result.push_out(" world");
761 assert_eq!(&*result.text_out(),"hello world");
762 result.clear_out();
763 assert!(result.text_out().is_empty());
764 }
765
766 #[test]
767 fn set_output_and_take_output() {
768 use crate::output::OutputData;
769 let mut result = ExecResult::success("");
770 assert!(result.take_output().is_none());
771
772 result.set_output(Some(OutputData::text("data")));
773 assert!(result.has_output());
774
775 let taken = result.take_output();
776 assert!(taken.is_some());
777 assert!(!result.has_output());
778 }
779
780 #[test]
781 fn materialize_populates_out_from_output() {
782 use crate::output::{OutputData, OutputNode};
783 let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
785 let mut result = ExecResult::with_output(nodes);
786 assert!(matches!(&result.out, OutputPayload::Text(s) if s.is_empty()));
789 assert!(result.has_output());
790 result.materialize();
791 assert_eq!(&*result.text_out(),"a\nb");
792 assert!(result.output.is_none());
793 }
794
795 #[test]
796 fn value_bytes_round_trips_through_envelope() {
797 let v = Value::Bytes(vec![0u8, 1, 2, 255, 128]);
798 let json = value_to_json(&v);
799 assert_eq!(json["_type"], "bytes");
800 assert_eq!(json["len"], 5);
801 assert_eq!(json_to_value(json), v);
803 let obj = serde_json::json!({"name": "amy"});
805 assert!(matches!(json_to_value(obj), Value::Json(_)));
806 }
807
808 #[test]
809 fn no_envelope_never_decodes_bytes() {
810 let envelope = crate::bytes::bytes_to_envelope(&[1u8, 2, 3]);
814 assert!(matches!(json_to_value(envelope.clone()), Value::Bytes(_)));
816 assert!(matches!(
818 json_to_value_no_envelope(envelope),
819 Value::Json(serde_json::Value::Object(_))
820 ));
821 }
822
823 #[test]
824 fn no_envelope_shares_unwrap_law_for_scalars() {
825 assert_eq!(json_to_value_no_envelope(serde_json::json!(42)), Value::Int(42));
827 assert_eq!(json_to_value_no_envelope(serde_json::json!(1.5)), Value::Float(1.5));
828 assert_eq!(json_to_value_no_envelope(serde_json::json!(true)), Value::Bool(true));
829 assert_eq!(json_to_value_no_envelope(serde_json::json!("hi")), Value::String("hi".into()));
830 assert_eq!(json_to_value_no_envelope(serde_json::json!(null)), Value::Null);
831 assert!(matches!(
832 json_to_value_no_envelope(serde_json::json!([1, 2])),
833 Value::Json(serde_json::Value::Array(_))
834 ));
835 }
836
837 #[test]
838 fn output_payload_text_serializes_as_bare_string() {
839 let r = ExecResult::success("hello");
842 let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
843 assert_eq!(json["out"], "hello");
844 let back: ExecResult = serde_json::from_value(json).unwrap();
846 assert_eq!(&*back.text_out(), "hello");
847 assert!(!back.is_bytes());
848 }
849
850 #[test]
851 fn success_bytes_carries_binary_and_round_trips() {
852 let r = ExecResult::success_bytes(vec![0u8, 159, 146, 150]); assert!(r.is_bytes());
854 assert_eq!(r.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
855 assert!(r.try_text_out().is_err());
857 assert!(r.text_out().contains('\u{fffd}'));
859 let json: serde_json::Value = serde_json::to_value(&r).unwrap();
861 assert_eq!(json["out"]["_type"], "bytes");
862 let back: ExecResult = serde_json::from_value(json).unwrap();
863 assert_eq!(back.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
864 }
865
866 #[test]
867 fn valid_utf8_bytes_coerce_to_text() {
868 let r = ExecResult::success_bytes(b"plain text".to_vec());
869 assert!(r.is_bytes());
870 assert_eq!(r.try_text_out().unwrap(), "plain text");
871 assert_eq!(&*r.text_out(), "plain text");
872 }
873
874 #[test]
875 fn materialize_preserves_existing_out() {
876 use crate::output::OutputData;
877 let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
878 result.materialize();
879 assert_eq!(&*result.text_out(),"custom");
880 }
881
882 #[test]
883 fn take_output_for_stream_when_out_empty() {
884 use crate::output::{OutputData, OutputNode};
885 let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
887 let mut result = ExecResult::with_output(nodes);
888 let taken = result.take_output_for_stream();
889 assert!(taken.is_some());
890 assert!(!result.has_output());
891 }
892
893 #[test]
894 fn with_output_simple_text_populates_out_directly() {
895 use crate::output::OutputData;
896 let result = ExecResult::with_output(OutputData::text("hello"));
897 assert!(!result.has_output());
899 assert_eq!(&*result.text_out(), "hello");
900 let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
902 assert!(json_result.data.is_none());
903 }
904
905 fn latch_req(paths: &[&str]) -> LatchRequest {
906 LatchRequest {
907 nonce: "a3f7b2c1".to_string(),
908 command: "rm".to_string(),
909 paths: paths.iter().map(|p| (*p).to_string()).collect(),
910 hint: "rm --confirm=\"a3f7b2c1\" important.dat".to_string(),
911 tool: "rm".to_string(),
912 argv: paths.iter().map(|p| (*p).to_string()).collect(),
913 ttl: 60,
914 }
915 }
916
917 #[test]
918 fn latch_request_reads_the_latch_field() {
919 let mut result = ExecResult::failure(2, "rm: confirmation required (latch enabled)");
920 result.latch = Some(Box::new(latch_req(&["important.dat"])));
921
922 let req = result.latch_request().expect("a latch request");
923 assert_eq!(req.nonce, "a3f7b2c1");
924 assert_eq!(req.command, "rm");
925 assert_eq!(req.paths, vec!["important.dat".to_string()]);
926 assert_eq!(req.ttl, 60);
927 assert!(req.hint.contains("--confirm"));
928 }
929
930 #[test]
931 fn latch_request_handles_command_only_empty_paths() {
932 let mut result = ExecResult::failure(2, "kaish-trash empty: confirmation required");
933 result.latch = Some(Box::new(LatchRequest {
934 nonce: "deadbeef".to_string(),
935 command: "kaish-trash empty".to_string(),
936 paths: vec![],
937 hint: "kaish-trash empty --confirm=deadbeef".to_string(),
938 tool: "kaish-trash".to_string(),
939 argv: vec!["--".to_string(), "empty".to_string()],
940 ttl: 60,
941 }));
942
943 let req = result.latch_request().expect("a latch request");
944 assert_eq!(req.command, "kaish-trash empty");
945 assert!(req.paths.is_empty());
946 }
947
948 #[test]
949 fn latch_request_none_when_no_latch_set() {
950 assert!(ExecResult::success("").latch_request().is_none());
952 assert!(ExecResult::failure(2, "rm: unknown flag --bogus")
953 .latch_request()
954 .is_none());
955 }
956
957 #[test]
958 fn latch_request_ignores_data_plane_data() {
959 let mut result = ExecResult::failure(2, "boom");
962 result.data = Some(Value::Json(serde_json::json!({"count": 3})));
963 assert!(result.latch_request().is_none());
964 }
965
966 #[test]
967 fn clear_stdout_drops_data_but_never_the_latch() {
968 let mut result = ExecResult::success_data(Value::Json(serde_json::json!([1, 2, 3])));
972 result.latch = Some(Box::new(latch_req(&["precious.txt"])));
973 result.clear_stdout();
974 assert!(result.data.is_none(), "data-plane .data must clear");
975 assert!(
976 result.latch_request().is_some(),
977 "control-plane latch must survive a stdout redirect"
978 );
979 }
980
981 #[test]
982 fn take_output_for_stream_when_out_populated() {
983 use crate::output::OutputData;
984 let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
985 let taken = result.take_output_for_stream();
986 assert!(taken.is_none());
987 assert!(result.has_output()); }
989}