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,
181 #[serde(skip_serializing_if = "Option::is_none")]
184 pub original_code: Option<i64>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub content_type: Option<String>,
189 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
194 pub baggage: BTreeMap<String, String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub latch: Option<Box<LatchRequest>>,
209}
210
211impl ExecResult {
212 pub fn success(out: impl Into<String>) -> Self {
214 Self {
215 code: 0,
216 out: OutputPayload::Text(out.into()),
217 err: String::new(),
218 data: None,
219 output: None,
220 did_spill: false,
221 original_code: None,
222 content_type: None,
223 baggage: BTreeMap::new(),
224 latch: None,
225 }
226 }
227
228 pub fn with_output(output: OutputData) -> Self {
233 match output.into_text() {
236 Ok(text) => Self::success(text),
237 Err(output) => Self {
238 code: 0,
239 out: OutputPayload::Text(String::new()),
240 err: String::new(),
241 data: None,
242 output: Some(Box::new(output)),
243 did_spill: false,
244 original_code: None,
245 content_type: None,
246 baggage: BTreeMap::new(),
247 latch: None,
248 },
249 }
250 }
251
252 pub fn success_bytes(bytes: Vec<u8>) -> Self {
254 let mut r = Self::success("");
255 r.out = OutputPayload::Bytes(bytes);
256 r
257 }
258
259 pub fn success_text_or_bytes(bytes: Vec<u8>) -> Self {
265 match String::from_utf8(bytes) {
266 Ok(text) => Self::success(text),
267 Err(e) => Self::success_bytes(e.into_bytes()),
268 }
269 }
270
271 pub fn success_data(data: Value) -> Self {
273 let out = value_to_json(&data).to_string();
274 Self {
275 code: 0,
276 out: OutputPayload::Text(out),
277 err: String::new(),
278 data: Some(data),
279 output: None,
280 did_spill: false,
281 original_code: None,
282 content_type: None,
283 baggage: BTreeMap::new(),
284 latch: None,
285 }
286 }
287
288 pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
297 Self {
298 code: 0,
299 out: OutputPayload::Text(out.into()),
300 err: String::new(),
301 data: Some(data),
302 output: None,
303 did_spill: false,
304 original_code: None,
305 content_type: None,
306 baggage: BTreeMap::new(),
307 latch: None,
308 }
309 }
310
311 pub fn failure(code: i64, err: impl Into<String>) -> Self {
313 Self {
314 code,
315 out: OutputPayload::Text(String::new()),
316 err: err.into(),
317 data: None,
318 output: None,
319 did_spill: false,
320 original_code: None,
321 content_type: None,
322 baggage: BTreeMap::new(),
323 latch: None,
324 }
325 }
326
327 pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
333 Self {
334 code,
335 out: OutputPayload::Text(stdout.into()),
336 err: stderr.into(),
337 data: None,
338 output: None,
339 did_spill: false,
340 original_code: None,
341 content_type: None,
342 baggage: BTreeMap::new(),
343 latch: None,
344 }
345 }
346
347 pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
352 Self {
353 code: 0,
354 out: OutputPayload::Text(text.into()),
355 err: String::new(),
356 data: None,
357 output: Some(Box::new(output)),
358 did_spill: false,
359 original_code: None,
360 content_type: None,
361 baggage: BTreeMap::new(),
362 latch: None,
363 }
364 }
365
366 pub fn from_parts(
368 code: i64,
369 out: String,
370 err: String,
371 data: Option<Value>,
372 ) -> Self {
373 Self {
374 code,
375 out: OutputPayload::Text(out),
376 err,
377 data,
378 output: None,
379 did_spill: false,
380 original_code: None,
381 content_type: None,
382 baggage: BTreeMap::new(),
383 latch: None,
384 }
385 }
386
387 pub fn with_code(mut self, code: i64) -> Self {
389 self.code = code;
390 self
391 }
392
393 pub fn text_out(&self) -> Cow<'_, str> {
408 match &self.out {
409 OutputPayload::Text(s) if !s.is_empty() => Cow::Borrowed(s),
410 OutputPayload::Bytes(b) => match std::str::from_utf8(b) {
411 Ok(s) => Cow::Borrowed(s),
412 Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
413 },
414 _ => match self.output {
416 Some(ref output) => Cow::Owned(output.to_canonical_string()),
417 None => Cow::Borrowed(""),
418 },
419 }
420 }
421
422 pub fn try_text_out(&self) -> Result<Cow<'_, str>, BinaryNotText> {
427 match &self.out {
428 OutputPayload::Bytes(b) => std::str::from_utf8(b)
429 .map(Cow::Borrowed)
430 .map_err(|_| BinaryNotText { len: b.len() }),
431 _ => Ok(self.text_out()),
432 }
433 }
434
435 pub fn out_bytes(&self) -> Option<&[u8]> {
437 match &self.out {
438 OutputPayload::Bytes(b) => Some(b),
439 OutputPayload::Text(_) => None,
440 }
441 }
442
443 pub fn is_bytes(&self) -> bool {
445 matches!(self.out, OutputPayload::Bytes(_))
446 }
447
448 pub fn output(&self) -> Option<&OutputData> {
450 self.output.as_deref()
451 }
452
453 pub fn has_output(&self) -> bool {
455 self.output.is_some()
456 }
457
458 pub fn set_out(&mut self, s: String) {
462 self.out = OutputPayload::Text(s);
463 }
464
465 pub fn set_out_bytes(&mut self, b: Vec<u8>) {
467 self.out = OutputPayload::Bytes(b);
468 }
469
470 pub fn push_out(&mut self, s: &str) {
472 match &mut self.out {
473 OutputPayload::Text(t) => t.push_str(s),
474 OutputPayload::Bytes(b) => b.extend_from_slice(s.as_bytes()),
475 }
476 }
477
478 pub fn clear_out(&mut self) {
480 self.out = OutputPayload::Text(String::new());
481 }
482
483 pub fn clear_stdout(&mut self) {
497 self.out = OutputPayload::Text(String::new());
498 self.output = None;
499 self.data = None;
500 }
501
502 pub fn set_output(&mut self, o: Option<OutputData>) {
504 self.output = o.map(Box::new);
505 }
506
507 pub fn take_output(&mut self) -> Option<OutputData> {
509 self.output.take().map(|o| *o)
510 }
511
512 pub fn materialize(&mut self) {
515 if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
516 if let Some(ref output) = self.output {
517 self.out = OutputPayload::Text(output.to_canonical_string());
518 }
519 }
520 self.output = None;
521 }
522
523 pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
526 if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
527 self.output.take().map(|o| *o)
528 } else {
529 None
530 }
531 }
532
533 pub fn ok(&self) -> bool {
535 self.code == 0
536 }
537
538 pub fn latch_request(&self) -> Option<LatchRequest> {
548 self.latch.as_deref().cloned()
549 }
550
551 pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
553 self.content_type = Some(ct.into());
554 self
555 }
556
557}
558
559pub fn json_to_value(json: serde_json::Value) -> Value {
564 match json {
565 serde_json::Value::Null => Value::Null,
566 serde_json::Value::Bool(b) => Value::Bool(b),
567 serde_json::Value::Number(n) => {
568 if let Some(i) = n.as_i64() {
569 Value::Int(i)
570 } else if let Some(f) = n.as_f64() {
571 Value::Float(f)
572 } else {
573 Value::String(n.to_string())
574 }
575 }
576 serde_json::Value::String(s) => Value::String(s),
577 serde_json::Value::Object(_) => match crate::bytes::envelope_to_bytes(&json) {
580 Some(bytes) => Value::Bytes(bytes),
581 None => Value::Json(json),
582 },
583 serde_json::Value::Array(_) => Value::Json(json),
584 }
585}
586
587pub fn json_to_value_no_envelope(json: serde_json::Value) -> Value {
597 match json {
598 serde_json::Value::Null => Value::Null,
599 serde_json::Value::Bool(b) => Value::Bool(b),
600 serde_json::Value::Number(n) => {
601 if let Some(i) = n.as_i64() {
602 Value::Int(i)
603 } else if let Some(f) = n.as_f64() {
604 Value::Float(f)
605 } else {
606 Value::String(n.to_string())
607 }
608 }
609 serde_json::Value::String(s) => Value::String(s),
610 serde_json::Value::Object(_) | serde_json::Value::Array(_) => Value::Json(json),
613 }
614}
615
616pub fn value_to_json(value: &Value) -> serde_json::Value {
618 match value {
619 Value::Null => serde_json::Value::Null,
620 Value::Bool(b) => serde_json::Value::Bool(*b),
621 Value::Int(i) => serde_json::Value::Number((*i).into()),
622 Value::Float(f) => {
623 serde_json::Number::from_f64(*f)
627 .map(serde_json::Value::Number)
628 .unwrap_or_else(|| serde_json::Value::String(f.to_string()))
629 }
630 Value::String(s) => serde_json::Value::String(s.clone()),
631 Value::Json(json) => json.clone(),
632 Value::Bytes(data) => crate::bytes::bytes_to_envelope(data),
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 #[test]
641 fn success_creates_ok_result() {
642 let result = ExecResult::success("hello world");
643 assert!(result.ok());
644 assert_eq!(result.code, 0);
645 assert_eq!(&*result.text_out(),"hello world");
646 assert!(result.err.is_empty());
647 }
648
649 #[test]
650 fn value_to_json_finite_float_is_number() {
651 assert_eq!(value_to_json(&Value::Float(3.5)), serde_json::json!(3.5));
652 }
653
654 #[test]
655 fn value_to_json_non_finite_float_serializes_to_string() {
656 assert_eq!(value_to_json(&Value::Float(f64::NAN)), serde_json::json!("NaN"));
658 assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), serde_json::json!("inf"));
659 assert_eq!(
660 value_to_json(&Value::Float(f64::NEG_INFINITY)),
661 serde_json::json!("-inf")
662 );
663 assert_ne!(value_to_json(&Value::Float(f64::NAN)), serde_json::Value::Null);
665 }
666
667 #[test]
668 fn failure_creates_non_ok_result() {
669 let result = ExecResult::failure(1, "command not found");
670 assert!(!result.ok());
671 assert_eq!(result.code, 1);
672 assert_eq!(result.err, "command not found");
673 }
674
675 #[test]
676 fn success_does_not_sniff_json_stdout() {
677 let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
680 assert!(result.data.is_none());
681 assert_eq!(&*result.text_out(),r#"{"count": 42, "items": ["a", "b"]}"#);
682 }
683
684 #[test]
685 fn from_output_does_not_sniff_json_stdout() {
686 let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
687 assert!(result.data.is_none());
688 assert_eq!(&*result.text_out(),"[1, 2, 3]");
689 }
690
691 #[test]
692 fn non_json_stdout_has_no_data() {
693 let result = ExecResult::success("just plain text");
694 assert!(result.data.is_none());
695 }
696
697 #[test]
698 fn success_data_creates_result_with_value() {
699 let value = Value::String("test data".into());
700 let result = ExecResult::success_data(value.clone());
701 assert!(result.ok());
702 assert_eq!(result.data, Some(value));
703 }
704
705 #[test]
706 fn did_spill_defaults_to_false() {
707 assert!(!ExecResult::success("hi").did_spill);
708 assert!(!ExecResult::failure(1, "err").did_spill);
709 assert!(!ExecResult::from_output(0, "out", "err").did_spill);
710 }
711
712 #[test]
713 fn did_spill_is_serialized() {
714 let mut result = ExecResult::success("hi");
715 result.did_spill = true;
716 let json = serde_json::to_string(&result).unwrap();
717 assert!(json.contains("\"did_spill\":true"));
718 }
719
720 #[test]
721 fn original_code_omitted_when_none() {
722 let result = ExecResult::success("hi");
723 let json = serde_json::to_string(&result).unwrap();
724 assert!(!json.contains("original_code"));
725 }
726
727 #[test]
728 fn original_code_present_when_set() {
729 let mut result = ExecResult::success("hi");
730 result.original_code = Some(0);
731 let json = serde_json::to_string(&result).unwrap();
732 assert!(json.contains("\"original_code\":0"));
733 }
734
735 #[test]
736 fn default_is_empty_success() {
737 let result = ExecResult::default();
738 assert!(result.ok());
739 assert!(result.text_out().is_empty());
740 assert!(result.data.is_none());
741 assert!(result.content_type.is_none());
742 assert!(result.baggage.is_empty());
743 }
744
745 #[test]
746 fn from_parts_creates_result() {
747 let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
748 assert_eq!(result.code, 42);
749 assert_eq!(&*result.text_out(),"out");
750 assert_eq!(result.err, "err");
751 assert!(result.data.is_none());
752 assert!(result.output.is_none());
753 }
754
755 #[test]
756 fn with_code_sets_code() {
757 let result = ExecResult::success("hi").with_code(42);
758 assert_eq!(result.code, 42);
759 assert_eq!(&*result.text_out(),"hi");
760 }
761
762 #[test]
763 fn output_getter() {
764 use crate::output::{OutputData, OutputNode};
765 let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
767 let result = ExecResult::with_output(nodes);
768 assert!(result.output().is_some());
769 assert!(result.has_output());
770
771 let text_result = ExecResult::with_output(OutputData::text("test"));
773 assert!(!text_result.has_output());
774 assert_eq!(&*text_result.text_out(), "test");
775
776 let plain = ExecResult::success("text");
777 assert!(plain.output().is_none());
778 assert!(!plain.has_output());
779 }
780
781 #[test]
782 fn set_out_and_push_out_and_clear_out() {
783 let mut result = ExecResult::success("");
784 result.set_out("hello".into());
785 assert_eq!(&*result.text_out(),"hello");
786 result.push_out(" world");
787 assert_eq!(&*result.text_out(),"hello world");
788 result.clear_out();
789 assert!(result.text_out().is_empty());
790 }
791
792 #[test]
793 fn set_output_and_take_output() {
794 use crate::output::OutputData;
795 let mut result = ExecResult::success("");
796 assert!(result.take_output().is_none());
797
798 result.set_output(Some(OutputData::text("data")));
799 assert!(result.has_output());
800
801 let taken = result.take_output();
802 assert!(taken.is_some());
803 assert!(!result.has_output());
804 }
805
806 #[test]
807 fn materialize_populates_out_from_output() {
808 use crate::output::{OutputData, OutputNode};
809 let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
811 let mut result = ExecResult::with_output(nodes);
812 assert!(matches!(&result.out, OutputPayload::Text(s) if s.is_empty()));
815 assert!(result.has_output());
816 result.materialize();
817 assert_eq!(&*result.text_out(),"a\nb");
818 assert!(result.output.is_none());
819 }
820
821 #[test]
822 fn value_bytes_round_trips_through_envelope() {
823 let v = Value::Bytes(vec![0u8, 1, 2, 255, 128]);
824 let json = value_to_json(&v);
825 assert_eq!(json["_type"], "bytes");
826 assert_eq!(json["len"], 5);
827 assert_eq!(json_to_value(json), v);
829 let obj = serde_json::json!({"name": "amy"});
831 assert!(matches!(json_to_value(obj), Value::Json(_)));
832 }
833
834 #[test]
835 fn no_envelope_never_decodes_bytes() {
836 let envelope = crate::bytes::bytes_to_envelope(&[1u8, 2, 3]);
840 assert!(matches!(json_to_value(envelope.clone()), Value::Bytes(_)));
842 assert!(matches!(
844 json_to_value_no_envelope(envelope),
845 Value::Json(serde_json::Value::Object(_))
846 ));
847 }
848
849 #[test]
850 fn no_envelope_shares_unwrap_law_for_scalars() {
851 assert_eq!(json_to_value_no_envelope(serde_json::json!(42)), Value::Int(42));
853 assert_eq!(json_to_value_no_envelope(serde_json::json!(1.5)), Value::Float(1.5));
854 assert_eq!(json_to_value_no_envelope(serde_json::json!(true)), Value::Bool(true));
855 assert_eq!(json_to_value_no_envelope(serde_json::json!("hi")), Value::String("hi".into()));
856 assert_eq!(json_to_value_no_envelope(serde_json::json!(null)), Value::Null);
857 assert!(matches!(
858 json_to_value_no_envelope(serde_json::json!([1, 2])),
859 Value::Json(serde_json::Value::Array(_))
860 ));
861 }
862
863 #[test]
864 fn output_payload_text_serializes_as_bare_string() {
865 let r = ExecResult::success("hello");
868 let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
869 assert_eq!(json["out"], "hello");
870 let back: ExecResult = serde_json::from_value(json).unwrap();
872 assert_eq!(&*back.text_out(), "hello");
873 assert!(!back.is_bytes());
874 }
875
876 #[test]
877 fn success_bytes_carries_binary_and_round_trips() {
878 let r = ExecResult::success_bytes(vec![0u8, 159, 146, 150]); assert!(r.is_bytes());
880 assert_eq!(r.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
881 assert!(r.try_text_out().is_err());
883 assert!(r.text_out().contains('\u{fffd}'));
885 let json: serde_json::Value = serde_json::to_value(&r).unwrap();
887 assert_eq!(json["out"]["_type"], "bytes");
888 let back: ExecResult = serde_json::from_value(json).unwrap();
889 assert_eq!(back.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
890 }
891
892 #[test]
893 fn valid_utf8_bytes_coerce_to_text() {
894 let r = ExecResult::success_bytes(b"plain text".to_vec());
895 assert!(r.is_bytes());
896 assert_eq!(r.try_text_out().unwrap(), "plain text");
897 assert_eq!(&*r.text_out(), "plain text");
898 }
899
900 #[test]
901 fn materialize_preserves_existing_out() {
902 use crate::output::OutputData;
903 let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
904 result.materialize();
905 assert_eq!(&*result.text_out(),"custom");
906 }
907
908 #[test]
909 fn take_output_for_stream_when_out_empty() {
910 use crate::output::{OutputData, OutputNode};
911 let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
913 let mut result = ExecResult::with_output(nodes);
914 let taken = result.take_output_for_stream();
915 assert!(taken.is_some());
916 assert!(!result.has_output());
917 }
918
919 #[test]
920 fn with_output_simple_text_populates_out_directly() {
921 use crate::output::OutputData;
922 let result = ExecResult::with_output(OutputData::text("hello"));
923 assert!(!result.has_output());
925 assert_eq!(&*result.text_out(), "hello");
926 let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
928 assert!(json_result.data.is_none());
929 }
930
931 fn latch_req(paths: &[&str]) -> LatchRequest {
932 LatchRequest {
933 nonce: "a3f7b2c1".to_string(),
934 command: "rm".to_string(),
935 paths: paths.iter().map(|p| (*p).to_string()).collect(),
936 hint: "rm --confirm=\"a3f7b2c1\" important.dat".to_string(),
937 tool: "rm".to_string(),
938 argv: paths.iter().map(|p| (*p).to_string()).collect(),
939 ttl: 60,
940 job_id: None,
941 }
942 }
943
944 #[test]
945 fn latch_request_reads_the_latch_field() {
946 let mut result = ExecResult::failure(2, "rm: confirmation required (latch enabled)");
947 result.latch = Some(Box::new(latch_req(&["important.dat"])));
948
949 let req = result.latch_request().expect("a latch request");
950 assert_eq!(req.nonce, "a3f7b2c1");
951 assert_eq!(req.command, "rm");
952 assert_eq!(req.paths, vec!["important.dat".to_string()]);
953 assert_eq!(req.ttl, 60);
954 assert!(req.hint.contains("--confirm"));
955 }
956
957 #[test]
958 fn latch_request_handles_command_only_empty_paths() {
959 let mut result = ExecResult::failure(2, "kaish-trash empty: confirmation required");
960 result.latch = Some(Box::new(LatchRequest {
961 nonce: "deadbeef".to_string(),
962 command: "kaish-trash empty".to_string(),
963 paths: vec![],
964 hint: "kaish-trash empty --confirm=deadbeef".to_string(),
965 tool: "kaish-trash".to_string(),
966 argv: vec!["--".to_string(), "empty".to_string()],
967 ttl: 60,
968 job_id: None,
969 }));
970
971 let req = result.latch_request().expect("a latch request");
972 assert_eq!(req.command, "kaish-trash empty");
973 assert!(req.paths.is_empty());
974 }
975
976 #[test]
977 fn latch_request_none_when_no_latch_set() {
978 assert!(ExecResult::success("").latch_request().is_none());
980 assert!(ExecResult::failure(2, "rm: unknown flag --bogus")
981 .latch_request()
982 .is_none());
983 }
984
985 #[test]
986 fn latch_request_ignores_data_plane_data() {
987 let mut result = ExecResult::failure(2, "boom");
990 result.data = Some(Value::Json(serde_json::json!({"count": 3})));
991 assert!(result.latch_request().is_none());
992 }
993
994 #[test]
995 fn clear_stdout_drops_data_but_never_the_latch() {
996 let mut result = ExecResult::success_data(Value::Json(serde_json::json!([1, 2, 3])));
1000 result.latch = Some(Box::new(latch_req(&["precious.txt"])));
1001 result.clear_stdout();
1002 assert!(result.data.is_none(), "data-plane .data must clear");
1003 assert!(
1004 result.latch_request().is_some(),
1005 "control-plane latch must survive a stdout redirect"
1006 );
1007 }
1008
1009 #[test]
1010 fn take_output_for_stream_when_out_populated() {
1011 use crate::output::OutputData;
1012 let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
1013 let taken = result.take_output_for_stream();
1014 assert!(taken.is_none());
1015 assert!(result.has_output()); }
1017}