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/// The result of executing a command or pipeline.
12///
13/// `$?` in script syntax is the POSIX exit code (an integer). To read the
14/// previous command's structured `.data` (or its captured stdout) from
15/// inside a script, use the `kaish-last` builtin and pipe / capture its
16/// output. Inside Rust callers, read `.data`, `.text_out()`, etc. directly.
17///
18/// Notes on the fields:
19/// - `code` — exit code (0 = success)
20/// - `err` — error message if failed
21/// - `out` — raw stdout as string
22/// - `data` — structured data; only set by builtins/tools that opt in
23///   (e.g. `seq`, `jq`, `cut`, `find`, `glob`, `split`). External commands
24///   never populate this — pipe their stdout through `jq` to get it.
25#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
26pub struct ExecResult {
27    /// Exit code. 0 means success.
28    pub code: i64,
29    /// Raw standard output as a string (canonical for pipes).
30    out: String,
31    /// Raw standard error as a string.
32    pub err: String,
33    /// Structured data — only populated when a builtin/tool sets it explicitly.
34    /// Stdout is *never* sniffed; this stays `None` for external commands.
35    pub data: Option<Value>,
36    /// Structured output data for rendering.
37    output: Option<OutputData>,
38    /// True if output was truncated and written to a spill file.
39    pub did_spill: bool,
40    /// The command's original exit code before spill logic overwrote it with 2 or 3.
41    /// Present only when `did_spill` is true and `code` was changed.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub original_code: Option<i64>,
44    /// MIME content type hint (e.g., "text/markdown", "image/svg+xml").
45    /// When set, downstream consumers can use this instead of sniffing content.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub content_type: Option<String>,
48    /// Opaque key-value context propagated from tools through execution.
49    /// Intermediaries (kaish) carry but don't interpret. Consumers read known keys.
50    /// Follows W3C Baggage semantics — useful for OTel trace propagation,
51    /// application-level hints, etc.
52    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
53    pub baggage: BTreeMap<String, String>,
54}
55
56impl ExecResult {
57    /// Create a successful result with output.
58    pub fn success(out: impl Into<String>) -> Self {
59        Self {
60            code: 0,
61            out: out.into(),
62            err: String::new(),
63            data: None,
64            output: None,
65            did_spill: false,
66            original_code: None,
67            content_type: None,
68            baggage: BTreeMap::new(),
69        }
70    }
71
72    /// Create a successful result with structured output data.
73    ///
74    /// The `OutputData` is the source of truth. Text is materialized lazily
75    /// via `text_out()` when needed (pipes, redirects, command substitution).
76    pub fn with_output(output: OutputData) -> Self {
77        // Simple text: move string into .out directly for efficient Cow::Borrowed.
78        // Structured output: store in .output, materialize lazily.
79        match output.into_text() {
80            Ok(text) => Self::success(text),
81            Err(output) => Self {
82                code: 0,
83                out: String::new(),
84                err: String::new(),
85                data: None,
86                output: Some(output),
87                did_spill: false,
88                original_code: None,
89                content_type: None,
90                baggage: BTreeMap::new(),
91            },
92        }
93    }
94
95    /// Create a successful result with structured data.
96    pub fn success_data(data: Value) -> Self {
97        let out = value_to_json(&data).to_string();
98        Self {
99            code: 0,
100            out,
101            err: String::new(),
102            data: Some(data),
103            output: None,
104            did_spill: false,
105            original_code: None,
106            content_type: None,
107            baggage: BTreeMap::new(),
108        }
109    }
110
111    /// Create a successful result with both text output and structured data.
112    ///
113    /// Use this when a command should have:
114    /// - Text output for pipes and traditional shell usage
115    /// - Structured data for iteration and programmatic access
116    ///
117    /// The data field takes precedence for command substitution in contexts
118    /// like `for i in $(cmd)` where the structured data can be iterated.
119    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
120        Self {
121            code: 0,
122            out: out.into(),
123            err: String::new(),
124            data: Some(data),
125            output: None,
126            did_spill: false,
127            original_code: None,
128            content_type: None,
129            baggage: BTreeMap::new(),
130        }
131    }
132
133    /// Create a failed result with an error message.
134    pub fn failure(code: i64, err: impl Into<String>) -> Self {
135        Self {
136            code,
137            out: String::new(),
138            err: err.into(),
139            data: None,
140            output: None,
141            did_spill: false,
142            original_code: None,
143            content_type: None,
144            baggage: BTreeMap::new(),
145        }
146    }
147
148    /// Create a result from raw output streams.
149    ///
150    /// `data` is left empty — kaish does not sniff stdout for JSON. To get
151    /// structured iteration from an external command, pipe through `jq`:
152    /// `for i in $(curl ... | jq .); do ...`.
153    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
154        Self {
155            code,
156            out: stdout.into(),
157            err: stderr.into(),
158            data: None,
159            output: None,
160            did_spill: false,
161            original_code: None,
162            content_type: None,
163            baggage: BTreeMap::new(),
164        }
165    }
166
167    /// Create a successful result with structured output and explicit pipe text.
168    ///
169    /// Use this when a builtin needs custom text formatting that differs from
170    /// the canonical `OutputData::to_canonical_string()` representation.
171    pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
172        Self {
173            code: 0,
174            out: text.into(),
175            err: String::new(),
176            data: None,
177            output: Some(output),
178            did_spill: false,
179            original_code: None,
180            content_type: None,
181            baggage: BTreeMap::new(),
182        }
183    }
184
185    /// Create a result from parts — for kernel struct literal sites.
186    pub fn from_parts(
187        code: i64,
188        out: String,
189        err: String,
190        data: Option<Value>,
191    ) -> Self {
192        Self {
193            code,
194            out,
195            err,
196            data,
197            output: None,
198            did_spill: false,
199            original_code: None,
200            content_type: None,
201            baggage: BTreeMap::new(),
202        }
203    }
204
205    /// Builder: set the exit code, returning self for chaining.
206    pub fn with_code(mut self, code: i64) -> Self {
207        self.code = code;
208        self
209    }
210
211    // ── Read accessors ──
212
213    /// Get text output, materializing from OutputData on demand.
214    ///
215    /// Returns `self.out` if non-empty, otherwise falls back to
216    /// `OutputData::to_canonical_string()`. This is the canonical way to
217    /// get text for pipes, command substitution, and file redirects.
218    pub fn text_out(&self) -> Cow<'_, str> {
219        if !self.out.is_empty() {
220            Cow::Borrowed(&self.out)
221        } else if let Some(ref output) = self.output {
222            Cow::Owned(output.to_canonical_string())
223        } else {
224            Cow::Borrowed("")
225        }
226    }
227
228    /// Get a reference to structured output data.
229    pub fn output(&self) -> Option<&OutputData> {
230        self.output.as_ref()
231    }
232
233    /// True if structured output data is present.
234    pub fn has_output(&self) -> bool {
235        self.output.is_some()
236    }
237
238    // ── Mutation accessors ──
239
240    /// Replace `.out` with a new string.
241    pub fn set_out(&mut self, s: String) {
242        self.out = s;
243    }
244
245    /// Append to `.out`.
246    pub fn push_out(&mut self, s: &str) {
247        self.out.push_str(s);
248    }
249
250    /// Clear `.out`.
251    pub fn clear_out(&mut self) {
252        self.out.clear();
253    }
254
255    /// Replace `.output`.
256    pub fn set_output(&mut self, o: Option<OutputData>) {
257        self.output = o;
258    }
259
260    /// Take `.output`, leaving None.
261    pub fn take_output(&mut self) -> Option<OutputData> {
262        self.output.take()
263    }
264
265    /// Materialize: if `.out` is empty and `.output` is present,
266    /// populate `.out` from canonical string and clear `.output`.
267    pub fn materialize(&mut self) {
268        if self.out.is_empty() {
269            if let Some(ref output) = self.output {
270                self.out = output.to_canonical_string();
271            }
272        }
273        self.output = None;
274    }
275
276    /// Take `.output` only if `.out` is empty (no custom text),
277    /// so caller can stream directly without materializing.
278    pub fn take_output_for_stream(&mut self) -> Option<OutputData> {
279        if self.out.is_empty() {
280            self.output.take()
281        } else {
282            None
283        }
284    }
285
286    /// True if the command succeeded (exit code 0).
287    pub fn ok(&self) -> bool {
288        self.code == 0
289    }
290
291    /// Set content type hint, returning self for chaining.
292    pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
293        self.content_type = Some(ct.into());
294        self
295    }
296
297}
298
299/// Convert serde_json::Value to our AST Value.
300///
301/// Primitives are mapped to their corresponding Value variants.
302/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
303pub fn json_to_value(json: serde_json::Value) -> Value {
304    match json {
305        serde_json::Value::Null => Value::Null,
306        serde_json::Value::Bool(b) => Value::Bool(b),
307        serde_json::Value::Number(n) => {
308            if let Some(i) = n.as_i64() {
309                Value::Int(i)
310            } else if let Some(f) = n.as_f64() {
311                Value::Float(f)
312            } else {
313                Value::String(n.to_string())
314            }
315        }
316        serde_json::Value::String(s) => Value::String(s),
317        // Arrays and objects are preserved as Json values
318        serde_json::Value::Array(_) | serde_json::Value::Object(_) => Value::Json(json),
319    }
320}
321
322/// Convert our AST Value to serde_json::Value for serialization.
323pub fn value_to_json(value: &Value) -> serde_json::Value {
324    match value {
325        Value::Null => serde_json::Value::Null,
326        Value::Bool(b) => serde_json::Value::Bool(*b),
327        Value::Int(i) => serde_json::Value::Number((*i).into()),
328        Value::Float(f) => {
329            serde_json::Number::from_f64(*f)
330                .map(serde_json::Value::Number)
331                .unwrap_or(serde_json::Value::Null)
332        }
333        Value::String(s) => serde_json::Value::String(s.clone()),
334        Value::Json(json) => json.clone(),
335        Value::Blob(blob) => {
336            let mut map = serde_json::Map::new();
337            map.insert("_type".to_string(), serde_json::Value::String("blob".to_string()));
338            map.insert("id".to_string(), serde_json::Value::String(blob.id.clone()));
339            map.insert("size".to_string(), serde_json::Value::Number(blob.size.into()));
340            map.insert("contentType".to_string(), serde_json::Value::String(blob.content_type.clone()));
341            if let Some(hash) = &blob.hash {
342                let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
343                map.insert("hash".to_string(), serde_json::Value::String(hash_hex));
344            }
345            serde_json::Value::Object(map)
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn success_creates_ok_result() {
356        let result = ExecResult::success("hello world");
357        assert!(result.ok());
358        assert_eq!(result.code, 0);
359        assert_eq!(result.out, "hello world");
360        assert!(result.err.is_empty());
361    }
362
363    #[test]
364    fn failure_creates_non_ok_result() {
365        let result = ExecResult::failure(1, "command not found");
366        assert!(!result.ok());
367        assert_eq!(result.code, 1);
368        assert_eq!(result.err, "command not found");
369    }
370
371    #[test]
372    fn success_does_not_sniff_json_stdout() {
373        // External-command stdout is never sniffed for JSON. Tools that want
374        // structured data must call success_with_data() / success_data().
375        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
376        assert!(result.data.is_none());
377        assert_eq!(result.out, r#"{"count": 42, "items": ["a", "b"]}"#);
378    }
379
380    #[test]
381    fn from_output_does_not_sniff_json_stdout() {
382        let result = ExecResult::from_output(0, r#"[1, 2, 3]"#, "");
383        assert!(result.data.is_none());
384        assert_eq!(result.out, "[1, 2, 3]");
385    }
386
387    #[test]
388    fn non_json_stdout_has_no_data() {
389        let result = ExecResult::success("just plain text");
390        assert!(result.data.is_none());
391    }
392
393    #[test]
394    fn success_data_creates_result_with_value() {
395        let value = Value::String("test data".into());
396        let result = ExecResult::success_data(value.clone());
397        assert!(result.ok());
398        assert_eq!(result.data, Some(value));
399    }
400
401    #[test]
402    fn did_spill_defaults_to_false() {
403        assert!(!ExecResult::success("hi").did_spill);
404        assert!(!ExecResult::failure(1, "err").did_spill);
405        assert!(!ExecResult::from_output(0, "out", "err").did_spill);
406    }
407
408    #[test]
409    fn did_spill_is_serialized() {
410        let mut result = ExecResult::success("hi");
411        result.did_spill = true;
412        let json = serde_json::to_string(&result).unwrap();
413        assert!(json.contains("\"did_spill\":true"));
414    }
415
416    #[test]
417    fn original_code_omitted_when_none() {
418        let result = ExecResult::success("hi");
419        let json = serde_json::to_string(&result).unwrap();
420        assert!(!json.contains("original_code"));
421    }
422
423    #[test]
424    fn original_code_present_when_set() {
425        let mut result = ExecResult::success("hi");
426        result.original_code = Some(0);
427        let json = serde_json::to_string(&result).unwrap();
428        assert!(json.contains("\"original_code\":0"));
429    }
430
431    #[test]
432    fn default_is_empty_success() {
433        let result = ExecResult::default();
434        assert!(result.ok());
435        assert!(result.out.is_empty());
436        assert!(result.data.is_none());
437        assert!(result.content_type.is_none());
438        assert!(result.baggage.is_empty());
439    }
440
441    #[test]
442    fn from_parts_creates_result() {
443        let result = ExecResult::from_parts(42, "out".into(), "err".into(), None);
444        assert_eq!(result.code, 42);
445        assert_eq!(result.out, "out");
446        assert_eq!(result.err, "err");
447        assert!(result.data.is_none());
448        assert!(result.output.is_none());
449    }
450
451    #[test]
452    fn with_code_sets_code() {
453        let result = ExecResult::success("hi").with_code(42);
454        assert_eq!(result.code, 42);
455        assert_eq!(result.out, "hi");
456    }
457
458    #[test]
459    fn output_getter() {
460        use crate::output::{OutputData, OutputNode};
461        // Use structured (non-text) output so with_output preserves .output
462        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
463        let result = ExecResult::with_output(nodes);
464        assert!(result.output().is_some());
465        assert!(result.has_output());
466
467        // Simple text now routes to .out, so output is None
468        let text_result = ExecResult::with_output(OutputData::text("test"));
469        assert!(!text_result.has_output());
470        assert_eq!(&*text_result.text_out(), "test");
471
472        let plain = ExecResult::success("text");
473        assert!(plain.output().is_none());
474        assert!(!plain.has_output());
475    }
476
477    #[test]
478    fn set_out_and_push_out_and_clear_out() {
479        let mut result = ExecResult::success("");
480        result.set_out("hello".into());
481        assert_eq!(result.out, "hello");
482        result.push_out(" world");
483        assert_eq!(result.out, "hello world");
484        result.clear_out();
485        assert!(result.out.is_empty());
486    }
487
488    #[test]
489    fn set_output_and_take_output() {
490        use crate::output::OutputData;
491        let mut result = ExecResult::success("");
492        assert!(result.take_output().is_none());
493
494        result.set_output(Some(OutputData::text("data")));
495        assert!(result.has_output());
496
497        let taken = result.take_output();
498        assert!(taken.is_some());
499        assert!(!result.has_output());
500    }
501
502    #[test]
503    fn materialize_populates_out_from_output() {
504        use crate::output::{OutputData, OutputNode};
505        // Use structured output to test materialization
506        let nodes = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
507        let mut result = ExecResult::with_output(nodes);
508        assert!(result.out.is_empty());
509        assert!(result.has_output());
510        result.materialize();
511        assert_eq!(result.out, "a\nb");
512        assert!(result.output.is_none());
513    }
514
515    #[test]
516    fn materialize_preserves_existing_out() {
517        use crate::output::OutputData;
518        let mut result = ExecResult::with_output_and_text(OutputData::text("ignored"), "custom");
519        result.materialize();
520        assert_eq!(result.out, "custom");
521    }
522
523    #[test]
524    fn take_output_for_stream_when_out_empty() {
525        use crate::output::{OutputData, OutputNode};
526        // Use structured output — text now goes to .out directly
527        let nodes = OutputData::nodes(vec![OutputNode::new("a")]);
528        let mut result = ExecResult::with_output(nodes);
529        let taken = result.take_output_for_stream();
530        assert!(taken.is_some());
531        assert!(!result.has_output());
532    }
533
534    #[test]
535    fn with_output_simple_text_populates_out_directly() {
536        use crate::output::OutputData;
537        let result = ExecResult::with_output(OutputData::text("hello"));
538        // Simple text should go to .out, not .output
539        assert!(!result.has_output());
540        assert_eq!(&*result.text_out(), "hello");
541        // Even JSON-shaped text is NOT auto-parsed — .data stays None.
542        let json_result = ExecResult::with_output(OutputData::text(r#"{"key": 1}"#));
543        assert!(json_result.data.is_none());
544    }
545
546    #[test]
547    fn take_output_for_stream_when_out_populated() {
548        use crate::output::OutputData;
549        let mut result = ExecResult::with_output_and_text(OutputData::text("x"), "custom");
550        let taken = result.take_output_for_stream();
551        assert!(taken.is_none());
552        assert!(result.has_output()); // not taken
553    }
554}