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