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 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub job_id: Option<u64>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct BinaryNotText {
121 pub len: usize,
123}
124
125impl std::fmt::Display for BinaryNotText {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 write!(
128 f,
129 "output is binary ({} bytes), not text — pipe through base64/xxd or redirect to a file",
130 self.len
131 )
132 }
133}
134
135impl std::error::Error for BinaryNotText {}
136
137#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
152#[non_exhaustive]
153pub struct ExecResult {
154 pub code: i64,
156 out: OutputPayload,
158 pub err: String,
160 pub data: Option<Value>,
163 output: Option<Box<OutputData>>,
173 pub did_spill: bool,
178 #[serde(skip_serializing_if = "Option::is_none")]
181 pub original_code: Option<i64>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub content_type: Option<String>,
186 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191 pub baggage: BTreeMap<String, String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub latch: Option<Box<LatchRequest>>,
206}
207
208impl ExecResult {
209 pub fn success(out: impl Into<String>) -> Self {
211 Self {
212 code: 0,
213 out: OutputPayload::Text(out.into()),
214 err: String::new(),
215 data: None,
216 output: None,
217 did_spill: false,
218 original_code: None,
219 content_type: None,
220 baggage: BTreeMap::new(),
221 latch: None,
222 }
223 }
224
225 pub fn with_output(output: OutputData) -> Self {
230 match output.into_text() {
233 Ok(text) => Self::success(text),
234 Err(output) => Self {
235 code: 0,
236 out: OutputPayload::Text(String::new()),
237 err: String::new(),
238 data: None,
239 output: Some(Box::new(output)),
240 did_spill: false,
241 original_code: None,
242 content_type: None,
243 baggage: BTreeMap::new(),
244 latch: None,
245 },
246 }
247 }
248
249 pub fn success_bytes(bytes: Vec<u8>) -> Self {
251 let mut r = Self::success("");
252 r.out = OutputPayload::Bytes(bytes);
253 r
254 }
255
256 pub fn success_text_or_bytes(bytes: Vec<u8>) -> Self {
262 match String::from_utf8(bytes) {
263 Ok(text) => Self::success(text),
264 Err(e) => Self::success_bytes(e.into_bytes()),
265 }
266 }
267
268 pub fn success_data(data: Value) -> Self {
270 let out = value_to_json(&data).to_string();
271 Self {
272 code: 0,
273 out: OutputPayload::Text(out),
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 success_with_data(out: impl Into<String>, data: Value) -> Self {
294 Self {
295 code: 0,
296 out: OutputPayload::Text(out.into()),
297 err: String::new(),
298 data: Some(data),
299 output: None,
300 did_spill: false,
301 original_code: None,
302 content_type: None,
303 baggage: BTreeMap::new(),
304 latch: None,
305 }
306 }
307
308 pub fn failure(code: i64, err: impl Into<String>) -> Self {
310 Self {
311 code,
312 out: OutputPayload::Text(String::new()),
313 err: err.into(),
314 data: None,
315 output: None,
316 did_spill: false,
317 original_code: None,
318 content_type: None,
319 baggage: BTreeMap::new(),
320 latch: None,
321 }
322 }
323
324 pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
330 Self {
331 code,
332 out: OutputPayload::Text(stdout.into()),
333 err: stderr.into(),
334 data: None,
335 output: None,
336 did_spill: false,
337 original_code: None,
338 content_type: None,
339 baggage: BTreeMap::new(),
340 latch: None,
341 }
342 }
343
344 pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
349 Self {
350 code: 0,
351 out: OutputPayload::Text(text.into()),
352 err: String::new(),
353 data: None,
354 output: Some(Box::new(output)),
355 did_spill: false,
356 original_code: None,
357 content_type: None,
358 baggage: BTreeMap::new(),
359 latch: None,
360 }
361 }
362
363 pub fn from_parts(
365 code: i64,
366 out: String,
367 err: String,
368 data: Option<Value>,
369 ) -> Self {
370 Self {
371 code,
372 out: OutputPayload::Text(out),
373 err,
374 data,
375 output: None,
376 did_spill: false,
377 original_code: None,
378 content_type: None,
379 baggage: BTreeMap::new(),
380 latch: None,
381 }
382 }
383
384 pub fn with_code(mut self, code: i64) -> Self {
386 self.code = code;
387 self
388 }
389
390 pub fn text_out(&self) -> Cow<'_, str> {
405 match &self.out {
406 OutputPayload::Text(s) if !s.is_empty() => Cow::Borrowed(s),
407 OutputPayload::Bytes(b) => match std::str::from_utf8(b) {
408 Ok(s) => Cow::Borrowed(s),
409 Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
410 },
411 _ => match self.output {
413 Some(ref output) => Cow::Owned(output.to_canonical_string()),
414 None => Cow::Borrowed(""),
415 },
416 }
417 }
418
419 pub fn try_text_out(&self) -> Result<Cow<'_, str>, BinaryNotText> {
424 match &self.out {
425 OutputPayload::Bytes(b) => std::str::from_utf8(b)
426 .map(Cow::Borrowed)
427 .map_err(|_| BinaryNotText { len: b.len() }),
428 _ => Ok(self.text_out()),
429 }
430 }
431
432 pub fn out_bytes(&self) -> Option<&[u8]> {
434 match &self.out {
435 OutputPayload::Bytes(b) => Some(b),
436 OutputPayload::Text(_) => None,
437 }
438 }
439
440 pub fn is_bytes(&self) -> bool {
442 matches!(self.out, OutputPayload::Bytes(_))
443 }
444
445 pub fn output(&self) -> Option<&OutputData> {
447 self.output.as_deref()
448 }
449
450 pub fn has_output(&self) -> bool {
452 self.output.is_some()
453 }
454
455 pub fn set_out(&mut self, s: String) {
459 self.out = OutputPayload::Text(s);
460 }
461
462 pub fn set_out_bytes(&mut self, b: Vec<u8>) {
464 self.out = OutputPayload::Bytes(b);
465 }
466
467 pub fn push_out(&mut self, s: &str) {
469 match &mut self.out {
470 OutputPayload::Text(t) => t.push_str(s),
471 OutputPayload::Bytes(b) => b.extend_from_slice(s.as_bytes()),
472 }
473 }
474
475 pub fn clear_out(&mut self) {
477 self.out = OutputPayload::Text(String::new());
478 }
479
480 pub fn clear_stdout(&mut self) {
494 self.out = OutputPayload::Text(String::new());
495 self.output = None;
496 self.data = None;
497 }
498
499 pub fn set_output(&mut self, o: Option<OutputData>) {
501 self.output = o.map(Box::new);
502 }
503
504 pub fn take_output(&mut self) -> Option<OutputData> {
506 self.output.take().map(|o| *o)
507 }
508
509 pub fn materialize(&mut self) {
512 if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
513 if let Some(ref output) = self.output {
514 self.out = OutputPayload::Text(output.to_canonical_string());
515 }
516 }
517 self.output = None;
518 }
519
520 pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
523 if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
524 self.output.take().map(|o| *o)
525 } else {
526 None
527 }
528 }
529
530 pub fn ok(&self) -> bool {
532 self.code == 0
533 }
534
535 pub fn latch_request(&self) -> Option<LatchRequest> {
545 self.latch.as_deref().cloned()
546 }
547
548 pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
550 self.content_type = Some(ct.into());
551 self
552 }
553
554}
555
556pub fn json_to_value(json: serde_json::Value) -> Value {
561 match json {
562 serde_json::Value::Null => Value::Null,
563 serde_json::Value::Bool(b) => Value::Bool(b),
564 serde_json::Value::Number(n) => {
565 if let Some(i) = n.as_i64() {
566 Value::Int(i)
567 } else if let Some(f) = n.as_f64() {
568 Value::Float(f)
569 } else {
570 Value::String(n.to_string())
571 }
572 }
573 serde_json::Value::String(s) => Value::String(s),
574 serde_json::Value::Object(_) => match crate::bytes::envelope_to_bytes(&json) {
577 Some(bytes) => Value::Bytes(bytes),
578 None => Value::Json(json),
579 },
580 serde_json::Value::Array(_) => Value::Json(json),
581 }
582}
583
584pub fn json_to_value_no_envelope(json: serde_json::Value) -> Value {
594 match json {
595 serde_json::Value::Null => Value::Null,
596 serde_json::Value::Bool(b) => Value::Bool(b),
597 serde_json::Value::Number(n) => {
598 if let Some(i) = n.as_i64() {
599 Value::Int(i)
600 } else if let Some(f) = n.as_f64() {
601 Value::Float(f)
602 } else {
603 Value::String(n.to_string())
604 }
605 }
606 serde_json::Value::String(s) => Value::String(s),
607 serde_json::Value::Object(_) | serde_json::Value::Array(_) => Value::Json(json),
610 }
611}
612
613pub fn value_to_json(value: &Value) -> serde_json::Value {
615 match value {
616 Value::Null => serde_json::Value::Null,
617 Value::Bool(b) => serde_json::Value::Bool(*b),
618 Value::Int(i) => serde_json::Value::Number((*i).into()),
619 Value::Float(f) => {
620 serde_json::Number::from_f64(*f)
624 .map(serde_json::Value::Number)
625 .unwrap_or_else(|| serde_json::Value::String(f.to_string()))
626 }
627 Value::String(s) => serde_json::Value::String(s.clone()),
628 Value::Json(json) => json.clone(),
629 Value::Bytes(data) => crate::bytes::bytes_to_envelope(data),
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn success_creates_ok_result() {
639 let result = ExecResult::success("hello world");
640 assert!(result.ok());
641 assert_eq!(result.code, 0);
642 assert_eq!(&*result.text_out(),"hello world");
643 assert!(result.err.is_empty());
644 }
645
646 #[test]
647 fn value_to_json_finite_float_is_number() {
648 assert_eq!(value_to_json(&Value::Float(3.5)), serde_json::json!(3.5));
649 }
650
651 #[test]
652 fn value_to_json_non_finite_float_serializes_to_string() {
653 assert_eq!(value_to_json(&Value::Float(f64::NAN)), serde_json::json!("NaN"));
655 assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), serde_json::json!("inf"));
656 assert_eq!(
657 value_to_json(&Value::Float(f64::NEG_INFINITY)),
658 serde_json::json!("-inf")
659 );
660 assert_ne!(value_to_json(&Value::Float(f64::NAN)), serde_json::Value::Null);
662 }
663
664 #[test]
665 fn failure_creates_non_ok_result() {
666 let result = ExecResult::failure(1, "command not found");
667 assert!(!result.ok());
668 assert_eq!(result.code, 1);
669 assert_eq!(result.err, "command not found");
670 }
671
672 #[test]
673 fn success_does_not_sniff_json_stdout() {
674 let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
677 assert!(result.data.is_none());
678 assert_eq!(&*result.text_out(),r#"{"count": 42, "items": ["a", "b"]}"#);
679 }
680
681 #[test]
682 fn from_output_does_not_sniff_json_stdout() {
683 let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
684 assert!(result.data.is_none());
685 assert_eq!(&*result.text_out(),"[1, 2, 3]");
686 }
687
688 #[test]
689 fn non_json_stdout_has_no_data() {
690 let result = ExecResult::success("just plain text");
691 assert!(result.data.is_none());
692 }
693
694 #[test]
695 fn success_data_creates_result_with_value() {
696 let value = Value::String("test data".into());
697 let result = ExecResult::success_data(value.clone());
698 assert!(result.ok());
699 assert_eq!(result.data, Some(value));
700 }
701
702 #[test]
703 fn did_spill_defaults_to_false() {
704 assert!(!ExecResult::success("hi").did_spill);
705 assert!(!ExecResult::failure(1, "err").did_spill);
706 assert!(!ExecResult::from_output(0, "out", "err").did_spill);
707 }
708
709 #[test]
710 fn did_spill_is_serialized() {
711 let mut result = ExecResult::success("hi");
712 result.did_spill = true;
713 let json = serde_json::to_string(&result).unwrap();
714 assert!(json.contains("\"did_spill\":true"));
715 }
716
717 #[test]
718 fn original_code_omitted_when_none() {
719 let result = ExecResult::success("hi");
720 let json = serde_json::to_string(&result).unwrap();
721 assert!(!json.contains("original_code"));
722 }
723
724 #[test]
725 fn original_code_present_when_set() {
726 let mut result = ExecResult::success("hi");
727 result.original_code = Some(0);
728 let json = serde_json::to_string(&result).unwrap();
729 assert!(json.contains("\"original_code\":0"));
730 }
731
732 #[test]
733 fn default_is_empty_success() {
734 let result = ExecResult::default();
735 assert!(result.ok());
736 assert!(result.text_out().is_empty());
737 assert!(result.data.is_none());
738 assert!(result.content_type.is_none());
739 assert!(result.baggage.is_empty());
740 }
741
742 #[test]
743 fn from_parts_creates_result() {
744 let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
745 assert_eq!(result.code, 42);
746 assert_eq!(&*result.text_out(),"out");
747 assert_eq!(result.err, "err");
748 assert!(result.data.is_none());
749 assert!(result.output.is_none());
750 }
751
752 #[test]
753 fn with_code_sets_code() {
754 let result = ExecResult::success("hi").with_code(42);
755 assert_eq!(result.code, 42);
756 assert_eq!(&*result.text_out(),"hi");
757 }
758
759 #[test]
760 fn output_getter() {
761 use crate::output::{OutputData, OutputNode};
762 let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
764 let result = ExecResult::with_output(nodes);
765 assert!(result.output().is_some());
766 assert!(result.has_output());
767
768 let text_result = ExecResult::with_output(OutputData::text("test"));
770 assert!(!text_result.has_output());
771 assert_eq!(&*text_result.text_out(), "test");
772
773 let plain = ExecResult::success("text");
774 assert!(plain.output().is_none());
775 assert!(!plain.has_output());
776 }
777
778 #[test]
779 fn set_out_and_push_out_and_clear_out() {
780 let mut result = ExecResult::success("");
781 result.set_out("hello".into());
782 assert_eq!(&*result.text_out(),"hello");
783 result.push_out(" world");
784 assert_eq!(&*result.text_out(),"hello world");
785 result.clear_out();
786 assert!(result.text_out().is_empty());
787 }
788
789 #[test]
790 fn set_output_and_take_output() {
791 use crate::output::OutputData;
792 let mut result = ExecResult::success("");
793 assert!(result.take_output().is_none());
794
795 result.set_output(Some(OutputData::text("data")));
796 assert!(result.has_output());
797
798 let taken = result.take_output();
799 assert!(taken.is_some());
800 assert!(!result.has_output());
801 }
802
803 #[test]
804 fn materialize_populates_out_from_output() {
805 use crate::output::{OutputData, OutputNode};
806 let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
808 let mut result = ExecResult::with_output(nodes);
809 assert!(matches!(&result.out, OutputPayload::Text(s) if s.is_empty()));
812 assert!(result.has_output());
813 result.materialize();
814 assert_eq!(&*result.text_out(),"a\nb");
815 assert!(result.output.is_none());
816 }
817
818 #[test]
819 fn value_bytes_round_trips_through_envelope() {
820 let v = Value::Bytes(vec![0u8, 1, 2, 255, 128]);
821 let json = value_to_json(&v);
822 assert_eq!(json["_type"], "bytes");
823 assert_eq!(json["len"], 5);
824 assert_eq!(json_to_value(json), v);
826 let obj = serde_json::json!({"name": "amy"});
828 assert!(matches!(json_to_value(obj), Value::Json(_)));
829 }
830
831 #[test]
832 fn no_envelope_never_decodes_bytes() {
833 let envelope = crate::bytes::bytes_to_envelope(&[1u8, 2, 3]);
837 assert!(matches!(json_to_value(envelope.clone()), Value::Bytes(_)));
839 assert!(matches!(
841 json_to_value_no_envelope(envelope),
842 Value::Json(serde_json::Value::Object(_))
843 ));
844 }
845
846 #[test]
847 fn no_envelope_shares_unwrap_law_for_scalars() {
848 assert_eq!(json_to_value_no_envelope(serde_json::json!(42)), Value::Int(42));
850 assert_eq!(json_to_value_no_envelope(serde_json::json!(1.5)), Value::Float(1.5));
851 assert_eq!(json_to_value_no_envelope(serde_json::json!(true)), Value::Bool(true));
852 assert_eq!(json_to_value_no_envelope(serde_json::json!("hi")), Value::String("hi".into()));
853 assert_eq!(json_to_value_no_envelope(serde_json::json!(null)), Value::Null);
854 assert!(matches!(
855 json_to_value_no_envelope(serde_json::json!([1, 2])),
856 Value::Json(serde_json::Value::Array(_))
857 ));
858 }
859
860 #[test]
861 fn output_payload_text_serializes_as_bare_string() {
862 let r = ExecResult::success("hello");
865 let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
866 assert_eq!(json["out"], "hello");
867 let back: ExecResult = serde_json::from_value(json).unwrap();
869 assert_eq!(&*back.text_out(), "hello");
870 assert!(!back.is_bytes());
871 }
872
873 #[test]
874 fn success_bytes_carries_binary_and_round_trips() {
875 let r = ExecResult::success_bytes(vec![0u8, 159, 146, 150]); assert!(r.is_bytes());
877 assert_eq!(r.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
878 assert!(r.try_text_out().is_err());
880 assert!(r.text_out().contains('\u{fffd}'));
882 let json: serde_json::Value = serde_json::to_value(&r).unwrap();
884 assert_eq!(json["out"]["_type"], "bytes");
885 let back: ExecResult = serde_json::from_value(json).unwrap();
886 assert_eq!(back.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
887 }
888
889 #[test]
890 fn valid_utf8_bytes_coerce_to_text() {
891 let r = ExecResult::success_bytes(b"plain text".to_vec());
892 assert!(r.is_bytes());
893 assert_eq!(r.try_text_out().unwrap(), "plain text");
894 assert_eq!(&*r.text_out(), "plain text");
895 }
896
897 #[test]
898 fn materialize_preserves_existing_out() {
899 use crate::output::OutputData;
900 let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
901 result.materialize();
902 assert_eq!(&*result.text_out(),"custom");
903 }
904
905 #[test]
906 fn take_output_for_stream_when_out_empty() {
907 use crate::output::{OutputData, OutputNode};
908 let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
910 let mut result = ExecResult::with_output(nodes);
911 let taken = result.take_output_for_stream();
912 assert!(taken.is_some());
913 assert!(!result.has_output());
914 }
915
916 #[test]
917 fn with_output_simple_text_populates_out_directly() {
918 use crate::output::OutputData;
919 let result = ExecResult::with_output(OutputData::text("hello"));
920 assert!(!result.has_output());
922 assert_eq!(&*result.text_out(), "hello");
923 let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
925 assert!(json_result.data.is_none());
926 }
927
928 fn latch_req(paths: &[&str]) -> LatchRequest {
929 LatchRequest {
930 nonce: "a3f7b2c1".to_string(),
931 command: "rm".to_string(),
932 paths: paths.iter().map(|p| (*p).to_string()).collect(),
933 hint: "rm --confirm=\"a3f7b2c1\" important.dat".to_string(),
934 tool: "rm".to_string(),
935 argv: paths.iter().map(|p| (*p).to_string()).collect(),
936 ttl: 60,
937 job_id: None,
938 }
939 }
940
941 #[test]
942 fn latch_request_reads_the_latch_field() {
943 let mut result = ExecResult::failure(2, "rm: confirmation required (latch enabled)");
944 result.latch = Some(Box::new(latch_req(&["important.dat"])));
945
946 let req = result.latch_request().expect("a latch request");
947 assert_eq!(req.nonce, "a3f7b2c1");
948 assert_eq!(req.command, "rm");
949 assert_eq!(req.paths, vec!["important.dat".to_string()]);
950 assert_eq!(req.ttl, 60);
951 assert!(req.hint.contains("--confirm"));
952 }
953
954 #[test]
955 fn latch_request_handles_command_only_empty_paths() {
956 let mut result = ExecResult::failure(2, "kaish-trash empty: confirmation required");
957 result.latch = Some(Box::new(LatchRequest {
958 nonce: "deadbeef".to_string(),
959 command: "kaish-trash empty".to_string(),
960 paths: vec![],
961 hint: "kaish-trash empty --confirm=deadbeef".to_string(),
962 tool: "kaish-trash".to_string(),
963 argv: vec!["--".to_string(), "empty".to_string()],
964 ttl: 60,
965 job_id: None,
966 }));
967
968 let req = result.latch_request().expect("a latch request");
969 assert_eq!(req.command, "kaish-trash empty");
970 assert!(req.paths.is_empty());
971 }
972
973 #[test]
974 fn latch_request_none_when_no_latch_set() {
975 assert!(ExecResult::success("").latch_request().is_none());
977 assert!(ExecResult::failure(2, "rm: unknown flag --bogus")
978 .latch_request()
979 .is_none());
980 }
981
982 #[test]
983 fn latch_request_ignores_data_plane_data() {
984 let mut result = ExecResult::failure(2, "boom");
987 result.data = Some(Value::Json(serde_json::json!({"count": 3})));
988 assert!(result.latch_request().is_none());
989 }
990
991 #[test]
992 fn clear_stdout_drops_data_but_never_the_latch() {
993 let mut result = ExecResult::success_data(Value::Json(serde_json::json!([1, 2, 3])));
997 result.latch = Some(Box::new(latch_req(&["precious.txt"])));
998 result.clear_stdout();
999 assert!(result.data.is_none(), "data-plane .data must clear");
1000 assert!(
1001 result.latch_request().is_some(),
1002 "control-plane latch must survive a stdout redirect"
1003 );
1004 }
1005
1006 #[test]
1007 fn take_output_for_stream_when_out_populated() {
1008 use crate::output::OutputData;
1009 let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
1010 let taken = result.take_output_for_stream();
1011 assert!(taken.is_none());
1012 assert!(result.has_output()); }
1014}