Skip to main content

kaish_kernel/interpreter/
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//!
5//! ```kaish
6//! api-call endpoint=/users
7//! if ${?.ok}; then
8//!     echo "Got ${?.data.count} users"
9//! else
10//!     echo "Error: ${?.err}"
11//! fi
12//! ```
13//!
14//! This differs from traditional shells where `$?` is just an integer exit code.
15//! In kaish, we capture the full context: exit code, stdout, parsed data, and errors.
16//!
17//! # Structured Output (Tree-of-Tables Model)
18//!
19//! The unified output model uses `OutputData` containing a tree of `OutputNode`s:
20//!
21//! - **Builtins**: Pure data producers returning `OutputData`
22//! - **Frontends**: Handle all rendering (REPL, MCP, kaijutsu)
23
24use crate::ast::Value;
25use serde::Serialize;
26
27// ============================================================
28// Structured Output (Tree-of-Tables Model)
29// ============================================================
30
31/// Entry type for rendering hints (colors, icons).
32///
33/// This unified enum is used by both the new OutputNode system
34/// and the legacy DisplayHint::Table for backward compatibility.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
36#[serde(rename_all = "lowercase")]
37pub enum EntryType {
38    /// Generic text content.
39    #[default]
40    Text,
41    /// Regular file.
42    File,
43    /// Directory.
44    Directory,
45    /// Executable file.
46    Executable,
47    /// Symbolic link.
48    Symlink,
49}
50
51/// A node in the output tree.
52///
53/// Nodes can carry text, tabular cells, and nested children.
54/// This is the building block for all structured output.
55///
56/// # Examples
57///
58/// Simple text output (echo):
59/// ```
60/// # use kaish_kernel::interpreter::OutputNode;
61/// let node = OutputNode::text("hello world");
62/// ```
63///
64/// File listing (ls):
65/// ```
66/// # use kaish_kernel::interpreter::{OutputNode, EntryType};
67/// let node = OutputNode::new("file.txt").with_entry_type(EntryType::File);
68/// ```
69///
70/// Table row (ls -l):
71/// ```
72/// # use kaish_kernel::interpreter::{OutputNode, EntryType};
73/// let node = OutputNode::new("file.txt")
74///     .with_cells(vec!["drwxr-xr-x".into(), "4096".into()])
75///     .with_entry_type(EntryType::Directory);
76/// ```
77///
78/// Tree node with children:
79/// ```
80/// # use kaish_kernel::interpreter::{OutputNode, EntryType};
81/// let child = OutputNode::new("main.rs").with_entry_type(EntryType::File);
82/// let parent = OutputNode::new("src")
83///     .with_entry_type(EntryType::Directory)
84///     .with_children(vec![child]);
85/// ```
86#[derive(Debug, Clone, PartialEq, Default, Serialize)]
87pub struct OutputNode {
88    /// Primary identifier (filename, key, label).
89    pub name: String,
90    /// Rendering hint (colors, icons).
91    pub entry_type: EntryType,
92    /// Text content (for echo, cat, exec).
93    pub text: Option<String>,
94    /// Additional columns (for ls -l, ps, env).
95    pub cells: Vec<String>,
96    /// Child nodes (for tree, find).
97    pub children: Vec<OutputNode>,
98}
99
100impl OutputNode {
101    /// Create a new node with a name.
102    pub fn new(name: impl Into<String>) -> Self {
103        Self {
104            name: name.into(),
105            ..Default::default()
106        }
107    }
108
109    /// Create a text-only node (for echo, cat, etc.).
110    pub fn text(content: impl Into<String>) -> Self {
111        Self {
112            text: Some(content.into()),
113            ..Default::default()
114        }
115    }
116
117    /// Set the entry type for rendering hints.
118    pub fn with_entry_type(mut self, entry_type: EntryType) -> Self {
119        self.entry_type = entry_type;
120        self
121    }
122
123    /// Set additional columns for tabular output.
124    pub fn with_cells(mut self, cells: Vec<String>) -> Self {
125        self.cells = cells;
126        self
127    }
128
129    /// Set child nodes for tree output.
130    pub fn with_children(mut self, children: Vec<OutputNode>) -> Self {
131        self.children = children;
132        self
133    }
134
135    /// Set text content.
136    pub fn with_text(mut self, text: impl Into<String>) -> Self {
137        self.text = Some(text.into());
138        self
139    }
140
141    /// Check if this is a text-only node.
142    pub fn is_text_only(&self) -> bool {
143        self.text.is_some() && self.name.is_empty() && self.cells.is_empty() && self.children.is_empty()
144    }
145
146    /// Check if this node has children.
147    pub fn has_children(&self) -> bool {
148        !self.children.is_empty()
149    }
150
151    /// Get the display name, potentially with text content.
152    pub fn display_name(&self) -> &str {
153        if self.name.is_empty() {
154            self.text.as_deref().unwrap_or("")
155        } else {
156            &self.name
157        }
158    }
159}
160
161/// Structured output data from a command.
162///
163/// This is the top-level structure for command output.
164/// It contains optional column headers and a list of root nodes.
165///
166/// # Rendering Rules
167///
168/// | Structure | Interactive | Piped/Model |
169/// |-----------|-------------|-------------|
170/// | Single node with `text` | Print text | Print text |
171/// | Flat nodes, `name` only | Multi-column, colored | One per line |
172/// | Flat nodes with `cells` | Aligned table | TSV or names only |
173/// | Nested `children` | Box-drawing tree | Brace notation |
174#[derive(Debug, Clone, PartialEq, Default, Serialize)]
175pub struct OutputData {
176    /// Column headers (optional, for table output).
177    pub headers: Option<Vec<String>>,
178    /// Top-level nodes.
179    pub root: Vec<OutputNode>,
180}
181
182impl OutputData {
183    /// Create new empty output data.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Create output data with a single text node.
189    ///
190    /// This is the simplest form for commands like `echo`.
191    pub fn text(content: impl Into<String>) -> Self {
192        Self {
193            headers: None,
194            root: vec![OutputNode::text(content)],
195        }
196    }
197
198    /// Create output data with named nodes (for ls, etc.).
199    pub fn nodes(nodes: Vec<OutputNode>) -> Self {
200        Self {
201            headers: None,
202            root: nodes,
203        }
204    }
205
206    /// Create output data with headers and nodes (for ls -l, ps, etc.).
207    pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
208        Self {
209            headers: Some(headers),
210            root: nodes,
211        }
212    }
213
214    /// Set column headers.
215    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
216        self.headers = Some(headers);
217        self
218    }
219
220    /// Check if this output is simple text (single text-only node).
221    pub fn is_simple_text(&self) -> bool {
222        self.root.len() == 1 && self.root[0].is_text_only()
223    }
224
225    /// Check if this output is a flat list (no nested children).
226    pub fn is_flat(&self) -> bool {
227        self.root.iter().all(|n| !n.has_children())
228    }
229
230    /// Check if this output has tabular data (nodes with cells).
231    pub fn is_tabular(&self) -> bool {
232        self.root.iter().any(|n| !n.cells.is_empty())
233    }
234
235    /// Get the text content if this is simple text output.
236    pub fn as_text(&self) -> Option<&str> {
237        if self.is_simple_text() {
238            self.root[0].text.as_deref()
239        } else {
240            None
241        }
242    }
243
244    /// Convert to canonical string output (for pipes).
245    ///
246    /// This produces a simple string representation suitable for
247    /// piping to other commands:
248    /// - Text nodes: their text content
249    /// - Named nodes: names joined by newlines
250    /// - Tabular nodes (name + cells): TSV format (name\tcell1\tcell2...)
251    /// - Nested nodes: brace notation
252    pub fn to_canonical_string(&self) -> String {
253        if let Some(text) = self.as_text() {
254            return text.to_string();
255        }
256
257        // For flat lists (with or without cells), output one line per node
258        if self.is_flat() {
259            return self.root.iter()
260                .map(|n| {
261                    if n.cells.is_empty() {
262                        n.display_name().to_string()
263                    } else {
264                        // For tabular data, use TSV format
265                        let mut parts = vec![n.display_name().to_string()];
266                        parts.extend(n.cells.iter().cloned());
267                        parts.join("\t")
268                    }
269                })
270                .collect::<Vec<_>>()
271                .join("\n");
272        }
273
274        // For trees, use brace notation
275        fn format_node(node: &OutputNode) -> String {
276            if node.children.is_empty() {
277                node.name.clone()
278            } else {
279                let children: Vec<String> = node.children.iter()
280                    .map(format_node)
281                    .collect();
282                format!("{}/{{{}}}", node.name, children.join(","))
283            }
284        }
285
286        self.root.iter()
287            .map(format_node)
288            .collect::<Vec<_>>()
289            .join("\n")
290    }
291
292    /// Serialize to TOON format for `--toon` flag handling.
293    ///
294    /// Converts to JSON value first, then encodes as TOON.
295    /// TOON is ~40% fewer tokens than JSON for tabular data.
296    pub fn to_toon(&self) -> String {
297        let json_value = self.to_json();
298        toon_format::encode_default(&json_value)
299            .unwrap_or_else(|_| self.to_canonical_string())
300    }
301
302    /// Serialize to a JSON value for `--json` flag handling.
303    ///
304    /// Bare data, no envelope — optimized for `jq` patterns.
305    ///
306    /// | Structure | JSON |
307    /// |-----------|------|
308    /// | Simple text | `"hello world"` |
309    /// | Flat list (names only) | `["file1", "file2"]` |
310    /// | Table (headers + cells) | `[{"col1": "v1", ...}, ...]` |
311    /// | Tree (nested children) | `{"dir": {"file": null}}` |
312    pub fn to_json(&self) -> serde_json::Value {
313        // Simple text → JSON string
314        if let Some(text) = self.as_text() {
315            return serde_json::Value::String(text.to_string());
316        }
317
318        // Table → array of objects keyed by headers
319        if let Some(ref headers) = self.headers {
320            let rows: Vec<serde_json::Value> = self.root.iter().map(|node| {
321                let mut map = serde_json::Map::new();
322                // First header maps to node.name
323                if let Some(first) = headers.first() {
324                    map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
325                }
326                // Remaining headers map to cells
327                for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
328                    map.insert(header.clone(), serde_json::Value::String(cell.clone()));
329                }
330                serde_json::Value::Object(map)
331            }).collect();
332            return serde_json::Value::Array(rows);
333        }
334
335        // Tree → nested object
336        if !self.is_flat() {
337            fn node_to_json(node: &OutputNode) -> serde_json::Value {
338                if node.children.is_empty() {
339                    serde_json::Value::Null
340                } else {
341                    let mut map = serde_json::Map::new();
342                    for child in &node.children {
343                        map.insert(child.name.clone(), node_to_json(child));
344                    }
345                    serde_json::Value::Object(map)
346                }
347            }
348
349            // Single root node → its children as the top-level object
350            if self.root.len() == 1 {
351                return node_to_json(&self.root[0]);
352            }
353            // Multiple root nodes → object with each root as a key
354            let mut map = serde_json::Map::new();
355            for node in &self.root {
356                map.insert(node.name.clone(), node_to_json(node));
357            }
358            return serde_json::Value::Object(map);
359        }
360
361        // Flat list → array of strings
362        let items: Vec<serde_json::Value> = self.root.iter()
363            .map(|n| serde_json::Value::String(n.display_name().to_string()))
364            .collect();
365        serde_json::Value::Array(items)
366    }
367}
368
369// ============================================================
370// Output Format (Global --json / --toon flags)
371// ============================================================
372
373/// Output serialization format, requested via global flags.
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum OutputFormat {
376    /// JSON serialization via OutputData::to_json()
377    Json,
378    /// TOON serialization via OutputData::to_toon() — compact token-efficient format.
379    Toon,
380}
381
382/// Transform an ExecResult into the requested output format.
383///
384/// Serializes regardless of exit code — commands like `diff` (exit 1 = files differ)
385/// and `grep` (exit 1 = no matches) use non-zero exits for semantic meaning,
386/// not errors. The `--json` contract must hold for all exit codes.
387pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
388    if result.output.is_none() && result.out.is_empty() {
389        return result;
390    }
391    match format {
392        OutputFormat::Json => {
393            let json_str = if let Some(ref output) = result.output {
394                serde_json::to_string_pretty(&output.to_json())
395                    .unwrap_or_else(|_| "null".to_string())
396            } else {
397                // Text-only: wrap as JSON string
398                serde_json::to_string(&result.out)
399                    .unwrap_or_else(|_| "null".to_string())
400            };
401            result.out = json_str;
402            // Clear sentinel — format already applied, prevents double-encoding
403            result.output = None;
404            result
405        }
406        OutputFormat::Toon => {
407            let toon_str = if let Some(ref output) = result.output {
408                output.to_toon()
409            } else {
410                // Text-only: encode as TOON string primitive
411                toon_format::encode_default(&result.out)
412                    .unwrap_or_else(|_| result.out.clone())
413            };
414            result.out = toon_str;
415            // Clear sentinel — format already applied, prevents double-encoding
416            result.output = None;
417            result
418        }
419    }
420}
421
422/// The result of executing a command or pipeline.
423///
424/// Fields accessible via `${?.field}`:
425/// - `code` — exit code (0 = success)
426/// - `ok` — true if code == 0
427/// - `err` — error message if failed
428/// - `out` — raw stdout as string
429/// - `data` — parsed JSON from stdout (if valid JSON)
430#[derive(Debug, Clone, PartialEq)]
431pub struct ExecResult {
432    /// Exit code. 0 means success.
433    pub code: i64,
434    /// Raw standard output as a string (canonical for pipes).
435    pub out: String,
436    /// Raw standard error as a string.
437    pub err: String,
438    /// Parsed JSON data from stdout, if stdout was valid JSON.
439    pub data: Option<Value>,
440    /// Structured output data for rendering.
441    pub output: Option<OutputData>,
442}
443
444impl ExecResult {
445    /// Create a successful result with output.
446    pub fn success(out: impl Into<String>) -> Self {
447        let out = out.into();
448        let data = Self::try_parse_json(&out);
449        Self {
450            code: 0,
451            out,
452            err: String::new(),
453            data,
454            output: None,
455        }
456    }
457
458    /// Create a successful result with structured output data.
459    ///
460    /// This is the preferred constructor for new code. The `OutputData`
461    /// provides a unified model for all output types.
462    pub fn with_output(output: OutputData) -> Self {
463        let out = output.to_canonical_string();
464        let data = Self::try_parse_json(&out);
465        Self {
466            code: 0,
467            out,
468            err: String::new(),
469            data,
470            output: Some(output),
471        }
472    }
473
474    /// Create a successful result with structured data.
475    pub fn success_data(data: Value) -> Self {
476        let out = value_to_json(&data).to_string();
477        Self {
478            code: 0,
479            out,
480            err: String::new(),
481            data: Some(data),
482            output: None,
483        }
484    }
485
486    /// Create a successful result with both text output and structured data.
487    ///
488    /// Use this when a command should have:
489    /// - Text output for pipes and traditional shell usage
490    /// - Structured data for iteration and programmatic access
491    ///
492    /// The data field takes precedence for command substitution in contexts
493    /// like `for i in $(cmd)` where the structured data can be iterated.
494    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
495        Self {
496            code: 0,
497            out: out.into(),
498            err: String::new(),
499            data: Some(data),
500            output: None,
501        }
502    }
503
504    /// Create a failed result with an error message.
505    pub fn failure(code: i64, err: impl Into<String>) -> Self {
506        Self {
507            code,
508            out: String::new(),
509            err: err.into(),
510            data: None,
511            output: None,
512        }
513    }
514
515    /// Create a result from raw output streams.
516    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
517        let out = stdout.into();
518        let data = if code == 0 {
519            Self::try_parse_json(&out)
520        } else {
521            None
522        };
523        Self {
524            code,
525            out,
526            err: stderr.into(),
527            data,
528            output: None,
529        }
530    }
531
532    /// True if the command succeeded (exit code 0).
533    pub fn ok(&self) -> bool {
534        self.code == 0
535    }
536
537    /// Get a field by name, for variable access like `${?.field}`.
538    pub fn get_field(&self, name: &str) -> Option<Value> {
539        match name {
540            "code" => Some(Value::Int(self.code)),
541            "ok" => Some(Value::Bool(self.ok())),
542            "out" => Some(Value::String(self.out.clone())),
543            "err" => Some(Value::String(self.err.clone())),
544            "data" => self.data.clone(),
545            _ => None,
546        }
547    }
548
549    /// Try to parse a string as JSON, returning a Value if successful.
550    fn try_parse_json(s: &str) -> Option<Value> {
551        let trimmed = s.trim();
552        if trimmed.is_empty() {
553            return None;
554        }
555        serde_json::from_str::<serde_json::Value>(trimmed)
556            .ok()
557            .map(json_to_value)
558    }
559}
560
561impl Default for ExecResult {
562    fn default() -> Self {
563        Self::success("")
564    }
565}
566
567/// Convert serde_json::Value to our AST Value.
568///
569/// Primitives are mapped to their corresponding Value variants.
570/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
571pub fn json_to_value(json: serde_json::Value) -> Value {
572    match json {
573        serde_json::Value::Null => Value::Null,
574        serde_json::Value::Bool(b) => Value::Bool(b),
575        serde_json::Value::Number(n) => {
576            if let Some(i) = n.as_i64() {
577                Value::Int(i)
578            } else if let Some(f) = n.as_f64() {
579                Value::Float(f)
580            } else {
581                Value::String(n.to_string())
582            }
583        }
584        serde_json::Value::String(s) => Value::String(s),
585        // Arrays and objects are preserved as Json values
586        serde_json::Value::Array(_) | serde_json::Value::Object(_) => Value::Json(json),
587    }
588}
589
590/// Convert our AST Value to serde_json::Value for serialization.
591pub fn value_to_json(value: &Value) -> serde_json::Value {
592    match value {
593        Value::Null => serde_json::Value::Null,
594        Value::Bool(b) => serde_json::Value::Bool(*b),
595        Value::Int(i) => serde_json::Value::Number((*i).into()),
596        Value::Float(f) => {
597            serde_json::Number::from_f64(*f)
598                .map(serde_json::Value::Number)
599                .unwrap_or(serde_json::Value::Null)
600        }
601        Value::String(s) => serde_json::Value::String(s.clone()),
602        Value::Json(json) => json.clone(),
603        Value::Blob(blob) => {
604            let mut map = serde_json::Map::new();
605            map.insert("_type".to_string(), serde_json::Value::String("blob".to_string()));
606            map.insert("id".to_string(), serde_json::Value::String(blob.id.clone()));
607            map.insert("size".to_string(), serde_json::Value::Number(blob.size.into()));
608            map.insert("contentType".to_string(), serde_json::Value::String(blob.content_type.clone()));
609            if let Some(hash) = &blob.hash {
610                let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
611                map.insert("hash".to_string(), serde_json::Value::String(hash_hex));
612            }
613            serde_json::Value::Object(map)
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn success_creates_ok_result() {
624        let result = ExecResult::success("hello world");
625        assert!(result.ok());
626        assert_eq!(result.code, 0);
627        assert_eq!(result.out, "hello world");
628        assert!(result.err.is_empty());
629    }
630
631    #[test]
632    fn failure_creates_non_ok_result() {
633        let result = ExecResult::failure(1, "command not found");
634        assert!(!result.ok());
635        assert_eq!(result.code, 1);
636        assert_eq!(result.err, "command not found");
637    }
638
639    #[test]
640    fn json_stdout_is_parsed() {
641        // JSON objects/arrays are now stored as Value::Json for direct access
642        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
643        assert!(result.data.is_some());
644        let data = result.data.unwrap();
645        // Objects are stored as Value::Json
646        assert!(matches!(data, Value::Json(_)));
647        // Verify the structure is preserved
648        if let Value::Json(json) = data {
649            assert_eq!(json.get("count"), Some(&serde_json::json!(42)));
650            assert_eq!(json.get("items"), Some(&serde_json::json!(["a", "b"])));
651        }
652    }
653
654    #[test]
655    fn non_json_stdout_has_no_data() {
656        let result = ExecResult::success("just plain text");
657        assert!(result.data.is_none());
658    }
659
660    #[test]
661    fn get_field_code() {
662        let result = ExecResult::failure(127, "not found");
663        assert_eq!(result.get_field("code"), Some(Value::Int(127)));
664    }
665
666    #[test]
667    fn get_field_ok() {
668        let success = ExecResult::success("hi");
669        let failure = ExecResult::failure(1, "err");
670        assert_eq!(success.get_field("ok"), Some(Value::Bool(true)));
671        assert_eq!(failure.get_field("ok"), Some(Value::Bool(false)));
672    }
673
674    #[test]
675    fn get_field_out_and_err() {
676        let result = ExecResult::from_output(1, "stdout text", "stderr text");
677        assert_eq!(result.get_field("out"), Some(Value::String("stdout text".into())));
678        assert_eq!(result.get_field("err"), Some(Value::String("stderr text".into())));
679    }
680
681    #[test]
682    fn get_field_data() {
683        let result = ExecResult::success(r#"{"key": "value"}"#);
684        let data = result.get_field("data");
685        assert!(data.is_some());
686    }
687
688    #[test]
689    fn get_field_unknown_returns_none() {
690        let result = ExecResult::success("");
691        assert_eq!(result.get_field("nonexistent"), None);
692    }
693
694    #[test]
695    fn success_data_creates_result_with_value() {
696        let value = Value::String("test data".into());
697        let result = ExecResult::success_data(value.clone());
698        assert!(result.ok());
699        assert_eq!(result.data, Some(value));
700    }
701
702    #[test]
703    fn entry_type_variants() {
704        // Just verify the enum variants exist and are distinct
705        assert_ne!(EntryType::File, EntryType::Directory);
706        assert_ne!(EntryType::Directory, EntryType::Executable);
707        assert_ne!(EntryType::Executable, EntryType::Symlink);
708    }
709
710    #[test]
711    fn to_json_simple_text() {
712        let output = OutputData::text("hello world");
713        assert_eq!(output.to_json(), serde_json::json!("hello world"));
714    }
715
716    #[test]
717    fn to_json_flat_list() {
718        let output = OutputData::nodes(vec![
719            OutputNode::new("file1"),
720            OutputNode::new("file2"),
721            OutputNode::new("file3"),
722        ]);
723        assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
724    }
725
726    #[test]
727    fn to_json_table() {
728        let output = OutputData::table(
729            vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
730            vec![
731                OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
732                OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
733            ],
734        );
735        assert_eq!(output.to_json(), serde_json::json!([
736            {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
737            {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
738        ]));
739    }
740
741    #[test]
742    fn to_json_tree() {
743        let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
744        let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
745        let subdir = OutputNode::new("lib")
746            .with_entry_type(EntryType::Directory)
747            .with_children(vec![child2]);
748        let root = OutputNode::new("src")
749            .with_entry_type(EntryType::Directory)
750            .with_children(vec![child1, subdir]);
751
752        let output = OutputData::nodes(vec![root]);
753        assert_eq!(output.to_json(), serde_json::json!({
754            "main.rs": null,
755            "lib": {"utils.rs": null},
756        }));
757    }
758
759    #[test]
760    fn to_json_tree_multiple_roots() {
761        let root1 = OutputNode::new("src")
762            .with_entry_type(EntryType::Directory)
763            .with_children(vec![OutputNode::new("main.rs")]);
764        let root2 = OutputNode::new("docs")
765            .with_entry_type(EntryType::Directory)
766            .with_children(vec![OutputNode::new("README.md")]);
767
768        let output = OutputData::nodes(vec![root1, root2]);
769        assert_eq!(output.to_json(), serde_json::json!({
770            "src": {"main.rs": null},
771            "docs": {"README.md": null},
772        }));
773    }
774
775    #[test]
776    fn to_json_empty() {
777        let output = OutputData::new();
778        assert_eq!(output.to_json(), serde_json::json!([]));
779    }
780
781    // --toon tests
782
783    #[test]
784    fn to_toon_text() {
785        let output = OutputData::text("hello world");
786        let toon = output.to_toon();
787        // TOON encodes a bare string — should round-trip via decode
788        let decoded = toon_format::decode_default::<serde_json::Value>(&toon).expect("valid TOON");
789        assert_eq!(decoded, serde_json::json!("hello world"));
790    }
791
792    #[test]
793    fn to_toon_table() {
794        let output = OutputData::table(
795            vec!["NAME".into(), "SIZE".into()],
796            vec![
797                OutputNode::new("foo.rs").with_cells(vec!["1024".into()]),
798                OutputNode::new("bar/").with_cells(vec!["4096".into()]),
799            ],
800        );
801        let toon = output.to_toon();
802        let decoded = toon_format::decode_default::<serde_json::Value>(&toon).expect("valid TOON");
803        assert_eq!(decoded, serde_json::json!([
804            {"NAME": "foo.rs", "SIZE": "1024"},
805            {"NAME": "bar/", "SIZE": "4096"},
806        ]));
807    }
808
809    #[test]
810    fn to_toon_flat_list() {
811        let output = OutputData::nodes(vec![
812            OutputNode::new("alpha"),
813            OutputNode::new("bravo"),
814            OutputNode::new("charlie"),
815        ]);
816        let toon = output.to_toon();
817        let decoded = toon_format::decode_default::<serde_json::Value>(&toon).expect("valid TOON");
818        assert_eq!(decoded, serde_json::json!(["alpha", "bravo", "charlie"]));
819    }
820
821    #[test]
822    fn apply_output_format_toon() {
823        let output = OutputData::table(
824            vec!["KEY".into(), "VALUE".into()],
825            vec![
826                OutputNode::new("HOME").with_cells(vec!["/home/user".into()]),
827            ],
828        );
829        let result = ExecResult::with_output(output);
830        let formatted = apply_output_format(result, OutputFormat::Toon);
831        // Should be valid TOON that round-trips to the same JSON
832        let decoded = toon_format::decode_default::<serde_json::Value>(&formatted.out).expect("valid TOON");
833        assert_eq!(decoded, serde_json::json!([
834            {"KEY": "HOME", "VALUE": "/home/user"},
835        ]));
836    }
837
838    #[test]
839    fn apply_output_format_toon_text_only() {
840        let result = ExecResult::success("just text");
841        let formatted = apply_output_format(result, OutputFormat::Toon);
842        let decoded = toon_format::decode_default::<serde_json::Value>(&formatted.out).expect("valid TOON");
843        assert_eq!(decoded, serde_json::json!("just text"));
844    }
845
846    #[test]
847    fn apply_output_format_clears_sentinel() {
848        let output = OutputData::table(
849            vec!["NAME".into()],
850            vec![OutputNode::new("test")],
851        );
852        let result = ExecResult::with_output(output);
853        assert!(result.output.is_some(), "before: sentinel present");
854
855        let formatted = apply_output_format(result, OutputFormat::Json);
856        assert!(formatted.output.is_none(), "after Json: sentinel cleared");
857
858        let output = OutputData::nodes(vec![OutputNode::new("a")]);
859        let result = ExecResult::with_output(output);
860        let formatted = apply_output_format(result, OutputFormat::Toon);
861        assert!(formatted.output.is_none(), "after Toon: sentinel cleared");
862    }
863
864    #[test]
865    fn apply_output_format_no_double_encoding() {
866        // Simulate: user runs `ls --json` → kernel applies Json, clears sentinel
867        // Then MCP would try to apply Toon, but sentinel is gone → no-op
868        let output = OutputData::nodes(vec![
869            OutputNode::new("file1"),
870            OutputNode::new("file2"),
871        ]);
872        let result = ExecResult::with_output(output);
873
874        // First pass: explicit --json
875        let after_json = apply_output_format(result, OutputFormat::Json);
876        let json_out = after_json.out.clone();
877        assert!(after_json.output.is_none(), "sentinel cleared by Json");
878
879        // Second pass: MCP tries Toon, but output is None and out is non-empty
880        // This should encode the JSON string as a TOON string primitive
881        // But in practice, MCP checks `output.is_some()` first and skips.
882        // Verify the sentinel pattern works:
883        assert!(after_json.output.is_none());
884        // The JSON output should be valid JSON
885        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
886        assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
887    }
888}