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/// A pending confirmation-latch request, decoded from a latched [`ExecResult`].
59///
60/// When the confirmation latch (`set -o latch`) gates a destructive operation,
61/// the kernel returns exit code 2 with this typed payload on the dedicated
62/// [`ExecResult::latch`] field. Embedders read it via
63/// [`ExecResult::latch_request`] — the seam to apply preapproval policy or a
64/// model review before approving the operation. It is deliberately *not* the
65/// data-plane [`ExecResult::data`]: a stdout redirect clears `.data` but never
66/// this control-plane signal, and it survives `--json` formatting (surfaced
67/// under a `latch` key in the error envelope).
68///
69/// To approve, re-run the *same argv* with `--confirm=<nonce>` (the `hint`
70/// shows the exact form). The nonce is command- and path-scoped, so it cannot
71/// authorize a different command or a path outside `paths`.
72#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct LatchRequest {
75    /// Confirmation nonce — pass back as `--confirm=<nonce>` on the same command.
76    pub nonce: String,
77    /// The canonical command being gated (e.g. `"rm"`, `"kaish-trash empty"`).
78    /// A human/display label — for a precise machine replay use `tool` + `argv`.
79    pub command: String,
80    /// The resolved paths the operation would touch. Empty for command-only ops.
81    pub paths: Vec<String>,
82    /// A ready-to-run confirmation command string (informational, for humans).
83    /// Machine fulfillment should prefer `Kernel::confirm` (which replays the
84    /// captured `tool`/`argv`); the hint is a display string and does not
85    /// robustly quote paths with spaces or glob characters.
86    pub hint: String,
87    /// The dispatch name of the gated tool (e.g. `"rm"`, `"kaish-trash"`), as
88    /// resolved at the dispatch seam — the argv0 for a replay via
89    /// `Kernel::execute_argv`. Empty only when the latch was produced outside a
90    /// dispatch (a direct `tool.execute` in a unit test).
91    #[serde(default)]
92    pub tool: String,
93    /// The exact captured argv (`ToolArgs::to_argv`) of the gated invocation,
94    /// minus the tool name and the `--confirm` nonce. `Kernel::confirm` prepends
95    /// `--confirm=<nonce>` and replays `execute_argv(tool, argv)` — the
96    /// highest-fidelity fulfillment, with no re-parsing of the `hint`.
97    #[serde(default)]
98    pub argv: Vec<String>,
99    /// Seconds until the nonce expires.
100    pub ttl: u64,
101    /// The id of the *backgrounded* job that raised this latch, if any —
102    /// `Some` only when the gate came from a job the `JobManager` is tracking
103    /// (`rm x &` reaching its gate), `None` for a foreground latch (the common
104    /// case: `rm x` gated directly at the dispatch seam, no job involved).
105    ///
106    /// Deliberately a bare `u64`, not a `JobId`: this crate (`kaish-types`) is
107    /// a dependency-light leaf with no notion of `JobId` — that type lives in
108    /// `kaish-kernel`'s scheduler module, which depends on `kaish-types`, not
109    /// the other way around. `kaish-kernel` re-wraps this as `JobId(id)` at
110    /// the two call sites that care ([`JobManager`]'s `Job::latch()` stamps
111    /// it; `Kernel::confirm` reads it back to retire the originating job
112    /// after a successful replay). Skipped on the wire when absent, so a
113    /// foreground latch's `--json` shape is unchanged.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub job_id: Option<u64>,
116}
117
118/// Returned when a binary result is asked to behave as text.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct BinaryNotText {
121    /// Number of binary bytes that could not be coerced.
122    pub len: usize,
123}
124
125impl std::fmt::Display for BinaryNotText {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "output is binary ({} bytes), not text — pipe through base64/xxd or redirect to a file",
130            self.len
131        )
132    }
133}
134
135impl std::error::Error for BinaryNotText {}
136
137/// The result of executing a command or pipeline.
138///
139/// `$?` in script syntax is the POSIX exit code (an integer). To read the
140/// previous command's structured `.data` (or its captured stdout) from
141/// inside a script, use the `kaish-last` builtin and pipe / capture its
142/// output. Inside Rust callers, read `.data`, `.text_out()`, etc. directly.
143///
144/// Notes on the fields:
145/// - `code` — exit code (0 = success)
146/// - `err` — error message if failed
147/// - `out` — raw stdout as string
148/// - `data` — structured data; only set by builtins/tools that opt in
149///   (e.g. `seq`, `jq`, `cut`, `find`, `glob`, `split`). External commands
150///   never populate this — pipe their stdout through `jq` to get it.
151#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
152#[non_exhaustive]
153pub struct ExecResult {
154    /// Exit code. 0 means success.
155    pub code: i64,
156    /// Standard output payload — text (canonical for pipes) or raw bytes.
157    out: OutputPayload,
158    /// Raw standard error as a string.
159    pub err: String,
160    /// Structured data — only populated when a builtin/tool sets it explicitly.
161    /// Stdout is *never* sniffed; this stays `None` for external commands.
162    pub data: Option<Value>,
163    /// Structured output data for rendering.
164    ///
165    /// Boxed like [`Self::latch`]: `OutputData` is ~120 B and `ExecResult` (and
166    /// the `ControlFlow` that wraps it) is returned up every level of deep
167    /// `$()`/pipeline recursion, so an inline `Option<OutputData>` fattened every
168    /// frame. The box is allocated only when a builtin sets structured output,
169    /// and it serializes identically (Box is transparent to serde). Private —
170    /// the public accessors below hand back plain `OutputData`/`&OutputData`, so
171    /// the boxing never leaks (GH #48, item 5).
172    output: Option<Box<OutputData>>,
173    /// True if output was capped and lost data. Either the output limiter
174    /// spilled the overflow to disk (the `out` message carries the path),
175    /// truncated it in memory (Memory spill mode — head+tail only, no
176    /// recoverable file), or an external command's stdout overflowed its
177    /// fixed-size capture ring with output limiting off (GH #191) — the
178    /// capture buffer evicted its head with no spill file at all. All cases
179    /// remap the exit code to 3.
180    pub did_spill: bool,
181    /// The command's original exit code before spill logic overwrote it with 2 or 3.
182    /// Present only when `did_spill` is true and `code` was changed.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub original_code: Option<i64>,
185    /// MIME content type hint (e.g., "text/markdown", "image/svg+xml").
186    /// When set, downstream consumers can use this instead of sniffing content.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub content_type: Option<String>,
189    /// Opaque key-value context propagated from tools through execution.
190    /// Intermediaries (kaish) carry but don't interpret. Consumers read known keys.
191    /// Follows W3C Baggage semantics — useful for OTel trace propagation,
192    /// application-level hints, etc.
193    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
194    pub baggage: BTreeMap<String, String>,
195    /// A pending confirmation-latch request — the *control-plane* signal a
196    /// gated destructive op (`rm`/`kaish-trash`/an overwrite under `set -o
197    /// latch`) raises alongside exit code 2. First-class and typed, distinct
198    /// from the data-plane `.data`: a stdout redirect clears `.data` but never
199    /// this. Read it via [`Self::latch_request`]; set it via
200    /// `ToolCtx::latch_result`.
201    ///
202    /// Boxed: `ExecResult` is returned up every level of deep `$()`/pipeline
203    /// recursion, and `LatchRequest` is ~150 bytes — inline it would fatten
204    /// every stack frame and cost interpreter stack headroom (GH #46/#47). The
205    /// box is allocated only when a latch actually fires. Serializes identically
206    /// to an unboxed `Option` (Box is transparent to serde).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub latch: Option<Box<LatchRequest>>,
209}
210
211impl ExecResult {
212    /// Create a successful result with output.
213    pub fn success(out: impl Into<String>) -> Self {
214        Self {
215            code: 0,
216            out: OutputPayload::Text(out.into()),
217            err: String::new(),
218            data: None,
219            output: None,
220            did_spill: false,
221            original_code: None,
222            content_type: None,
223            baggage: BTreeMap::new(),
224            latch: None,
225        }
226    }
227
228    /// Create a successful result with structured output data.
229    ///
230    /// The `OutputData` is the source of truth. Text is materialized lazily
231    /// via `text_out()` when needed (pipes, redirects, command substitution).
232    pub fn with_output(output: OutputData) -> Self {
233        // Simple text: move string into .out directly for efficient Cow::Borrowed.
234        // Structured output: store in .output, materialize lazily.
235        match output.into_text() {
236            Ok(text) => Self::success(text),
237            Err(output) => Self {
238                code: 0,
239                out: OutputPayload::Text(String::new()),
240                err: String::new(),
241                data: None,
242                output: Some(Box::new(output)),
243                did_spill: false,
244                original_code: None,
245                content_type: None,
246                baggage: BTreeMap::new(),
247                latch: None,
248            },
249        }
250    }
251
252    /// Create a successful result whose stdout is raw bytes (binary payload).
253    pub fn success_bytes(bytes: Vec<u8>) -> Self {
254        let mut r = Self::success("");
255        r.out = OutputPayload::Bytes(bytes);
256        r
257    }
258
259    /// Create a successful result from bytes, applying the coercion rule at the
260    /// producer: valid UTF-8 becomes a text result, anything else a binary
261    /// `Bytes` result. This is the single place pass-through/decoder builtins
262    /// (`cat`, `head -c`, `base64 -d`, `xxd -r`, `tee`, …) decide text-vs-binary,
263    /// so text workflows stay text and only real binary flows as bytes.
264    pub fn success_text_or_bytes(bytes: Vec<u8>) -> Self {
265        match String::from_utf8(bytes) {
266            Ok(text) => Self::success(text),
267            Err(e) => Self::success_bytes(e.into_bytes()),
268        }
269    }
270
271    /// Create a successful result with structured data.
272    pub fn success_data(data: Value) -> Self {
273        let out = value_to_json(&data).to_string();
274        Self {
275            code: 0,
276            out: OutputPayload::Text(out),
277            err: String::new(),
278            data: Some(data),
279            output: None,
280            did_spill: false,
281            original_code: None,
282            content_type: None,
283            baggage: BTreeMap::new(),
284            latch: None,
285        }
286    }
287
288    /// Create a successful result with both text output and structured data.
289    ///
290    /// Use this when a command should have:
291    /// - Text output for pipes and traditional shell usage
292    /// - Structured data for iteration and programmatic access
293    ///
294    /// The data field takes precedence for command substitution in contexts
295    /// like `for i in $(cmd)` where the structured data can be iterated.
296    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
297        Self {
298            code: 0,
299            out: OutputPayload::Text(out.into()),
300            err: String::new(),
301            data: Some(data),
302            output: None,
303            did_spill: false,
304            original_code: None,
305            content_type: None,
306            baggage: BTreeMap::new(),
307            latch: None,
308        }
309    }
310
311    /// Create a failed result with an error message.
312    pub fn failure(code: i64, err: impl Into<String>) -> Self {
313        Self {
314            code,
315            out: OutputPayload::Text(String::new()),
316            err: err.into(),
317            data: None,
318            output: None,
319            did_spill: false,
320            original_code: None,
321            content_type: None,
322            baggage: BTreeMap::new(),
323            latch: None,
324        }
325    }
326
327    /// Create a result from raw output streams.
328    ///
329    /// `data` is left empty — kaish does not sniff stdout for JSON. To get
330    /// structured iteration from an external command, pipe through `jq`:
331    /// `for i in $(curl ... | jq .); do ...`.
332    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
333        Self {
334            code,
335            out: OutputPayload::Text(stdout.into()),
336            err: stderr.into(),
337            data: None,
338            output: None,
339            did_spill: false,
340            original_code: None,
341            content_type: None,
342            baggage: BTreeMap::new(),
343            latch: None,
344        }
345    }
346
347    /// Create a successful result with structured output and explicit pipe text.
348    ///
349    /// Use this when a builtin needs custom text formatting that differs from
350    /// the canonical `OutputData::to_canonical_string()` representation.
351    pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
352        Self {
353            code: 0,
354            out: OutputPayload::Text(text.into()),
355            err: String::new(),
356            data: None,
357            output: Some(Box::new(output)),
358            did_spill: false,
359            original_code: None,
360            content_type: None,
361            baggage: BTreeMap::new(),
362            latch: None,
363        }
364    }
365
366    /// Create a result from parts — for kernel struct literal sites.
367    pub fn from_parts(
368        code: i64,
369        out: String,
370        err: String,
371        data: Option<Value>,
372    ) -> Self {
373        Self {
374            code,
375            out: OutputPayload::Text(out),
376            err,
377            data,
378            output: None,
379            did_spill: false,
380            original_code: None,
381            content_type: None,
382            baggage: BTreeMap::new(),
383            latch: None,
384        }
385    }
386
387    /// Builder: set the exit code, returning self for chaining.
388    pub fn with_code(mut self, code: i64) -> Self {
389        self.code = code;
390        self
391    }
392
393    // ── Read accessors ──
394
395    /// Get text output, materializing from OutputData on demand.
396    ///
397    /// Returns the text payload if non-empty, otherwise falls back to
398    /// `OutputData::to_canonical_string()`. This is the canonical way to
399    /// get text for pipes, command substitution, and file redirects.
400    ///
401    /// **Binary payloads** decode lossily here (`U+FFFD` for invalid UTF-8).
402    /// Several builtins already produce a `Bytes` payload (`cat`/`head`/`tail`/
403    /// `base64 -d`/`xxd -r`/`dd`/`tee`/external commands), so this lossy path
404    /// IS reachable — callers that need to catch binary rather than silently
405    /// mangle it should use [`Self::try_text_out`] instead, which loud-errors
406    /// with [`BinaryNotText`] on invalid UTF-8. See `docs/binary-data.md`.
407    pub fn text_out(&self) -> Cow<'_, str> {
408        match &self.out {
409            OutputPayload::Text(s) if !s.is_empty() => Cow::Borrowed(s),
410            OutputPayload::Bytes(b) => match std::str::from_utf8(b) {
411                Ok(s) => Cow::Borrowed(s),
412                Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
413            },
414            // Empty text → fall back to structured output's canonical string.
415            _ => match self.output {
416                Some(ref output) => Cow::Owned(output.to_canonical_string()),
417                None => Cow::Borrowed(""),
418            },
419        }
420    }
421
422    /// Get text output, or a [`BinaryNotText`] error if the payload is binary
423    /// and not valid UTF-8. This is the boundary guard for text sinks (`echo`,
424    /// interpolation, `$()` capture) — adopted as those paths grow byte
425    /// awareness (Phase 2). Valid-UTF-8 bytes coerce; everything else is loud.
426    pub fn try_text_out(&self) -> Result<Cow<'_, str>, BinaryNotText> {
427        match &self.out {
428            OutputPayload::Bytes(b) => std::str::from_utf8(b)
429                .map(Cow::Borrowed)
430                .map_err(|_| BinaryNotText { len: b.len() }),
431            _ => Ok(self.text_out()),
432        }
433    }
434
435    /// Raw bytes if this result carries a binary payload, else `None`.
436    pub fn out_bytes(&self) -> Option<&[u8]> {
437        match &self.out {
438            OutputPayload::Bytes(b) => Some(b),
439            OutputPayload::Text(_) => None,
440        }
441    }
442
443    /// True if the stdout payload is raw bytes rather than text.
444    pub fn is_bytes(&self) -> bool {
445        matches!(self.out, OutputPayload::Bytes(_))
446    }
447
448    /// Get a reference to structured output data.
449    pub fn output(&self) -> Option<&OutputData> {
450        self.output.as_deref()
451    }
452
453    /// True if structured output data is present.
454    pub fn has_output(&self) -> bool {
455        self.output.is_some()
456    }
457
458    // ── Mutation accessors ──
459
460    /// Replace `.out` with text.
461    pub fn set_out(&mut self, s: String) {
462        self.out = OutputPayload::Text(s);
463    }
464
465    /// Replace `.out` with raw bytes (binary payload).
466    pub fn set_out_bytes(&mut self, b: Vec<u8>) {
467        self.out = OutputPayload::Bytes(b);
468    }
469
470    /// Append text to `.out`. A binary payload is appended to as raw UTF-8 bytes.
471    pub fn push_out(&mut self, s: &str) {
472        match &mut self.out {
473            OutputPayload::Text(t) => t.push_str(s),
474            OutputPayload::Bytes(b) => b.extend_from_slice(s.as_bytes()),
475        }
476    }
477
478    /// Clear `.out` back to empty text.
479    pub fn clear_out(&mut self) {
480        self.out = OutputPayload::Text(String::new());
481    }
482
483    /// Drop every representation of stdout: the text `.out`, the structured
484    /// `.output`, and the data-plane `.data` sideband. Used when a stdout
485    /// redirect (`> file`, `>> file`, `&> file`, `1>&2`) has consumed the
486    /// command's output — the bytes went to the file (or stderr), so nothing
487    /// flows onward to a pipe, a `$(...)` capture, or the `.data` sideband.
488    /// Clearing all three together keeps them from drifting: a redirect that
489    /// cleared `.out`/`.output` but left `.data` would leak structured data past
490    /// its own redirect (`x=$(fromjson … > file)` capturing the value instead
491    /// of `""`).
492    ///
493    /// The confirmation-latch request is untouched by design: it is a
494    /// *control-plane* signal on the dedicated `.latch` field, not stdout, so a
495    /// redirect can't drop it — `rm precious > log` still gates.
496    pub fn clear_stdout(&mut self) {
497        self.out = OutputPayload::Text(String::new());
498        self.output = None;
499        self.data = None;
500    }
501
502    /// Replace `.output`.
503    pub fn set_output(&mut self, o: Option<OutputData>) {
504        self.output = o.map(Box::new);
505    }
506
507    /// Take `.output`, leaving None.
508    pub fn take_output(&mut self) -> Option<OutputData> {
509        self.output.take().map(|o| *o)
510    }
511
512    /// Materialize: if `.out` is empty and `.output` is present,
513    /// populate `.out` from canonical string and clear `.output`.
514    pub fn materialize(&mut self) {
515        if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
516            if let Some(ref output) = self.output {
517                self.out = OutputPayload::Text(output.to_canonical_string());
518            }
519        }
520        self.output = None;
521    }
522
523    /// Take `.output` only if `.out` is empty (no custom text),
524    /// so caller can stream directly without materializing.
525    pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
526        if matches!(&self.out, OutputPayload::Text(s) if s.is_empty()) {
527            self.output.take().map(|o| *o)
528        } else {
529            None
530        }
531    }
532
533    /// True if the command succeeded (exit code 0).
534    pub fn ok(&self) -> bool {
535        self.code == 0
536    }
537
538    /// The pending confirmation-latch request, if this result is a latch gate.
539    ///
540    /// A gated destructive op (`rm`/`kaish-trash`/an overwrite under `set -o
541    /// latch`) returns exit code 2 with the typed request on the `.latch` field.
542    /// This is the seam an embedder hooks to apply preapproval policy or a model
543    /// review before re-running the command with `--confirm=<nonce>`, instead of
544    /// string-matching the error. A plain usage error (also exit 2, but no
545    /// latch) returns `None`. Unlike the data-plane `.data`, `.latch` survives
546    /// `--json` formatting, so this is safe to call before or after it.
547    pub fn latch_request(&self) -> Option<LatchRequest> {
548        self.latch.as_deref().cloned()
549    }
550
551    /// Set content type hint, returning self for chaining.
552    pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
553        self.content_type = Some(ct.into());
554        self
555    }
556
557}
558
559/// Convert serde_json::Value to our AST Value.
560///
561/// Primitives are mapped to their corresponding Value variants.
562/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
563pub fn json_to_value(json: serde_json::Value) -> Value {
564    match json {
565        serde_json::Value::Null => Value::Null,
566        serde_json::Value::Bool(b) => Value::Bool(b),
567        serde_json::Value::Number(n) => {
568            if let Some(i) = n.as_i64() {
569                Value::Int(i)
570            } else if let Some(f) = n.as_f64() {
571                Value::Float(f)
572            } else {
573                Value::String(n.to_string())
574            }
575        }
576        serde_json::Value::String(s) => Value::String(s),
577        // A base64 byte envelope round-trips back to inline Bytes; any other
578        // object/array stays structured Json.
579        serde_json::Value::Object(_) => match crate::bytes::envelope_to_bytes(&json) {
580            Some(bytes) => Value::Bytes(bytes),
581            None => Value::Json(json),
582        },
583        serde_json::Value::Array(_) => Value::Json(json),
584    }
585}
586
587/// Convert serde_json::Value to our AST Value **without** bytes-envelope sniffing.
588///
589/// External JSON (from `fromjson`, or native access traversal) must never
590/// silently become a `Value::Bytes` just because an object happens to match the
591/// base64 envelope shape (`{"_type":"bytes",…}`). That auto-decode is a feature
592/// of *internal* round-tripping only — it would be a silent, surprising
593/// conversion on untrusted input. Otherwise this is the same unwrap law as
594/// [`json_to_value`]: JSON scalars unwrap to native `Value` variants, and only
595/// objects/arrays stay `Value::Json`.
596pub fn json_to_value_no_envelope(json: serde_json::Value) -> Value {
597    match json {
598        serde_json::Value::Null => Value::Null,
599        serde_json::Value::Bool(b) => Value::Bool(b),
600        serde_json::Value::Number(n) => {
601            if let Some(i) = n.as_i64() {
602                Value::Int(i)
603            } else if let Some(f) = n.as_f64() {
604                Value::Float(f)
605            } else {
606                Value::String(n.to_string())
607            }
608        }
609        serde_json::Value::String(s) => Value::String(s),
610        // Objects and arrays stay structured — an envelope-shaped object is a
611        // plain record here, not decoded to bytes.
612        serde_json::Value::Object(_) | serde_json::Value::Array(_) => Value::Json(json),
613    }
614}
615
616/// Convert our AST Value to serde_json::Value for serialization.
617pub fn value_to_json(value: &Value) -> serde_json::Value {
618    match value {
619        Value::Null => serde_json::Value::Null,
620        Value::Bool(b) => serde_json::Value::Bool(*b),
621        Value::Int(i) => serde_json::Value::Number((*i).into()),
622        Value::Float(f) => {
623            // JSON has no NaN/Infinity. Rather than silently collapse them to
624            // null (data loss), serialize the non-finite value to its string
625            // form ("NaN", "inf", "-inf") so the information survives the trip.
626            serde_json::Number::from_f64(*f)
627                .map(serde_json::Value::Number)
628                .unwrap_or_else(|| serde_json::Value::String(f.to_string()))
629        }
630        Value::String(s) => serde_json::Value::String(s.clone()),
631        Value::Json(json) => json.clone(),
632        Value::Bytes(data) => crate::bytes::bytes_to_envelope(data),
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn success_creates_ok_result() {
642        let result = ExecResult::success("hello world");
643        assert!(result.ok());
644        assert_eq!(result.code, 0);
645        assert_eq!(&*result.text_out(),"hello world");
646        assert!(result.err.is_empty());
647    }
648
649    #[test]
650    fn value_to_json_finite_float_is_number() {
651        assert_eq!(value_to_json(&Value::Float(3.5)), serde_json::json!(3.5));
652    }
653
654    #[test]
655    fn value_to_json_non_finite_float_serializes_to_string() {
656        // JSON has no NaN/Infinity — preserve the info as a string, never null.
657        assert_eq!(value_to_json(&Value::Float(f64::NAN)), serde_json::json!("NaN"));
658        assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), serde_json::json!("inf"));
659        assert_eq!(
660            value_to_json(&Value::Float(f64::NEG_INFINITY)),
661            serde_json::json!("-inf")
662        );
663        // Crucially: not null (the old data-losing behavior).
664        assert_ne!(value_to_json(&Value::Float(f64::NAN)), serde_json::Value::Null);
665    }
666
667    #[test]
668    fn failure_creates_non_ok_result() {
669        let result = ExecResult::failure(1, "command not found");
670        assert!(!result.ok());
671        assert_eq!(result.code, 1);
672        assert_eq!(result.err, "command not found");
673    }
674
675    #[test]
676    fn success_does_not_sniff_json_stdout() {
677        // External-command stdout is never sniffed for JSON. Tools that want
678        // structured data must call success_with_data() / success_data().
679        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
680        assert!(result.data.is_none());
681        assert_eq!(&*result.text_out(),r#"{"count": 42, "items": ["a", "b"]}"#);
682    }
683
684    #[test]
685    fn from_output_does_not_sniff_json_stdout() {
686        let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
687        assert!(result.data.is_none());
688        assert_eq!(&*result.text_out(),"[1, 2, 3]");
689    }
690
691    #[test]
692    fn non_json_stdout_has_no_data() {
693        let result = ExecResult::success("just plain text");
694        assert!(result.data.is_none());
695    }
696
697    #[test]
698    fn success_data_creates_result_with_value() {
699        let value = Value::String("test data".into());
700        let result = ExecResult::success_data(value.clone());
701        assert!(result.ok());
702        assert_eq!(result.data, Some(value));
703    }
704
705    #[test]
706    fn did_spill_defaults_to_false() {
707        assert!(!ExecResult::success("hi").did_spill);
708        assert!(!ExecResult::failure(1, "err").did_spill);
709        assert!(!ExecResult::from_output(0, "out", "err").did_spill);
710    }
711
712    #[test]
713    fn did_spill_is_serialized() {
714        let mut result = ExecResult::success("hi");
715        result.did_spill = true;
716        let json = serde_json::to_string(&result).unwrap();
717        assert!(json.contains("\"did_spill\":true"));
718    }
719
720    #[test]
721    fn original_code_omitted_when_none() {
722        let result = ExecResult::success("hi");
723        let json = serde_json::to_string(&result).unwrap();
724        assert!(!json.contains("original_code"));
725    }
726
727    #[test]
728    fn original_code_present_when_set() {
729        let mut result = ExecResult::success("hi");
730        result.original_code = Some(0);
731        let json = serde_json::to_string(&result).unwrap();
732        assert!(json.contains("\"original_code\":0"));
733    }
734
735    #[test]
736    fn default_is_empty_success() {
737        let result = ExecResult::default();
738        assert!(result.ok());
739        assert!(result.text_out().is_empty());
740        assert!(result.data.is_none());
741        assert!(result.content_type.is_none());
742        assert!(result.baggage.is_empty());
743    }
744
745    #[test]
746    fn from_parts_creates_result() {
747        let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
748        assert_eq!(result.code, 42);
749        assert_eq!(&*result.text_out(),"out");
750        assert_eq!(result.err, "err");
751        assert!(result.data.is_none());
752        assert!(result.output.is_none());
753    }
754
755    #[test]
756    fn with_code_sets_code() {
757        let result = ExecResult::success("hi").with_code(42);
758        assert_eq!(result.code, 42);
759        assert_eq!(&*result.text_out(),"hi");
760    }
761
762    #[test]
763    fn output_getter() {
764        use crate::output::{OutputData, OutputNode};
765        // Use structured (non-text) output so with_output preserves .output
766        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
767        let result = ExecResult::with_output(nodes);
768        assert!(result.output().is_some());
769        assert!(result.has_output());
770
771        // Simple text now routes to .out, so output is None
772        let text_result = ExecResult::with_output(OutputData::text("test"));
773        assert!(!text_result.has_output());
774        assert_eq!(&*text_result.text_out(), "test");
775
776        let plain = ExecResult::success("text");
777        assert!(plain.output().is_none());
778        assert!(!plain.has_output());
779    }
780
781    #[test]
782    fn set_out_and_push_out_and_clear_out() {
783        let mut result = ExecResult::success("");
784        result.set_out("hello".into());
785        assert_eq!(&*result.text_out(),"hello");
786        result.push_out(" world");
787        assert_eq!(&*result.text_out(),"hello world");
788        result.clear_out();
789        assert!(result.text_out().is_empty());
790    }
791
792    #[test]
793    fn set_output_and_take_output() {
794        use crate::output::OutputData;
795        let mut result = ExecResult::success("");
796        assert!(result.take_output().is_none());
797
798        result.set_output(Some(OutputData::text("data")));
799        assert!(result.has_output());
800
801        let taken = result.take_output();
802        assert!(taken.is_some());
803        assert!(!result.has_output());
804    }
805
806    #[test]
807    fn materialize_populates_out_from_output() {
808        use crate::output::{OutputData, OutputNode};
809        // Use structured output to test materialization
810        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
811        let mut result = ExecResult::with_output(nodes);
812        // Raw text payload is empty before materialize (text_out() would
813        // already fall back to the OutputData canonical string).
814        assert!(matches!(&result.out, OutputPayload::Text(s) if s.is_empty()));
815        assert!(result.has_output());
816        result.materialize();
817        assert_eq!(&*result.text_out(),"a\nb");
818        assert!(result.output.is_none());
819    }
820
821    #[test]
822    fn value_bytes_round_trips_through_envelope() {
823        let v = Value::Bytes(vec![0u8, 1, 2, 255, 128]);
824        let json = value_to_json(&v);
825        assert_eq!(json["_type"], "bytes");
826        assert_eq!(json["len"], 5);
827        // json_to_value recognizes the envelope and reconstructs Bytes.
828        assert_eq!(json_to_value(json), v);
829        // A plain object is NOT mistaken for bytes.
830        let obj = serde_json::json!({"name": "amy"});
831        assert!(matches!(json_to_value(obj), Value::Json(_)));
832    }
833
834    #[test]
835    fn no_envelope_never_decodes_bytes() {
836        // The envelope-free path is what external JSON (fromjson, access) uses:
837        // an object matching the byte-envelope shape stays a plain record, it is
838        // NOT silently decoded to Value::Bytes.
839        let envelope = crate::bytes::bytes_to_envelope(&[1u8, 2, 3]);
840        // The sniffing path DOES decode it (internal round-trip).
841        assert!(matches!(json_to_value(envelope.clone()), Value::Bytes(_)));
842        // The envelope-free path leaves it structured.
843        assert!(matches!(
844            json_to_value_no_envelope(envelope),
845            Value::Json(serde_json::Value::Object(_))
846        ));
847    }
848
849    #[test]
850    fn no_envelope_shares_unwrap_law_for_scalars() {
851        // Scalars unwrap identically to json_to_value; only the object arm differs.
852        assert_eq!(json_to_value_no_envelope(serde_json::json!(42)), Value::Int(42));
853        assert_eq!(json_to_value_no_envelope(serde_json::json!(1.5)), Value::Float(1.5));
854        assert_eq!(json_to_value_no_envelope(serde_json::json!(true)), Value::Bool(true));
855        assert_eq!(json_to_value_no_envelope(serde_json::json!("hi")), Value::String("hi".into()));
856        assert_eq!(json_to_value_no_envelope(serde_json::json!(null)), Value::Null);
857        assert!(matches!(
858            json_to_value_no_envelope(serde_json::json!([1, 2])),
859            Value::Json(serde_json::Value::Array(_))
860        ));
861    }
862
863    #[test]
864    fn output_payload_text_serializes_as_bare_string() {
865        // Wire compatibility: a text result's `out` stays a plain JSON string,
866        // exactly as when `out` was a `String`.
867        let r = ExecResult::success("hello");
868        let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
869        assert_eq!(json["out"], "hello");
870        // Round-trips back to a Text payload.
871        let back: ExecResult = serde_json::from_value(json).unwrap();
872        assert_eq!(&*back.text_out(), "hello");
873        assert!(!back.is_bytes());
874    }
875
876    #[test]
877    fn success_bytes_carries_binary_and_round_trips() {
878        let r = ExecResult::success_bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8
879        assert!(r.is_bytes());
880        assert_eq!(r.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
881        // try_text_out is the loud guard: invalid UTF-8 → error, not mangling.
882        assert!(r.try_text_out().is_err());
883        // text_out (infallible) decodes lossily — Phase-1 fallback.
884        assert!(r.text_out().contains('\u{fffd}'));
885        // Serializes as a base64 envelope and round-trips back to bytes.
886        let json: serde_json::Value = serde_json::to_value(&r).unwrap();
887        assert_eq!(json["out"]["_type"], "bytes");
888        let back: ExecResult = serde_json::from_value(json).unwrap();
889        assert_eq!(back.out_bytes(), Some(&[0u8, 159, 146, 150][..]));
890    }
891
892    #[test]
893    fn valid_utf8_bytes_coerce_to_text() {
894        let r = ExecResult::success_bytes(b"plain text".to_vec());
895        assert!(r.is_bytes());
896        assert_eq!(r.try_text_out().unwrap(), "plain text");
897        assert_eq!(&*r.text_out(), "plain text");
898    }
899
900    #[test]
901    fn materialize_preserves_existing_out() {
902        use crate::output::OutputData;
903        let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
904        result.materialize();
905        assert_eq!(&*result.text_out(),"custom");
906    }
907
908    #[test]
909    fn take_output_for_stream_when_out_empty() {
910        use crate::output::{OutputData, OutputNode};
911        // Use structured output — text now goes to .out directly
912        let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
913        let mut result = ExecResult::with_output(nodes);
914        let taken = result.take_output_for_stream();
915        assert!(taken.is_some());
916        assert!(!result.has_output());
917    }
918
919    #[test]
920    fn with_output_simple_text_populates_out_directly() {
921        use crate::output::OutputData;
922        let result = ExecResult::with_output(OutputData::text("hello"));
923        // Simple text should go to .out, not .output
924        assert!(!result.has_output());
925        assert_eq!(&*result.text_out(), "hello");
926        // Even JSON-shaped text is NOT auto-parsed — .data stays None.
927        let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
928        assert!(json_result.data.is_none());
929    }
930
931    fn latch_req(paths: &[&str]) -> LatchRequest {
932        LatchRequest {
933            nonce: "a3f7b2c1".to_string(),
934            command: "rm".to_string(),
935            paths: paths.iter().map(|p| (*p).to_string()).collect(),
936            hint: "rm --confirm=\"a3f7b2c1\" important.dat".to_string(),
937            tool: "rm".to_string(),
938            argv: paths.iter().map(|p| (*p).to_string()).collect(),
939            ttl: 60,
940            job_id: None,
941        }
942    }
943
944    #[test]
945    fn latch_request_reads_the_latch_field() {
946        let mut result = ExecResult::failure(2, "rm: confirmation required (latch enabled)");
947        result.latch = Some(Box::new(latch_req(&["important.dat"])));
948
949        let req = result.latch_request().expect("a latch request");
950        assert_eq!(req.nonce, "a3f7b2c1");
951        assert_eq!(req.command, "rm");
952        assert_eq!(req.paths, vec!["important.dat".to_string()]);
953        assert_eq!(req.ttl, 60);
954        assert!(req.hint.contains("--confirm"));
955    }
956
957    #[test]
958    fn latch_request_handles_command_only_empty_paths() {
959        let mut result = ExecResult::failure(2, "kaish-trash empty: confirmation required");
960        result.latch = Some(Box::new(LatchRequest {
961            nonce: "deadbeef".to_string(),
962            command: "kaish-trash empty".to_string(),
963            paths: vec![],
964            hint: "kaish-trash empty --confirm=deadbeef".to_string(),
965            tool: "kaish-trash".to_string(),
966            argv: vec!["--".to_string(), "empty".to_string()],
967            ttl: 60,
968            job_id: None,
969        }));
970
971        let req = result.latch_request().expect("a latch request");
972        assert_eq!(req.command, "kaish-trash empty");
973        assert!(req.paths.is_empty());
974    }
975
976    #[test]
977    fn latch_request_none_when_no_latch_set() {
978        // A success result and a plain exit-2 usage error both carry no latch.
979        assert!(ExecResult::success("").latch_request().is_none());
980        assert!(ExecResult::failure(2, "rm: unknown flag --bogus")
981            .latch_request()
982            .is_none());
983    }
984
985    #[test]
986    fn latch_request_ignores_data_plane_data() {
987        // Data-plane `.data` (even on an exit-2 result) is never a latch — the
988        // latch lives on its own field. Structural guarantee, pinned.
989        let mut result = ExecResult::failure(2, "boom");
990        result.data = Some(Value::Json(serde_json::json!({"count": 3})));
991        assert!(result.latch_request().is_none());
992    }
993
994    #[test]
995    fn clear_stdout_drops_data_but_never_the_latch() {
996        // A stdout redirect clears the data-plane .data unconditionally, but the
997        // control-plane latch on its own field survives (rm precious > log still
998        // gates). Guards the plane split at the type level.
999        let mut result = ExecResult::success_data(Value::Json(serde_json::json!([1, 2, 3])));
1000        result.latch = Some(Box::new(latch_req(&["precious.txt"])));
1001        result.clear_stdout();
1002        assert!(result.data.is_none(), "data-plane .data must clear");
1003        assert!(
1004            result.latch_request().is_some(),
1005            "control-plane latch must survive a stdout redirect"
1006        );
1007    }
1008
1009    #[test]
1010    fn take_output_for_stream_when_out_populated() {
1011        use crate::output::OutputData;
1012        let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
1013        let taken = result.take_output_for_stream();
1014        assert!(taken.is_none());
1015        assert!(result.has_output()); // not taken
1016    }
1017}