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 crate::output::OutputData;
6use crate::value::Value;
7
8/// The result of executing a command or pipeline.
9///
10/// Fields accessible via `${?.field}`:
11/// - `code` — exit code (0 = success)
12/// - `ok` — true if code == 0
13/// - `err` — error message if failed
14/// - `out` — raw stdout as string
15/// - `data` — parsed JSON from stdout (if valid JSON)
16#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
17pub struct ExecResult {
18    /// Exit code. 0 means success.
19    pub code: i64,
20    /// Raw standard output as a string (canonical for pipes).
21    pub out: String,
22    /// Raw standard error as a string.
23    pub err: String,
24    /// Parsed JSON data from stdout, if stdout was valid JSON.
25    pub data: Option<Value>,
26    /// Structured output data for rendering.
27    pub output: Option<OutputData>,
28}
29
30impl ExecResult {
31    /// Create a successful result with output.
32    pub fn success(out: impl Into<String>) -> Self {
33        let out = out.into();
34        let data = Self::try_parse_json(&out);
35        Self {
36            code: 0,
37            out,
38            err: String::new(),
39            data,
40            output: None,
41        }
42    }
43
44    /// Create a successful result with structured output data.
45    ///
46    /// This is the preferred constructor for new code. The `OutputData`
47    /// provides a unified model for all output types.
48    pub fn with_output(output: OutputData) -> Self {
49        let out = output.to_canonical_string();
50        let data = Self::try_parse_json(&out);
51        Self {
52            code: 0,
53            out,
54            err: String::new(),
55            data,
56            output: Some(output),
57        }
58    }
59
60    /// Create a successful result with structured data.
61    pub fn success_data(data: Value) -> Self {
62        let out = value_to_json(&data).to_string();
63        Self {
64            code: 0,
65            out,
66            err: String::new(),
67            data: Some(data),
68            output: None,
69        }
70    }
71
72    /// Create a successful result with both text output and structured data.
73    ///
74    /// Use this when a command should have:
75    /// - Text output for pipes and traditional shell usage
76    /// - Structured data for iteration and programmatic access
77    ///
78    /// The data field takes precedence for command substitution in contexts
79    /// like `for i in $(cmd)` where the structured data can be iterated.
80    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
81        Self {
82            code: 0,
83            out: out.into(),
84            err: String::new(),
85            data: Some(data),
86            output: None,
87        }
88    }
89
90    /// Create a failed result with an error message.
91    pub fn failure(code: i64, err: impl Into<String>) -> Self {
92        Self {
93            code,
94            out: String::new(),
95            err: err.into(),
96            data: None,
97            output: None,
98        }
99    }
100
101    /// Create a result from raw output streams.
102    ///
103    /// **JSON auto-detection**: On success (code 0), stdout is checked for valid
104    /// JSON. If it parses, the result is stored in `.data` as structured data.
105    /// This enables `for i in $(external-command)` to iterate over JSON arrays
106    /// returned by MCP tools and external commands. This is intentional — external
107    /// tools communicate structured data via JSON stdout, and kaish makes it
108    /// available for iteration without requiring manual `jq` parsing.
109    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
110        let out = stdout.into();
111        let data = if code == 0 {
112            Self::try_parse_json(&out)
113        } else {
114            None
115        };
116        Self {
117            code,
118            out,
119            err: stderr.into(),
120            data,
121            output: None,
122        }
123    }
124
125    /// True if the command succeeded (exit code 0).
126    pub fn ok(&self) -> bool {
127        self.code == 0
128    }
129
130    /// Get a field by name, for variable access like `${?.field}`.
131    pub fn get_field(&self, name: &str) -> Option<Value> {
132        match name {
133            "code" => Some(Value::Int(self.code)),
134            "ok" => Some(Value::Bool(self.ok())),
135            "out" => Some(Value::String(self.out.clone())),
136            "err" => Some(Value::String(self.err.clone())),
137            "data" => self.data.clone(),
138            _ => None,
139        }
140    }
141
142    /// Try to parse a string as JSON, returning a Value if successful.
143    fn try_parse_json(s: &str) -> Option<Value> {
144        let trimmed = s.trim();
145        if trimmed.is_empty() {
146            return None;
147        }
148        serde_json::from_str::<serde_json::Value>(trimmed)
149            .ok()
150            .map(json_to_value)
151    }
152}
153
154impl Default for ExecResult {
155    fn default() -> Self {
156        Self::success("")
157    }
158}
159
160/// Convert serde_json::Value to our AST Value.
161///
162/// Primitives are mapped to their corresponding Value variants.
163/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
164pub fn json_to_value(json: serde_json::Value) -> Value {
165    match json {
166        serde_json::Value::Null => Value::Null,
167        serde_json::Value::Bool(b) => Value::Bool(b),
168        serde_json::Value::Number(n) => {
169            if let Some(i) = n.as_i64() {
170                Value::Int(i)
171            } else if let Some(f) = n.as_f64() {
172                Value::Float(f)
173            } else {
174                Value::String(n.to_string())
175            }
176        }
177        serde_json::Value::String(s) => Value::String(s),
178        // Arrays and objects are preserved as Json values
179        serde_json::Value::Array(_) | serde_json::Value::Object(_) => Value::Json(json),
180    }
181}
182
183/// Convert our AST Value to serde_json::Value for serialization.
184pub fn value_to_json(value: &Value) -> serde_json::Value {
185    match value {
186        Value::Null => serde_json::Value::Null,
187        Value::Bool(b) => serde_json::Value::Bool(*b),
188        Value::Int(i) => serde_json::Value::Number((*i).into()),
189        Value::Float(f) => {
190            serde_json::Number::from_f64(*f)
191                .map(serde_json::Value::Number)
192                .unwrap_or(serde_json::Value::Null)
193        }
194        Value::String(s) => serde_json::Value::String(s.clone()),
195        Value::Json(json) => json.clone(),
196        Value::Blob(blob) => {
197            let mut map = serde_json::Map::new();
198            map.insert("_type".to_string(), serde_json::Value::String("blob".to_string()));
199            map.insert("id".to_string(), serde_json::Value::String(blob.id.clone()));
200            map.insert("size".to_string(), serde_json::Value::Number(blob.size.into()));
201            map.insert("contentType".to_string(), serde_json::Value::String(blob.content_type.clone()));
202            if let Some(hash) = &blob.hash {
203                let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
204                map.insert("hash".to_string(), serde_json::Value::String(hash_hex));
205            }
206            serde_json::Value::Object(map)
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn success_creates_ok_result() {
217        let result = ExecResult::success("hello world");
218        assert!(result.ok());
219        assert_eq!(result.code, 0);
220        assert_eq!(result.out, "hello world");
221        assert!(result.err.is_empty());
222    }
223
224    #[test]
225    fn failure_creates_non_ok_result() {
226        let result = ExecResult::failure(1, "command not found");
227        assert!(!result.ok());
228        assert_eq!(result.code, 1);
229        assert_eq!(result.err, "command not found");
230    }
231
232    #[test]
233    fn json_stdout_is_parsed() {
234        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
235        assert!(result.data.is_some());
236        let data = result.data.unwrap();
237        assert!(matches!(data, Value::Json(_)));
238        if let Value::Json(json) = data {
239            assert_eq!(json.get("count"), Some(&serde_json::json!(42)));
240            assert_eq!(json.get("items"), Some(&serde_json::json!(["a", "b"])));
241        }
242    }
243
244    #[test]
245    fn non_json_stdout_has_no_data() {
246        let result = ExecResult::success("just plain text");
247        assert!(result.data.is_none());
248    }
249
250    #[test]
251    fn get_field_code() {
252        let result = ExecResult::failure(127, "not found");
253        assert_eq!(result.get_field("code"), Some(Value::Int(127)));
254    }
255
256    #[test]
257    fn get_field_ok() {
258        let success = ExecResult::success("hi");
259        let failure = ExecResult::failure(1, "err");
260        assert_eq!(success.get_field("ok"), Some(Value::Bool(true)));
261        assert_eq!(failure.get_field("ok"), Some(Value::Bool(false)));
262    }
263
264    #[test]
265    fn get_field_out_and_err() {
266        let result = ExecResult::from_output(1, "stdout text", "stderr text");
267        assert_eq!(result.get_field("out"), Some(Value::String("stdout text".into())));
268        assert_eq!(result.get_field("err"), Some(Value::String("stderr text".into())));
269    }
270
271    #[test]
272    fn get_field_data() {
273        let result = ExecResult::success(r#"{"key": "value"}"#);
274        let data = result.get_field("data");
275        assert!(data.is_some());
276    }
277
278    #[test]
279    fn get_field_unknown_returns_none() {
280        let result = ExecResult::success("");
281        assert_eq!(result.get_field("nonexistent"), None);
282    }
283
284    #[test]
285    fn success_data_creates_result_with_value() {
286        let value = Value::String("test data".into());
287        let result = ExecResult::success_data(value.clone());
288        assert!(result.ok());
289        assert_eq!(result.data, Some(value));
290    }
291}