Skip to main content

kaish_types/
result.rs

1//! ExecResult — the structured result of every command execution.
2//!
3//! After every command in kaish, the special variable `$?` contains an ExecResult.
4
5use std::borrow::Cow;
6use std::collections::BTreeMap;
7
8use crate::output::OutputData;
9use crate::value::Value;
10
11/// A command's stdout payload: text, or raw bytes.
12///
13/// `Text` xor `Bytes` — the enum makes the invalid both-set state
14/// unrepresentable (an earlier draft used two sibling fields; see
15/// `docs/binary-data.md`). Serializes wire-compatibly: `Text` is a bare JSON
16/// string (unchanged from when `out` was a `String`), `Bytes` is the base64
17/// envelope from [`crate::bytes`].
18#[derive(Debug, Clone, PartialEq)]
19pub enum OutputPayload {
20    /// UTF-8 text — the common case, canonical for pipes.
21    Text(String),
22    /// Raw bytes — binary output (set by binary-aware builtins). Until the
23    /// Phase-2 pipe/consumption rework, no builtin produces this in practice.
24    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            // Bare string keeps the historical `"out":"…"` wire shape.
37            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/// Returned when a binary result is asked to behave as text.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct BinaryNotText {
61    /// Number of binary bytes that could not be coerced.
62    pub len: usize,
63}
64
65impl std::fmt::Display for BinaryNotText {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(
68            f,
69            "output is binary ({} bytes), not text — pipe through base64/xxd or redirect to a file",
70            self.len
71        )
72    }
73}
74
75impl std::error::Error for BinaryNotText {}
76
77/// The result of executing a command or pipeline.
78///
79/// `$?` in script syntax is the POSIX exit code (an integer). To read the
80/// previous command's structured `.data` (or its captured stdout) from
81/// inside a script, use the `kaish-last` builtin and pipe / capture its
82/// output. Inside Rust callers, read `.data`, `.text_out()`, etc. directly.
83///
84/// Notes on the fields:
85/// - `code` — exit code (0 = success)
86/// - `err` — error message if failed
87/// - `out` — raw stdout as string
88/// - `data` — structured data; only set by builtins/tools that opt in
89///   (e.g. `seq`, `jq`, `cut`, `find`, `glob`, `split`). External commands
90///   never populate this — pipe their stdout through `jq` to get it.
91#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
92#[non_exhaustive]
93pub struct ExecResult {
94    /// Exit code. 0 means success.
95    pub code: i64,
96    /// Standard output payload — text (canonical for pipes) or raw bytes.
97    out: OutputPayload,
98    /// Raw standard error as a string.
99    pub err: String,
100    /// Structured data — only populated when a builtin/tool sets it explicitly.
101    /// Stdout is *never* sniffed; this stays `None` for external commands.
102    pub data: Option<Value>,
103    /// Structured output data for rendering.
104    output: Option<OutputData>,
105    /// True if the output limiter capped this result. Either the overflow was
106    /// written to a disk spill file (the `out` message carries the path) or it
107    /// was truncated in memory (Memory spill mode — head+tail only, no
108    /// recoverable file). Both cases remap the exit code to 3.
109    pub did_spill: bool,
110    /// The command's original exit code before spill logic overwrote it with 2 or 3.
111    /// Present only when `did_spill` is true and `code` was changed.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub original_code: Option<i64>,
114    /// MIME content type hint (e.g., "text/markdown", "image/svg+xml").
115    /// When set, downstream consumers can use this instead of sniffing content.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub content_type: Option<String>,
118    /// Opaque key-value context propagated from tools through execution.
119    /// Intermediaries (kaish) carry but don't interpret. Consumers read known keys.
120    /// Follows W3C Baggage semantics — useful for OTel trace propagation,
121    /// application-level hints, etc.
122    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
123    pub baggage: BTreeMap<String, String>,
124}
125
126impl ExecResult {
127    /// Create a successful result with output.
128    pub fn success(out: impl Into<String>) -> Self {
129        Self {
130            code: 0,
131            out: OutputPayload::Text(out.into()),
132            err: String::new(),
133            data: None,
134            output: None,
135            did_spill: false,
136            original_code: None,
137            content_type: None,
138            baggage: BTreeMap::new(),
139        }
140    }
141
142    /// Create a successful result with structured output data.
143    ///
144    /// The `OutputData` is the source of truth. Text is materialized lazily
145    /// via `text_out()` when needed (pipes, redirects, command substitution).
146    pub fn with_output(output: OutputData) -> Self {
147        // Simple text: move string into .out directly for efficient Cow::Borrowed.
148        // Structured output: store in .output, materialize lazily.
149        match output.into_text() {
150            Ok(text) => Self::success(text),
151            Err(output) => Self {
152                code: 0,
153                out: OutputPayload::Text(String::new()),
154                err: String::new(),
155                data: None,
156                output: Some(output),
157                did_spill: false,
158                original_code: None,
159                content_type: None,
160                baggage: BTreeMap::new(),
161            },
162        }
163    }
164
165    /// Create a successful result whose stdout is raw bytes (binary payload).
166    pub fn success_bytes(bytes: Vec<u8>) -> Self {
167        let mut r = Self::success("");
168        r.out = OutputPayload::Bytes(bytes);
169        r
170    }
171
172    /// Create a successful result from bytes, applying the coercion rule at the
173    /// producer: valid UTF-8 becomes a text result, anything else a binary
174    /// `Bytes` result. This is the single place pass-through/decoder builtins
175    /// (`cat`, `head -c`, `base64 -d`, `xxd -r`, `tee`, …) decide text-vs-binary,
176    /// so text workflows stay text and only real binary flows as bytes.
177    pub fn success_text_or_bytes(bytes: Vec<u8>) -> Self {
178        match String::from_utf8(bytes) {
179            Ok(text) => Self::success(text),
180            Err(e) => Self::success_bytes(e.into_bytes()),
181        }
182    }
183
184    /// Create a successful result with structured data.
185    pub fn success_data(data: Value) -> Self {
186        let out = value_to_json(&data).to_string();
187        Self {
188            code: 0,
189            out: OutputPayload::Text(out),
190            err: String::new(),
191            data: Some(data),
192            output: None,
193            did_spill: false,
194            original_code: None,
195            content_type: None,
196            baggage: BTreeMap::new(),
197        }
198    }
199
200    /// Create a successful result with both text output and structured data.
201    ///
202    /// Use this when a command should have:
203    /// - Text output for pipes and traditional shell usage
204    /// - Structured data for iteration and programmatic access
205    ///
206    /// The data field takes precedence for command substitution in contexts
207    /// like `for i in $(cmd)` where the structured data can be iterated.
208    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
209        Self {
210            code: 0,
211            out: OutputPayload::Text(out.into()),
212            err: String::new(),
213            data: Some(data),
214            output: None,
215            did_spill: false,
216            original_code: None,
217            content_type: None,
218            baggage: BTreeMap::new(),
219        }
220    }
221
222    /// Create a failed result with an error message.
223    pub fn failure(code: i64, err: impl Into<String>) -> Self {
224        Self {
225            code,
226            out: OutputPayload::Text(String::new()),
227            err: err.into(),
228            data: None,
229            output: None,
230            did_spill: false,
231            original_code: None,
232            content_type: None,
233            baggage: BTreeMap::new(),
234        }
235    }
236
237    /// Create a result from raw output streams.
238    ///
239    /// `data` is left empty — kaish does not sniff stdout for JSON. To get
240    /// structured iteration from an external command, pipe through `jq`:
241    /// `for i in $(curl ... | jq .); do ...`.
242    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
243        Self {
244            code,
245            out: OutputPayload::Text(stdout.into()),
246            err: stderr.into(),
247            data: None,
248            output: None,
249            did_spill: false,
250            original_code: None,
251            content_type: None,
252            baggage: BTreeMap::new(),
253        }
254    }
255
256    /// Create a successful result with structured output and explicit pipe text.
257    ///
258    /// Use this when a builtin needs custom text formatting that differs from
259    /// the canonical `OutputData::to_canonical_string()` representation.
260    pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
261        Self {
262            code: 0,
263            out: OutputPayload::Text(text.into()),
264            err: String::new(),
265            data: None,
266            output: Some(output),
267            did_spill: false,
268            original_code: None,
269            content_type: None,
270            baggage: BTreeMap::new(),
271        }
272    }
273
274    /// Create a result from parts — for kernel struct literal sites.
275    pub fn from_parts(
276        code: i64,
277        out: String,
278        err: String,
279        data: Option<Value>,
280    ) -> Self {
281        Self {
282            code,
283            out: OutputPayload::Text(out),
284            err,
285            data,
286            output: None,
287            did_spill: false,
288            original_code: None,
289            content_type: None,
290            baggage: BTreeMap::new(),
291        }
292    }
293
294    /// Builder: set the exit code, returning self for chaining.
295    pub fn with_code(mut self, code: i64) -> Self {
296        self.code = code;
297        self
298    }
299
300    // ── Read accessors ──
301
302    /// Get text output, materializing from OutputData on demand.
303    ///
304    /// Returns the text payload if non-empty, otherwise falls back to
305    /// `OutputData::to_canonical_string()`. This is the canonical way to
306    /// get text for pipes, command substitution, and file redirects.
307    ///
308    /// **Binary payloads** decode lossily here (`U+FFFD` for invalid UTF-8). No
309    /// builtin produces a `Bytes` payload yet (Phase 1), so this lossy path is
310    /// unreachable in practice; the loud-error guard for binary lives in
311    /// [`Self::try_text_out`], which the Phase-2 text sinks adopt. See
312    /// `docs/binary-data.md`.
313    pub fn text_out(&self) -> Cow<'_, str> {
314        match &self.out {
315            OutputPayload::Text(s) if !s.is_empty() => Cow::Borrowed(s),
316            OutputPayload::Bytes(b) => match std::str::from_utf8(b) {
317                Ok(s) => Cow::Borrowed(s),
318                Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
319            },
320            // Empty text → fall back to structured output's canonical string.
321            _ => match self.output {
322                Some(ref output) => Cow::Owned(output.to_canonical_string()),
323                None => Cow::Borrowed(""),
324            },
325        }
326    }
327
328    /// Get text output, or a [`BinaryNotText`] error if the payload is binary
329    /// and not valid UTF-8. This is the boundary guard for text sinks (`echo`,
330    /// interpolation, `$()` capture) — adopted as those paths grow byte
331    /// awareness (Phase 2). Valid-UTF-8 bytes coerce; everything else is loud.
332    pub fn try_text_out(&self) -> Result<Cow<'_, str>, BinaryNotText> {
333        match &self.out {
334            OutputPayload::Bytes(b) => std::str::from_utf8(b)
335                .map(Cow::Borrowed)
336                .map_err(|_| BinaryNotText { len: b.len() }),
337            _ => Ok(self.text_out()),
338        }
339    }
340
341    /// Raw bytes if this result carries a binary payload, else `None`.
342    pub fn out_bytes(&self) -> Option<&[u8]> {
343        match &self.out {
344            OutputPayload::Bytes(b) => Some(b),
345            OutputPayload::Text(_) => None,
346        }
347    }
348
349    /// True if the stdout payload is raw bytes rather than text.
350    pub fn is_bytes(&self) -> bool {
351        matches!(self.out, OutputPayload::Bytes(_))
352    }
353
354    /// Get a reference to structured output data.
355    pub fn output(&self) -> Option<&OutputData> {
356        self.output.as_ref()
357    }
358
359    /// True if structured output data is present.
360    pub fn has_output(&self) -> bool {
361        self.output.is_some()
362    }
363
364    // ── Mutation accessors ──
365
366    /// Replace `.out` with text.
367    pub fn set_out(&mut self, s: String) {
368        self.out = OutputPayload::Text(s);
369    }
370
371    /// Replace `.out` with raw bytes (binary payload).
372    pub fn set_out_bytes(&mut self, b: Vec<u8>) {
373        self.out = OutputPayload::Bytes(b);
374    }
375
376    /// Append text to `.out`. A binary payload is appended to as raw UTF-8 bytes.
377    pub fn push_out(&mut self, s: &str) {
378        match &mut self.out {
379            OutputPayload::Text(t) => t.push_str(s),
380            OutputPayload::Bytes(b) => b.extend_from_slice(s.as_bytes()),
381        }
382    }
383
384    /// Clear `.out` back to empty text.
385    pub fn clear_out(&mut self) {
386        self.out = OutputPayload::Text(String::new());
387    }
388
389    /// Replace `.output`.
390    pub fn set_output(&mut self, o: Option<OutputData>) {
391        self.output = o;
392    }
393
394    /// Take `.output`, leaving None.
395    pub fn take_output(&mut self) -> Option<OutputData> {
396        self.output.take()
397    }
398
399    /// Materialize: if `.out` is empty and `.output` is present,
400    /// populate `.out` from canonical string and clear `.output`.
401    pub fn materialize(&mut self) {
402        if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
403            if let Some(ref output) = self.output {
404                self.out = OutputPayload::Text(output.to_canonical_string());
405            }
406        }
407        self.output = None;
408    }
409
410    /// Take `.output` only if `.out` is empty (no custom text),
411    /// so caller can stream directly without materializing.
412    pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
413        if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
414            self.output.take()
415        } else {
416            None
417        }
418    }
419
420    /// True if the command succeeded (exit code 0).
421    pub fn ok(&self) -> bool {
422        self.code == 0
423    }
424
425    /// Set content type hint, returning self for chaining.
426    pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
427        self.content_type = Some(ct.into());
428        self
429    }
430
431}
432
433/// Convert serde_json::Value to our AST Value.
434///
435/// Primitives are mapped to their corresponding Value variants.
436/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
437pub fn json_to_value(json: serde_json::Value) -> Value {
438    match json {
439        serde_json::Value::Null => Value::Null,
440        serde_json::Value::Bool(b) => Value::Bool(b),
441        serde_json::Value::Number(n) => {
442            if let Some(i) = n.as_i64() {
443                Value::Int(i)
444            } else if let Some(f) = n.as_f64() {
445                Value::Float(f)
446            } else {
447                Value::String(n.to_string())
448            }
449        }
450        serde_json::Value::String(s) => Value::String(s),
451        // A base64 byte envelope round-trips back to inline Bytes; any other
452        // object/array stays structured Json.
453        serde_json::Value::Object(_) => match crate::bytes::envelope_to_bytes(&json) {
454            Some(bytes) => Value::Bytes(bytes),
455            None => Value::Json(json),
456        },
457        serde_json::Value::Array(_) => Value::Json(json),
458    }
459}
460
461/// Convert our AST Value to serde_json::Value for serialization.
462pub fn value_to_json(value: &Value) -> serde_json::Value {
463    match value {
464        Value::Null => serde_json::Value::Null,
465        Value::Bool(b) => serde_json::Value::Bool(*b),
466        Value::Int(i) => serde_json::Value::Number((*i).into()),
467        Value::Float(f) => {
468            // JSON has no NaN/Infinity. Rather than silently collapse them to
469            // null (data loss), serialize the non-finite value to its string
470            // form ("NaN", "inf", "-inf") so the information survives the trip.
471            serde_json::Number::from_f64(*f)
472                .map(serde_json::Value::Number)
473                .unwrap_or_else(|| serde_json::Value::String(f.to_string()))
474        }
475        Value::String(s) => serde_json::Value::String(s.clone()),
476        Value::Json(json) => json.clone(),
477        Value::Bytes(data) => crate::bytes::bytes_to_envelope(data),
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn success_creates_ok_result() {
487        let result = ExecResult::success("hello world");
488        assert!(result.ok());
489        assert_eq!(result.code, 0);
490        assert_eq!(&*result.text_out(),"hello world");
491        assert!(result.err.is_empty());
492    }
493
494    #[test]
495    fn value_to_json_finite_float_is_number() {
496        assert_eq!(value_to_json(&Value::Float(3.5)), serde_json::json!(3.5));
497    }
498
499    #[test]
500    fn value_to_json_non_finite_float_serializes_to_string() {
501        // JSON has no NaN/Infinity — preserve the info as a string, never null.
502        assert_eq!(value_to_json(&Value::Float(f64::NAN)), serde_json::json!("NaN"));
503        assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), serde_json::json!("inf"));
504        assert_eq!(
505            value_to_json(&Value::Float(f64::NEG_INFINITY)),
506            serde_json::json!("-inf")
507        );
508        // Crucially: not null (the old data-losing behavior).
509        assert_ne!(value_to_json(&Value::Float(f64::NAN)), serde_json::Value::Null);
510    }
511
512    #[test]
513    fn failure_creates_non_ok_result() {
514        let result = ExecResult::failure(1, "command not found");
515        assert!(!result.ok());
516        assert_eq!(result.code, 1);
517        assert_eq!(result.err, "command not found");
518    }
519
520    #[test]
521    fn success_does_not_sniff_json_stdout() {
522        // External-command stdout is never sniffed for JSON. Tools that want
523        // structured data must call success_with_data() / success_data().
524        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
525        assert!(result.data.is_none());
526        assert_eq!(&*result.text_out(),r#"{"count": 42, "items": ["a", "b"]}"#);
527    }
528
529    #[test]
530    fn from_output_does_not_sniff_json_stdout() {
531        let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
532        assert!(result.data.is_none());
533        assert_eq!(&*result.text_out(),"[1, 2, 3]");
534    }
535
536    #[test]
537    fn non_json_stdout_has_no_data() {
538        let result = ExecResult::success("just plain text");
539        assert!(result.data.is_none());
540    }
541
542    #[test]
543    fn success_data_creates_result_with_value() {
544        let value = Value::String("test data".into());
545        let result = ExecResult::success_data(value.clone());
546        assert!(result.ok());
547        assert_eq!(result.data, Some(value));
548    }
549
550    #[test]
551    fn did_spill_defaults_to_false() {
552        assert!(!ExecResult::success("hi").did_spill);
553        assert!(!ExecResult::failure(1, "err").did_spill);
554        assert!(!ExecResult::from_output(0, "out", "err").did_spill);
555    }
556
557    #[test]
558    fn did_spill_is_serialized() {
559        let mut result = ExecResult::success("hi");
560        result.did_spill = true;
561        let json = serde_json::to_string(&result).unwrap();
562        assert!(json.contains("\"did_spill\":true"));
563    }
564
565    #[test]
566    fn original_code_omitted_when_none() {
567        let result = ExecResult::success("hi");
568        let json = serde_json::to_string(&result).unwrap();
569        assert!(!json.contains("original_code"));
570    }
571
572    #[test]
573    fn original_code_present_when_set() {
574        let mut result = ExecResult::success("hi");
575        result.original_code = Some(0);
576        let json = serde_json::to_string(&result).unwrap();
577        assert!(json.contains("\"original_code\":0"));
578    }
579
580    #[test]
581    fn default_is_empty_success() {
582        let result = ExecResult::default();
583        assert!(result.ok());
584        assert!(result.text_out().is_empty());
585        assert!(result.data.is_none());
586        assert!(result.content_type.is_none());
587        assert!(result.baggage.is_empty());
588    }
589
590    #[test]
591    fn from_parts_creates_result() {
592        let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
593        assert_eq!(result.code, 42);
594        assert_eq!(&*result.text_out(),"out");
595        assert_eq!(result.err, "err");
596        assert!(result.data.is_none());
597        assert!(result.output.is_none());
598    }
599
600    #[test]
601    fn with_code_sets_code() {
602        let result = ExecResult::success("hi").with_code(42);
603        assert_eq!(result.code, 42);
604        assert_eq!(&*result.text_out(),"hi");
605    }
606
607    #[test]
608    fn output_getter() {
609        use crate::output::{OutputData, OutputNode};
610        // Use structured (non-text) output so with_output preserves .output
611        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
612        let result = ExecResult::with_output(nodes);
613        assert!(result.output().is_some());
614        assert!(result.has_output());
615
616        // Simple text now routes to .out, so output is None
617        let text_result = ExecResult::with_output(OutputData::text("test"));
618        assert!(!text_result.has_output());
619        assert_eq!(&*text_result.text_out(), "test");
620
621        let plain = ExecResult::success("text");
622        assert!(plain.output().is_none());
623        assert!(!plain.has_output());
624    }
625
626    #[test]
627    fn set_out_and_push_out_and_clear_out() {
628        let mut result = ExecResult::success("");
629        result.set_out("hello".into());
630        assert_eq!(&*result.text_out(),"hello");
631        result.push_out(" world");
632        assert_eq!(&*result.text_out(),"hello world");
633        result.clear_out();
634        assert!(result.text_out().is_empty());
635    }
636
637    #[test]
638    fn set_output_and_take_output() {
639        use crate::output::OutputData;
640        let mut result = ExecResult::success("");
641        assert!(result.take_output().is_none());
642
643        result.set_output(Some(OutputData::text("data")));
644        assert!(result.has_output());
645
646        let taken = result.take_output();
647        assert!(taken.is_some());
648        assert!(!result.has_output());
649    }
650
651    #[test]
652    fn materialize_populates_out_from_output() {
653        use crate::output::{OutputData, OutputNode};
654        // Use structured output to test materialization
655        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
656        let mut result = ExecResult::with_output(nodes);
657        // Raw text payload is empty before materialize (text_out() would
658        // already fall back to the OutputData canonical string).
659        assert!(matches!(&result.out, OutputPayload::Text(s) if s.is_empty()));
660        assert!(result.has_output());
661        result.materialize();
662        assert_eq!(&*result.text_out(),"a\nb");
663        assert!(result.output.is_none());
664    }
665
666    #[test]
667    fn value_bytes_round_trips_through_envelope() {
668        let v = Value::Bytes(vec![0u8, 1, 2, 255, 128]);
669        let json = value_to_json(&v);
670        assert_eq!(json["_type"], "bytes");
671        assert_eq!(json["len"], 5);
672        // json_to_value recognizes the envelope and reconstructs Bytes.
673        assert_eq!(json_to_value(json), v);
674        // A plain object is NOT mistaken for bytes.
675        let obj = serde_json::json!({"name": "amy"});
676        assert!(matches!(json_to_value(obj), Value::Json(_)));
677    }
678
679    #[test]
680    fn output_payload_text_serializes_as_bare_string() {
681        // Wire compatibility: a text result's `out` stays a plain JSON string,
682        // exactly as when `out` was a `String`.
683        let r = ExecResult::success("hello");
684        let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
685        assert_eq!(json["out"], "hello");
686        // Round-trips back to a Text payload.
687        let back: ExecResult = serde_json::from_value(json).unwrap();
688        assert_eq!(&*back.text_out(), "hello");
689        assert!(!back.is_bytes());
690    }
691
692    #[test]
693    fn success_bytes_carries_binary_and_round_trips() {
694        let r = ExecResult::success_bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8
695        assert!(r.is_bytes());
696        assert_eq!(r.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
697        // try_text_out is the loud guard: invalid UTF-8 → error, not mangling.
698        assert!(r.try_text_out().is_err());
699        // text_out (infallible) decodes lossily — Phase-1 fallback.
700        assert!(r.text_out().contains('\u{fffd}'));
701        // Serializes as a base64 envelope and round-trips back to bytes.
702        let json: serde_json::Value = serde_json::to_value(&r).unwrap();
703        assert_eq!(json["out"]["_type"], "bytes");
704        let back: ExecResult = serde_json::from_value(json).unwrap();
705        assert_eq!(back.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
706    }
707
708    #[test]
709    fn valid_utf8_bytes_coerce_to_text() {
710        let r = ExecResult::success_bytes(b"plain text".to_vec());
711        assert!(r.is_bytes());
712        assert_eq!(r.try_text_out().unwrap(), "plain text");
713        assert_eq!(&*r.text_out(), "plain text");
714    }
715
716    #[test]
717    fn materialize_preserves_existing_out() {
718        use crate::output::OutputData;
719        let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
720        result.materialize();
721        assert_eq!(&*result.text_out(),"custom");
722    }
723
724    #[test]
725    fn take_output_for_stream_when_out_empty() {
726        use crate::output::{OutputData, OutputNode};
727        // Use structured output — text now goes to .out directly
728        let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
729        let mut result = ExecResult::with_output(nodes);
730        let taken = result.take_output_for_stream();
731        assert!(taken.is_some());
732        assert!(!result.has_output());
733    }
734
735    #[test]
736    fn with_output_simple_text_populates_out_directly() {
737        use crate::output::OutputData;
738        let result = ExecResult::with_output(OutputData::text("hello"));
739        // Simple text should go to .out, not .output
740        assert!(!result.has_output());
741        assert_eq!(&*result.text_out(), "hello");
742        // Even JSON-shaped text is NOT auto-parsed — .data stays None.
743        let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
744        assert!(json_result.data.is_none());
745    }
746
747    #[test]
748    fn take_output_for_stream_when_out_populated() {
749        use crate::output::OutputData;
750        let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
751        let taken = result.take_output_for_stream();
752        assert!(taken.is_none());
753        assert!(result.has_output()); // not taken
754    }
755}