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;
6
7use crate::output::OutputData;
8use crate::value::Value;
9
10/// The result of executing a command or pipeline.
11///
12/// Fields accessible via `${?.field}`:
13/// - `code` — exit code (0 = success)
14/// - `ok` — true if code == 0
15/// - `err` — error message if failed
16/// - `out` — raw stdout as string
17/// - `data` — parsed JSON from stdout (if valid JSON)
18#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
19pub struct ExecResult {
20    /// Exit code. 0 means success.
21    pub code: i64,
22    /// Raw standard output as a string (canonical for pipes).
23    pub out: String,
24    /// Raw standard error as a string.
25    pub err: String,
26    /// Parsed JSON data from stdout, if stdout was valid JSON.
27    pub data: Option<Value>,
28    /// Structured output data for rendering.
29    pub output: Option<OutputData>,
30    /// True if output was truncated and written to a spill file.
31    pub did_spill: bool,
32    /// The command's original exit code before spill logic overwrote it with 2 or 3.
33    /// Present only when `did_spill` is true and `code` was changed.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub original_code: Option<i64>,
36}
37
38impl ExecResult {
39    /// Create a successful result with output.
40    pub fn success(out: impl Into<String>) -> Self {
41        let out = out.into();
42        let data = Self::try_parse_json(&out);
43        Self {
44            code: 0,
45            out,
46            err: String::new(),
47            data,
48            output: None,
49            did_spill: false,
50            original_code: None,
51        }
52    }
53
54    /// Create a successful result with structured output data.
55    ///
56    /// The `OutputData` is the source of truth. Text is materialized lazily
57    /// via `text_out()` when needed (pipes, redirects, command substitution).
58    ///
59    /// For text-type output, JSON auto-detection still runs so that
60    /// `echo '{"key":1}'` populates `data` for command substitution.
61    pub fn with_output(output: OutputData) -> Self {
62        let data = if output.is_simple_text() {
63            output.as_text().and_then(Self::try_parse_json)
64        } else {
65            None
66        };
67        Self {
68            code: 0,
69            out: String::new(),
70            err: String::new(),
71            data,
72            output: Some(output),
73            did_spill: false,
74            original_code: None,
75        }
76    }
77
78    /// Create a successful result with structured data.
79    pub fn success_data(data: Value) -> Self {
80        let out = value_to_json(&data).to_string();
81        Self {
82            code: 0,
83            out,
84            err: String::new(),
85            data: Some(data),
86            output: None,
87            did_spill: false,
88            original_code: None,
89        }
90    }
91
92    /// Create a successful result with both text output and structured data.
93    ///
94    /// Use this when a command should have:
95    /// - Text output for pipes and traditional shell usage
96    /// - Structured data for iteration and programmatic access
97    ///
98    /// The data field takes precedence for command substitution in contexts
99    /// like `for i in $(cmd)` where the structured data can be iterated.
100    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
101        Self {
102            code: 0,
103            out: out.into(),
104            err: String::new(),
105            data: Some(data),
106            output: None,
107            did_spill: false,
108            original_code: None,
109        }
110    }
111
112    /// Create a failed result with an error message.
113    pub fn failure(code: i64, err: impl Into<String>) -> Self {
114        Self {
115            code,
116            out: String::new(),
117            err: err.into(),
118            data: None,
119            output: None,
120            did_spill: false,
121            original_code: None,
122        }
123    }
124
125    /// Create a result from raw output streams.
126    ///
127    /// **JSON auto-detection**: On success (code 0), stdout is checked for valid
128    /// JSON. If it parses, the result is stored in `.data` as structured data.
129    /// This enables `for i in $(external-command)` to iterate over JSON arrays
130    /// returned by MCP tools and external commands. This is intentional — external
131    /// tools communicate structured data via JSON stdout, and kaish makes it
132    /// available for iteration without requiring manual `jq` parsing.
133    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
134        let out = stdout.into();
135        let data = if code == 0 {
136            Self::try_parse_json(&out)
137        } else {
138            None
139        };
140        Self {
141            code,
142            out,
143            err: stderr.into(),
144            data,
145            output: None,
146            did_spill: false,
147            original_code: None,
148        }
149    }
150
151    /// Create a successful result with structured output and explicit pipe text.
152    ///
153    /// Use this when a builtin needs custom text formatting that differs from
154    /// the canonical `OutputData::to_canonical_string()` representation.
155    pub fn with_output_and_text(output: OutputData, text: impl Into<String>) -> Self {
156        let out = text.into();
157        let data = Self::try_parse_json(&out);
158        Self {
159            code: 0,
160            out,
161            err: String::new(),
162            data,
163            output: Some(output),
164            did_spill: false,
165            original_code: None,
166        }
167    }
168
169    /// Get text output, materializing from OutputData on demand.
170    ///
171    /// Returns `self.out` if non-empty, otherwise falls back to
172    /// `OutputData::to_canonical_string()`. This is the canonical way to
173    /// get text for pipes, command substitution, and file redirects.
174    pub fn text_out(&self) -> Cow<'_, str> {
175        if !self.out.is_empty() {
176            Cow::Borrowed(&self.out)
177        } else if let Some(ref output) = self.output {
178            Cow::Owned(output.to_canonical_string())
179        } else {
180            Cow::Borrowed("")
181        }
182    }
183
184    /// True if the command succeeded (exit code 0).
185    pub fn ok(&self) -> bool {
186        self.code == 0
187    }
188
189    /// Get a field by name, for variable access like `${?.field}`.
190    pub fn get_field(&self, name: &str) -> Option<Value> {
191        match name {
192            "code" => Some(Value::Int(self.code)),
193            "ok" => Some(Value::Bool(self.ok())),
194            "out" => Some(Value::String(self.text_out().into_owned())),
195            "err" => Some(Value::String(self.err.clone())),
196            "data" => self.data.clone(),
197            _ => None,
198        }
199    }
200
201    /// Try to parse a string as JSON, returning a Value if successful.
202    pub(crate) fn try_parse_json(s: &str) -> Option<Value> {
203        let trimmed = s.trim();
204        if trimmed.is_empty() {
205            return None;
206        }
207        serde_json::from_str::<serde_json::Value>(trimmed)
208            .ok()
209            .map(json_to_value)
210    }
211}
212
213impl Default for ExecResult {
214    fn default() -> Self {
215        Self::success("")
216    }
217}
218
219/// Convert serde_json::Value to our AST Value.
220///
221/// Primitives are mapped to their corresponding Value variants.
222/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
223pub fn json_to_value(json: serde_json::Value) -> Value {
224    match json {
225        serde_json::Value::Null => Value::Null,
226        serde_json::Value::Bool(b) => Value::Bool(b),
227        serde_json::Value::Number(n) => {
228            if let Some(i) = n.as_i64() {
229                Value::Int(i)
230            } else if let Some(f) = n.as_f64() {
231                Value::Float(f)
232            } else {
233                Value::String(n.to_string())
234            }
235        }
236        serde_json::Value::String(s) => Value::String(s),
237        // Arrays and objects are preserved as Json values
238        serde_json::Value::Array(_) | serde_json::Value::Object(_) => Value::Json(json),
239    }
240}
241
242/// Convert our AST Value to serde_json::Value for serialization.
243pub fn value_to_json(value: &Value) -> serde_json::Value {
244    match value {
245        Value::Null => serde_json::Value::Null,
246        Value::Bool(b) => serde_json::Value::Bool(*b),
247        Value::Int(i) => serde_json::Value::Number((*i).into()),
248        Value::Float(f) => {
249            serde_json::Number::from_f64(*f)
250                .map(serde_json::Value::Number)
251                .unwrap_or(serde_json::Value::Null)
252        }
253        Value::String(s) => serde_json::Value::String(s.clone()),
254        Value::Json(json) => json.clone(),
255        Value::Blob(blob) => {
256            let mut map = serde_json::Map::new();
257            map.insert("_type".to_string(), serde_json::Value::String("blob".to_string()));
258            map.insert("id".to_string(), serde_json::Value::String(blob.id.clone()));
259            map.insert("size".to_string(), serde_json::Value::Number(blob.size.into()));
260            map.insert("contentType".to_string(), serde_json::Value::String(blob.content_type.clone()));
261            if let Some(hash) = &blob.hash {
262                let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
263                map.insert("hash".to_string(), serde_json::Value::String(hash_hex));
264            }
265            serde_json::Value::Object(map)
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn success_creates_ok_result() {
276        let result = ExecResult::success("hello world");
277        assert!(result.ok());
278        assert_eq!(result.code, 0);
279        assert_eq!(result.out, "hello world");
280        assert!(result.err.is_empty());
281    }
282
283    #[test]
284    fn failure_creates_non_ok_result() {
285        let result = ExecResult::failure(1, "command not found");
286        assert!(!result.ok());
287        assert_eq!(result.code, 1);
288        assert_eq!(result.err, "command not found");
289    }
290
291    #[test]
292    fn json_stdout_is_parsed() {
293        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
294        assert!(result.data.is_some());
295        let data = result.data.unwrap();
296        assert!(matches!(data, Value::Json(_)));
297        if let Value::Json(json) = data {
298            assert_eq!(json.get("count"), Some(&serde_json::json!(42)));
299            assert_eq!(json.get("items"), Some(&serde_json::json!(["a", "b"])));
300        }
301    }
302
303    #[test]
304    fn non_json_stdout_has_no_data() {
305        let result = ExecResult::success("just plain text");
306        assert!(result.data.is_none());
307    }
308
309    #[test]
310    fn get_field_code() {
311        let result = ExecResult::failure(127, "not found");
312        assert_eq!(result.get_field("code"), Some(Value::Int(127)));
313    }
314
315    #[test]
316    fn get_field_ok() {
317        let success = ExecResult::success("hi");
318        let failure = ExecResult::failure(1, "err");
319        assert_eq!(success.get_field("ok"), Some(Value::Bool(true)));
320        assert_eq!(failure.get_field("ok"), Some(Value::Bool(false)));
321    }
322
323    #[test]
324    fn get_field_out_and_err() {
325        let result = ExecResult::from_output(1, "stdout text", "stderr text");
326        assert_eq!(result.get_field("out"), Some(Value::String("stdout text".into())));
327        assert_eq!(result.get_field("err"), Some(Value::String("stderr text".into())));
328    }
329
330    #[test]
331    fn get_field_data() {
332        let result = ExecResult::success(r#"{"key": "value"}"#);
333        let data = result.get_field("data");
334        assert!(data.is_some());
335    }
336
337    #[test]
338    fn get_field_unknown_returns_none() {
339        let result = ExecResult::success("");
340        assert_eq!(result.get_field("nonexistent"), None);
341    }
342
343    #[test]
344    fn success_data_creates_result_with_value() {
345        let value = Value::String("test data".into());
346        let result = ExecResult::success_data(value.clone());
347        assert!(result.ok());
348        assert_eq!(result.data, Some(value));
349    }
350
351    #[test]
352    fn did_spill_defaults_to_false() {
353        assert!(!ExecResult::success("hi").did_spill);
354        assert!(!ExecResult::failure(1, "err").did_spill);
355        assert!(!ExecResult::from_output(0, "out", "err").did_spill);
356    }
357
358    #[test]
359    fn did_spill_is_serialized() {
360        let mut result = ExecResult::success("hi");
361        result.did_spill = true;
362        let json = serde_json::to_string(&result).unwrap();
363        assert!(json.contains("\"did_spill\":true"));
364    }
365
366    #[test]
367    fn original_code_omitted_when_none() {
368        let result = ExecResult::success("hi");
369        let json = serde_json::to_string(&result).unwrap();
370        assert!(!json.contains("original_code"));
371    }
372
373    #[test]
374    fn original_code_present_when_set() {
375        let mut result = ExecResult::success("hi");
376        result.original_code = Some(0);
377        let json = serde_json::to_string(&result).unwrap();
378        assert!(json.contains("\"original_code\":0"));
379    }
380}