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    ///
105    /// Boxed because `OutputData` is ~120 B and `ExecResult` (and
106    /// the `ControlFlow` that wraps it) is returned up every level of deep
107    /// `$()`/pipeline recursion, so an inline `Option<OutputData>` fattened every
108    /// frame. The box is allocated only when a builtin sets structured output,
109    /// and it serializes identically (Box is transparent to serde). Private —
110    /// the public accessors below hand back plain `OutputData`/`&OutputData`, so
111    /// the boxing never leaks (GH #48, item 5).
112    output: Option<Box<OutputData>>,
113    /// True if output was capped and lost data. Either the output limiter
114    /// spilled the overflow to disk (the `out` message carries the path),
115    /// truncated it in memory (Memory spill mode — head+tail only, no
116    /// recoverable file), or an external command's stdout overflowed its
117    /// fixed-size capture ring with output limiting off (GH #191) — the
118    /// capture buffer evicted its head with no spill file at all. All cases
119    /// remap the exit code to 3.
120    pub did_spill: bool,
121    /// The command's original exit code before spill logic overwrote it with 2 or 3.
122    /// Present only when `did_spill` is true and `code` was changed.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub original_code: Option<i64>,
125    /// MIME content type hint (e.g., "text/markdown", "image/svg+xml").
126    /// When set, downstream consumers can use this instead of sniffing content.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub content_type: Option<String>,
129    /// Opaque key-value context propagated from tools through execution.
130    /// Intermediaries (kaish) carry but don't interpret. Consumers read known keys.
131    /// Follows W3C Baggage semantics — useful for OTel trace propagation,
132    /// application-level hints, etc.
133    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134    pub baggage: BTreeMap<String, String>,
135}
136
137impl ExecResult {
138    /// Create a successful result with output.
139    pub fn success(out: impl Into<String>) -> Self {
140        Self {
141            code: 0,
142            out: OutputPayload::Text(out.into()),
143            err: String::new(),
144            data: None,
145            output: None,
146            did_spill: false,
147            original_code: None,
148            content_type: None,
149            baggage: BTreeMap::new(),
150        }
151    }
152
153    /// Create a successful result with structured output data.
154    ///
155    /// The `OutputData` is the source of truth. Text is materialized lazily
156    /// via `text_out()` when needed (pipes, redirects, command substitution).
157    pub fn with_output(output: OutputData) -> Self {
158        // Simple text: move string into .out directly for efficient Cow::Borrowed.
159        // Structured output: store in .output, materialize lazily.
160        match output.into_text() {
161            Ok(text) => Self::success(text),
162            Err(output) => Self {
163                code: 0,
164                out: OutputPayload::Text(String::new()),
165                err: String::new(),
166                data: None,
167                output: Some(Box::new(output)),
168                did_spill: false,
169                original_code: None,
170                content_type: None,
171                baggage: BTreeMap::new(),
172            },
173        }
174    }
175
176    /// Create a successful result whose stdout is raw bytes (binary payload).
177    pub fn success_bytes(bytes: Vec<u8>) -> Self {
178        let mut r = Self::success("");
179        r.out = OutputPayload::Bytes(bytes);
180        r
181    }
182
183    /// Create a successful result from bytes, applying the coercion rule at the
184    /// producer: valid UTF-8 becomes a text result, anything else a binary
185    /// `Bytes` result. This is the single place pass-through/decoder builtins
186    /// (`cat`, `head -c`, `base64 -d`, `xxd -r`, `tee`, …) decide text-vs-binary,
187    /// so text workflows stay text and only real binary flows as bytes.
188    pub fn success_text_or_bytes(bytes: Vec<u8>) -> Self {
189        match String::from_utf8(bytes) {
190            Ok(text) => Self::success(text),
191            Err(e) => Self::success_bytes(e.into_bytes()),
192        }
193    }
194
195    /// Create a successful result with structured data.
196    pub fn success_data(data: Value) -> Self {
197        let out = value_to_json(&data).to_string();
198        Self {
199            code: 0,
200            out: OutputPayload::Text(out),
201            err: String::new(),
202            data: Some(data),
203            output: None,
204            did_spill: false,
205            original_code: None,
206            content_type: None,
207            baggage: BTreeMap::new(),
208        }
209    }
210
211    /// Create a successful result with both text output and structured data.
212    ///
213    /// Use this when a command should have:
214    /// - Text output for pipes and traditional shell usage
215    /// - Structured data for iteration and programmatic access
216    ///
217    /// The data field takes precedence for command substitution in contexts
218    /// like `for i in $(cmd)` where the structured data can be iterated.
219    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
220        Self {
221            code: 0,
222            out: OutputPayload::Text(out.into()),
223            err: String::new(),
224            data: Some(data),
225            output: None,
226            did_spill: false,
227            original_code: None,
228            content_type: None,
229            baggage: BTreeMap::new(),
230        }
231    }
232
233    /// Create a failed result with an error message.
234    pub fn failure(code: i64, err: impl Into<String>) -> Self {
235        Self {
236            code,
237            out: OutputPayload::Text(String::new()),
238            err: err.into(),
239            data: None,
240            output: None,
241            did_spill: false,
242            original_code: None,
243            content_type: None,
244            baggage: BTreeMap::new(),
245        }
246    }
247
248    /// Create a result from raw output streams.
249    ///
250    /// `data` is left empty — kaish does not sniff stdout for JSON. To get
251    /// structured iteration from an external command, pipe through `jq`:
252    /// `for i in $(curl ... | jq .); do ...`.
253    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
254        Self {
255            code,
256            out: OutputPayload::Text(stdout.into()),
257            err: stderr.into(),
258            data: None,
259            output: None,
260            did_spill: false,
261            original_code: None,
262            content_type: None,
263            baggage: BTreeMap::new(),
264        }
265    }
266
267    /// Create a successful result with structured output and explicit pipe text.
268    ///
269    /// Use this when a builtin needs custom text formatting that differs from
270    /// the canonical `OutputData::to_canonical_string()` representation.
271    pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
272        Self {
273            code: 0,
274            out: OutputPayload::Text(text.into()),
275            err: String::new(),
276            data: None,
277            output: Some(Box::new(output)),
278            did_spill: false,
279            original_code: None,
280            content_type: None,
281            baggage: BTreeMap::new(),
282        }
283    }
284
285    /// Create a result from parts — for kernel struct literal sites.
286    pub fn from_parts(
287        code: i64,
288        out: String,
289        err: String,
290        data: Option<Value>,
291    ) -> Self {
292        Self {
293            code,
294            out: OutputPayload::Text(out),
295            err,
296            data,
297            output: None,
298            did_spill: false,
299            original_code: None,
300            content_type: None,
301            baggage: BTreeMap::new(),
302        }
303    }
304
305    /// Builder: set the exit code, returning self for chaining.
306    pub fn with_code(mut self, code: i64) -> Self {
307        self.code = code;
308        self
309    }
310
311    // ── Read accessors ──
312
313    /// Get text output, materializing from OutputData on demand.
314    ///
315    /// Returns the text payload if non-empty, otherwise falls back to
316    /// `OutputData::to_canonical_string()`. This is the canonical way to
317    /// get text for pipes, command substitution, and file redirects.
318    ///
319    /// **Binary payloads** decode lossily here (`U+FFFD` for invalid UTF-8).
320    /// Several builtins already produce a `Bytes` payload (`cat`/`head`/`tail`/
321    /// `base64 -d`/`xxd -r`/`dd`/`tee`/external commands), so this lossy path
322    /// IS reachable — callers that need to catch binary rather than silently
323    /// mangle it should use [`Self::try_text_out`] instead, which loud-errors
324    /// with [`BinaryNotText`] on invalid UTF-8. See `docs/binary-data.md`.
325    pub fn text_out(&self) -> Cow<'_, str> {
326        match &self.out {
327            OutputPayload::Text(s) if !s.is_empty() => Cow::Borrowed(s),
328            OutputPayload::Bytes(b) => match std::str::from_utf8(b) {
329                Ok(s) => Cow::Borrowed(s),
330                Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
331            },
332            // Empty text → fall back to structured output's canonical string.
333            _ => match self.output {
334                Some(ref output) => Cow::Owned(output.to_canonical_string()),
335                None => Cow::Borrowed(""),
336            },
337        }
338    }
339
340    /// Get text output, or a [`BinaryNotText`] error if the payload is binary
341    /// and not valid UTF-8. This is the boundary guard for text sinks (`echo`,
342    /// interpolation, `$()` capture) — adopted as those paths grow byte
343    /// awareness (Phase 2). Valid-UTF-8 bytes coerce; everything else is loud.
344    pub fn try_text_out(&self) -> Result<Cow<'_, str>, BinaryNotText> {
345        match &self.out {
346            OutputPayload::Bytes(b) => std::str::from_utf8(b)
347                .map(Cow::Borrowed)
348                .map_err(|_| BinaryNotText { len: b.len() }),
349            _ => Ok(self.text_out()),
350        }
351    }
352
353    /// Raw bytes if this result carries a binary payload, else `None`.
354    pub fn out_bytes(&self) -> Option<&[u8]> {
355        match &self.out {
356            OutputPayload::Bytes(b) => Some(b),
357            OutputPayload::Text(_) => None,
358        }
359    }
360
361    /// True if the stdout payload is raw bytes rather than text.
362    pub fn is_bytes(&self) -> bool {
363        matches!(self.out, OutputPayload::Bytes(_))
364    }
365
366    /// Get a reference to structured output data.
367    pub fn output(&self) -> Option<&OutputData> {
368        self.output.as_deref()
369    }
370
371    /// True if structured output data is present.
372    pub fn has_output(&self) -> bool {
373        self.output.is_some()
374    }
375
376    // ── Mutation accessors ──
377
378    /// Replace `.out` with text.
379    pub fn set_out(&mut self, s: String) {
380        self.out = OutputPayload::Text(s);
381    }
382
383    /// Replace `.out` with raw bytes (binary payload).
384    pub fn set_out_bytes(&mut self, b: Vec<u8>) {
385        self.out = OutputPayload::Bytes(b);
386    }
387
388    /// Append text to `.out`. A binary payload is appended to as raw UTF-8 bytes.
389    pub fn push_out(&mut self, s: &str) {
390        match &mut self.out {
391            OutputPayload::Text(t) => t.push_str(s),
392            OutputPayload::Bytes(b) => b.extend_from_slice(s.as_bytes()),
393        }
394    }
395
396    /// Clear `.out` back to empty text.
397    pub fn clear_out(&mut self) {
398        self.out = OutputPayload::Text(String::new());
399    }
400
401    /// Drop every representation of stdout: the text `.out`, the structured
402    /// `.output`, and the data-plane `.data` sideband. Used when a stdout
403    /// redirect (`> file`, `>> file`, `&> file`, `1>&2`) has consumed the
404    /// command's output — the bytes went to the file (or stderr), so nothing
405    /// flows onward to a pipe, a `$(...)` capture, or the `.data` sideband.
406    /// Clearing all three together keeps them from drifting: a redirect that
407    /// cleared `.out`/`.output` but left `.data` would leak structured data past
408    /// its own redirect (`x=$(fromjson … > file)` capturing the value instead
409    /// of `""`).
410    ///
411    /// stdout, so a redirect can't drop it — `rm precious > log` still
412    /// gates.
413    pub fn clear_stdout(&mut self) {
414        self.out = OutputPayload::Text(String::new());
415        self.output = None;
416        self.data = None;
417    }
418
419    /// Replace `.output`.
420    pub fn set_output(&mut self, o: Option<OutputData>) {
421        self.output = o.map(Box::new);
422    }
423
424    /// Take `.output`, leaving None.
425    pub fn take_output(&mut self) -> Option<OutputData> {
426        self.output.take().map(|o| *o)
427    }
428
429    /// Materialize: if `.out` is empty and `.output` is present,
430    /// populate `.out` from canonical string and clear `.output`.
431    pub fn materialize(&mut self) {
432        if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
433            if let Some(ref output) = self.output {
434                self.out = OutputPayload::Text(output.to_canonical_string());
435            }
436        }
437        self.output = None;
438    }
439
440    /// Take `.output` only if `.out` is empty (no custom text),
441    /// so caller can stream directly without materializing.
442    pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
443        if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
444            self.output.take().map(|o| *o)
445        } else {
446            None
447        }
448    }
449
450    /// True if the command succeeded (exit code 0).
451    pub fn ok(&self) -> bool {
452        self.code == 0
453    }
454
455    /// Set content type hint, returning self for chaining.
456    pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
457        self.content_type = Some(ct.into());
458        self
459    }
460
461}
462
463/// Convert serde_json::Value to our AST Value.
464///
465/// Primitives are mapped to their corresponding Value variants.
466/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
467pub fn json_to_value(json: serde_json::Value) -> Value {
468    match json {
469        serde_json::Value::Null => Value::Null,
470        serde_json::Value::Bool(b) => Value::Bool(b),
471        serde_json::Value::Number(n) => {
472            if let Some(i) = n.as_i64() {
473                Value::Int(i)
474            } else if let Some(f) = n.as_f64() {
475                Value::Float(f)
476            } else {
477                Value::String(n.to_string())
478            }
479        }
480        serde_json::Value::String(s) => Value::String(s),
481        // A base64 byte envelope round-trips back to inline Bytes; any other
482        // object/array stays structured Json.
483        serde_json::Value::Object(_) => match crate::bytes::envelope_to_bytes(&json) {
484            Some(bytes) => Value::Bytes(bytes),
485            None => Value::Json(json),
486        },
487        serde_json::Value::Array(_) => Value::Json(json),
488    }
489}
490
491/// Convert serde_json::Value to our AST Value **without** bytes-envelope sniffing.
492///
493/// External JSON (from `fromjson`, or native access traversal) must never
494/// silently become a `Value::Bytes` just because an object happens to match the
495/// base64 envelope shape (`{"_type":"bytes",…}`). That auto-decode is a feature
496/// of *internal* round-tripping only — it would be a silent, surprising
497/// conversion on untrusted input. Otherwise this is the same unwrap law as
498/// [`json_to_value`]: JSON scalars unwrap to native `Value` variants, and only
499/// objects/arrays stay `Value::Json`.
500pub fn json_to_value_no_envelope(json: serde_json::Value) -> Value {
501    match json {
502        serde_json::Value::Null => Value::Null,
503        serde_json::Value::Bool(b) => Value::Bool(b),
504        serde_json::Value::Number(n) => {
505            if let Some(i) = n.as_i64() {
506                Value::Int(i)
507            } else if let Some(f) = n.as_f64() {
508                Value::Float(f)
509            } else {
510                Value::String(n.to_string())
511            }
512        }
513        serde_json::Value::String(s) => Value::String(s),
514        // Objects and arrays stay structured — an envelope-shaped object is a
515        // plain record here, not decoded to bytes.
516        serde_json::Value::Object(_) | serde_json::Value::Array(_) => Value::Json(json),
517    }
518}
519
520/// Convert our AST Value to serde_json::Value for serialization.
521pub fn value_to_json(value: &Value) -> serde_json::Value {
522    match value {
523        Value::Null => serde_json::Value::Null,
524        Value::Bool(b) => serde_json::Value::Bool(*b),
525        Value::Int(i) => serde_json::Value::Number((*i).into()),
526        Value::Float(f) => {
527            // JSON has no NaN/Infinity. Rather than silently collapse them to
528            // null (data loss), serialize the non-finite value to its string
529            // form ("NaN", "inf", "-inf") so the information survives the trip.
530            serde_json::Number::from_f64(*f)
531                .map(serde_json::Value::Number)
532                .unwrap_or_else(|| serde_json::Value::String(f.to_string()))
533        }
534        Value::String(s) => serde_json::Value::String(s.clone()),
535        Value::Json(json) => json.clone(),
536        Value::Bytes(data) => crate::bytes::bytes_to_envelope(data),
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn success_creates_ok_result() {
546        let result = ExecResult::success("hello world");
547        assert!(result.ok());
548        assert_eq!(result.code, 0);
549        assert_eq!(&*result.text_out(),"hello world");
550        assert!(result.err.is_empty());
551    }
552
553    #[test]
554    fn value_to_json_finite_float_is_number() {
555        assert_eq!(value_to_json(&Value::Float(3.5)), serde_json::json!(3.5));
556    }
557
558    #[test]
559    fn value_to_json_non_finite_float_serializes_to_string() {
560        // JSON has no NaN/Infinity — preserve the info as a string, never null.
561        assert_eq!(value_to_json(&Value::Float(f64::NAN)), serde_json::json!("NaN"));
562        assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), serde_json::json!("inf"));
563        assert_eq!(
564            value_to_json(&Value::Float(f64::NEG_INFINITY)),
565            serde_json::json!("-inf")
566        );
567        // Crucially: not null (the old data-losing behavior).
568        assert_ne!(value_to_json(&Value::Float(f64::NAN)), serde_json::Value::Null);
569    }
570
571    #[test]
572    fn failure_creates_non_ok_result() {
573        let result = ExecResult::failure(1, "command not found");
574        assert!(!result.ok());
575        assert_eq!(result.code, 1);
576        assert_eq!(result.err, "command not found");
577    }
578
579    #[test]
580    fn success_does_not_sniff_json_stdout() {
581        // External-command stdout is never sniffed for JSON. Tools that want
582        // structured data must call success_with_data() / success_data().
583        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
584        assert!(result.data.is_none());
585        assert_eq!(&*result.text_out(),r#"{"count": 42, "items": ["a", "b"]}"#);
586    }
587
588    #[test]
589    fn from_output_does_not_sniff_json_stdout() {
590        let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
591        assert!(result.data.is_none());
592        assert_eq!(&*result.text_out(),"[1, 2, 3]");
593    }
594
595    #[test]
596    fn non_json_stdout_has_no_data() {
597        let result = ExecResult::success("just plain text");
598        assert!(result.data.is_none());
599    }
600
601    #[test]
602    fn success_data_creates_result_with_value() {
603        let value = Value::String("test data".into());
604        let result = ExecResult::success_data(value.clone());
605        assert!(result.ok());
606        assert_eq!(result.data, Some(value));
607    }
608
609    #[test]
610    fn did_spill_defaults_to_false() {
611        assert!(!ExecResult::success("hi").did_spill);
612        assert!(!ExecResult::failure(1, "err").did_spill);
613        assert!(!ExecResult::from_output(0, "out", "err").did_spill);
614    }
615
616    #[test]
617    fn did_spill_is_serialized() {
618        let mut result = ExecResult::success("hi");
619        result.did_spill = true;
620        let json = serde_json::to_string(&result).unwrap();
621        assert!(json.contains("\"did_spill\":true"));
622    }
623
624    #[test]
625    fn original_code_omitted_when_none() {
626        let result = ExecResult::success("hi");
627        let json = serde_json::to_string(&result).unwrap();
628        assert!(!json.contains("original_code"));
629    }
630
631    #[test]
632    fn original_code_present_when_set() {
633        let mut result = ExecResult::success("hi");
634        result.original_code = Some(0);
635        let json = serde_json::to_string(&result).unwrap();
636        assert!(json.contains("\"original_code\":0"));
637    }
638
639    #[test]
640    fn default_is_empty_success() {
641        let result = ExecResult::default();
642        assert!(result.ok());
643        assert!(result.text_out().is_empty());
644        assert!(result.data.is_none());
645        assert!(result.content_type.is_none());
646        assert!(result.baggage.is_empty());
647    }
648
649    #[test]
650    fn from_parts_creates_result() {
651        let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
652        assert_eq!(result.code, 42);
653        assert_eq!(&*result.text_out(),"out");
654        assert_eq!(result.err, "err");
655        assert!(result.data.is_none());
656        assert!(result.output.is_none());
657    }
658
659    #[test]
660    fn with_code_sets_code() {
661        let result = ExecResult::success("hi").with_code(42);
662        assert_eq!(result.code, 42);
663        assert_eq!(&*result.text_out(),"hi");
664    }
665
666    #[test]
667    fn output_getter() {
668        use crate::output::{OutputData, OutputNode};
669        // Use structured (non-text) output so with_output preserves .output
670        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
671        let result = ExecResult::with_output(nodes);
672        assert!(result.output().is_some());
673        assert!(result.has_output());
674
675        // Simple text now routes to .out, so output is None
676        let text_result = ExecResult::with_output(OutputData::text("test"));
677        assert!(!text_result.has_output());
678        assert_eq!(&*text_result.text_out(), "test");
679
680        let plain = ExecResult::success("text");
681        assert!(plain.output().is_none());
682        assert!(!plain.has_output());
683    }
684
685    #[test]
686    fn set_out_and_push_out_and_clear_out() {
687        let mut result = ExecResult::success("");
688        result.set_out("hello".into());
689        assert_eq!(&*result.text_out(),"hello");
690        result.push_out(" world");
691        assert_eq!(&*result.text_out(),"hello world");
692        result.clear_out();
693        assert!(result.text_out().is_empty());
694    }
695
696    #[test]
697    fn set_output_and_take_output() {
698        use crate::output::OutputData;
699        let mut result = ExecResult::success("");
700        assert!(result.take_output().is_none());
701
702        result.set_output(Some(OutputData::text("data")));
703        assert!(result.has_output());
704
705        let taken = result.take_output();
706        assert!(taken.is_some());
707        assert!(!result.has_output());
708    }
709
710    #[test]
711    fn materialize_populates_out_from_output() {
712        use crate::output::{OutputData, OutputNode};
713        // Use structured output to test materialization
714        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
715        let mut result = ExecResult::with_output(nodes);
716        // Raw text payload is empty before materialize (text_out() would
717        // already fall back to the OutputData canonical string).
718        assert!(matches!(&result.out, OutputPayload::Text(s) if s.is_empty()));
719        assert!(result.has_output());
720        result.materialize();
721        assert_eq!(&*result.text_out(),"a\nb");
722        assert!(result.output.is_none());
723    }
724
725    #[test]
726    fn value_bytes_round_trips_through_envelope() {
727        let v = Value::Bytes(vec![0u8, 1, 2, 255, 128]);
728        let json = value_to_json(&v);
729        assert_eq!(json["_type"], "bytes");
730        assert_eq!(json["len"], 5);
731        // json_to_value recognizes the envelope and reconstructs Bytes.
732        assert_eq!(json_to_value(json), v);
733        // A plain object is NOT mistaken for bytes.
734        let obj = serde_json::json!({"name": "amy"});
735        assert!(matches!(json_to_value(obj), Value::Json(_)));
736    }
737
738    #[test]
739    fn no_envelope_never_decodes_bytes() {
740        // The envelope-free path is what external JSON (fromjson, access) uses:
741        // an object matching the byte-envelope shape stays a plain record, it is
742        // NOT silently decoded to Value::Bytes.
743        let envelope = crate::bytes::bytes_to_envelope(&[1u8, 2, 3]);
744        // The sniffing path DOES decode it (internal round-trip).
745        assert!(matches!(json_to_value(envelope.clone()), Value::Bytes(_)));
746        // The envelope-free path leaves it structured.
747        assert!(matches!(
748            json_to_value_no_envelope(envelope),
749            Value::Json(serde_json::Value::Object(_))
750        ));
751    }
752
753    #[test]
754    fn no_envelope_shares_unwrap_law_for_scalars() {
755        // Scalars unwrap identically to json_to_value; only the object arm differs.
756        assert_eq!(json_to_value_no_envelope(serde_json::json!(42)), Value::Int(42));
757        assert_eq!(json_to_value_no_envelope(serde_json::json!(1.5)), Value::Float(1.5));
758        assert_eq!(json_to_value_no_envelope(serde_json::json!(true)), Value::Bool(true));
759        assert_eq!(json_to_value_no_envelope(serde_json::json!("hi")), Value::String("hi".into()));
760        assert_eq!(json_to_value_no_envelope(serde_json::json!(null)), Value::Null);
761        assert!(matches!(
762            json_to_value_no_envelope(serde_json::json!([1, 2])),
763            Value::Json(serde_json::Value::Array(_))
764        ));
765    }
766
767    #[test]
768    fn output_payload_text_serializes_as_bare_string() {
769        // Wire compatibility: a text result's `out` stays a plain JSON string,
770        // exactly as when `out` was a `String`.
771        let r = ExecResult::success("hello");
772        let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
773        assert_eq!(json["out"], "hello");
774        // Round-trips back to a Text payload.
775        let back: ExecResult = serde_json::from_value(json).unwrap();
776        assert_eq!(&*back.text_out(), "hello");
777        assert!(!back.is_bytes());
778    }
779
780    #[test]
781    fn success_bytes_carries_binary_and_round_trips() {
782        let r = ExecResult::success_bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8
783        assert!(r.is_bytes());
784        assert_eq!(r.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
785        // try_text_out is the loud guard: invalid UTF-8 → error, not mangling.
786        assert!(r.try_text_out().is_err());
787        // text_out (infallible) decodes lossily — Phase-1 fallback.
788        assert!(r.text_out().contains('\u{fffd}'));
789        // Serializes as a base64 envelope and round-trips back to bytes.
790        let json: serde_json::Value = serde_json::to_value(&r).unwrap();
791        assert_eq!(json["out"]["_type"], "bytes");
792        let back: ExecResult = serde_json::from_value(json).unwrap();
793        assert_eq!(back.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
794    }
795
796    #[test]
797    fn valid_utf8_bytes_coerce_to_text() {
798        let r = ExecResult::success_bytes(b"plain text".to_vec());
799        assert!(r.is_bytes());
800        assert_eq!(r.try_text_out().unwrap(), "plain text");
801        assert_eq!(&*r.text_out(), "plain text");
802    }
803
804    #[test]
805    fn materialize_preserves_existing_out() {
806        use crate::output::OutputData;
807        let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
808        result.materialize();
809        assert_eq!(&*result.text_out(),"custom");
810    }
811
812    #[test]
813    fn take_output_for_stream_when_out_empty() {
814        use crate::output::{OutputData, OutputNode};
815        // Use structured output — text now goes to .out directly
816        let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
817        let mut result = ExecResult::with_output(nodes);
818        let taken = result.take_output_for_stream();
819        assert!(taken.is_some());
820        assert!(!result.has_output());
821    }
822
823    #[test]
824    fn with_output_simple_text_populates_out_directly() {
825        use crate::output::OutputData;
826        let result = ExecResult::with_output(OutputData::text("hello"));
827        // Simple text should go to .out, not .output
828        assert!(!result.has_output());
829        assert_eq!(&*result.text_out(), "hello");
830        // Even JSON-shaped text is NOT auto-parsed — .data stays None.
831        let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
832        assert!(json_result.data.is_none());
833    }
834
835
836    #[test]
837    fn clear_stdout_drops_data() {
838        // A stdout redirect clears the data-plane .data unconditionally.
839        let mut result = ExecResult::success_data(Value::Json(serde_json::json!([1, 2, 3])));
840        result.clear_stdout();
841        assert!(result.data.is_none(), "data-plane .data must clear");
842    }
843
844    #[test]
845    fn take_output_for_stream_when_out_populated() {
846        use crate::output::OutputData;
847        let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
848        let taken = result.take_output_for_stream();
849        assert!(taken.is_none());
850        assert!(result.has_output()); // not taken
851    }
852}