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