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 a JSON value for `--json` flag handling.
293    ///
294    /// Bare data, no envelope — optimized for `jq` patterns.
295    ///
296    /// | Structure | JSON |
297    /// |-----------|------|
298    /// | Simple text | `"hello world"` |
299    /// | Flat list (names only) | `["file1", "file2"]` |
300    /// | Table (headers + cells) | `[{"col1": "v1", ...}, ...]` |
301    /// | Tree (nested children) | `{"dir": {"file": null}}` |
302    pub fn to_json(&self) -> serde_json::Value {
303        // Simple text → JSON string
304        if let Some(text) = self.as_text() {
305            return serde_json::Value::String(text.to_string());
306        }
307
308        // Table → array of objects keyed by headers
309        if let Some(ref headers) = self.headers {
310            let rows: Vec<serde_json::Value> = self.root.iter().map(|node| {
311                let mut map = serde_json::Map::new();
312                // First header maps to node.name
313                if let Some(first) = headers.first() {
314                    map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
315                }
316                // Remaining headers map to cells
317                for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
318                    map.insert(header.clone(), serde_json::Value::String(cell.clone()));
319                }
320                serde_json::Value::Object(map)
321            }).collect();
322            return serde_json::Value::Array(rows);
323        }
324
325        // Tree → nested object
326        if !self.is_flat() {
327            fn node_to_json(node: &OutputNode) -> serde_json::Value {
328                if node.children.is_empty() {
329                    serde_json::Value::Null
330                } else {
331                    let mut map = serde_json::Map::new();
332                    for child in &node.children {
333                        map.insert(child.name.clone(), node_to_json(child));
334                    }
335                    serde_json::Value::Object(map)
336                }
337            }
338
339            // Single root node → its children as the top-level object
340            if self.root.len() == 1 {
341                return node_to_json(&self.root[0]);
342            }
343            // Multiple root nodes → object with each root as a key
344            let mut map = serde_json::Map::new();
345            for node in &self.root {
346                map.insert(node.name.clone(), node_to_json(node));
347            }
348            return serde_json::Value::Object(map);
349        }
350
351        // Flat list → array of strings
352        let items: Vec<serde_json::Value> = self.root.iter()
353            .map(|n| serde_json::Value::String(n.display_name().to_string()))
354            .collect();
355        serde_json::Value::Array(items)
356    }
357}
358
359// ============================================================
360// Output Format (Global --json flag)
361// ============================================================
362
363/// Output serialization format, requested via global flags.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum OutputFormat {
366    /// JSON serialization via OutputData::to_json()
367    Json,
368}
369
370/// Transform an ExecResult into the requested output format.
371///
372/// Serializes regardless of exit code — commands like `diff` (exit 1 = files differ)
373/// and `grep` (exit 1 = no matches) use non-zero exits for semantic meaning,
374/// not errors. The `--json` contract must hold for all exit codes.
375pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
376    if result.output.is_none() && result.out.is_empty() {
377        return result;
378    }
379    match format {
380        OutputFormat::Json => {
381            let json_str = if let Some(ref output) = result.output {
382                serde_json::to_string_pretty(&output.to_json())
383                    .unwrap_or_else(|_| "null".to_string())
384            } else {
385                // Text-only: wrap as JSON string
386                serde_json::to_string(&result.out)
387                    .unwrap_or_else(|_| "null".to_string())
388            };
389            result.out = json_str;
390            // Clear sentinel — format already applied, prevents double-encoding
391            result.output = None;
392            result
393        }
394    }
395}
396
397/// The result of executing a command or pipeline.
398///
399/// Fields accessible via `${?.field}`:
400/// - `code` — exit code (0 = success)
401/// - `ok` — true if code == 0
402/// - `err` — error message if failed
403/// - `out` — raw stdout as string
404/// - `data` — parsed JSON from stdout (if valid JSON)
405#[derive(Debug, Clone, PartialEq)]
406pub struct ExecResult {
407    /// Exit code. 0 means success.
408    pub code: i64,
409    /// Raw standard output as a string (canonical for pipes).
410    pub out: String,
411    /// Raw standard error as a string.
412    pub err: String,
413    /// Parsed JSON data from stdout, if stdout was valid JSON.
414    pub data: Option<Value>,
415    /// Structured output data for rendering.
416    pub output: Option<OutputData>,
417}
418
419impl ExecResult {
420    /// Create a successful result with output.
421    pub fn success(out: impl Into<String>) -> Self {
422        let out = out.into();
423        let data = Self::try_parse_json(&out);
424        Self {
425            code: 0,
426            out,
427            err: String::new(),
428            data,
429            output: None,
430        }
431    }
432
433    /// Create a successful result with structured output data.
434    ///
435    /// This is the preferred constructor for new code. The `OutputData`
436    /// provides a unified model for all output types.
437    pub fn with_output(output: OutputData) -> Self {
438        let out = output.to_canonical_string();
439        let data = Self::try_parse_json(&out);
440        Self {
441            code: 0,
442            out,
443            err: String::new(),
444            data,
445            output: Some(output),
446        }
447    }
448
449    /// Create a successful result with structured data.
450    pub fn success_data(data: Value) -> Self {
451        let out = value_to_json(&data).to_string();
452        Self {
453            code: 0,
454            out,
455            err: String::new(),
456            data: Some(data),
457            output: None,
458        }
459    }
460
461    /// Create a successful result with both text output and structured data.
462    ///
463    /// Use this when a command should have:
464    /// - Text output for pipes and traditional shell usage
465    /// - Structured data for iteration and programmatic access
466    ///
467    /// The data field takes precedence for command substitution in contexts
468    /// like `for i in $(cmd)` where the structured data can be iterated.
469    pub fn success_with_data(out: impl Into<String>, data: Value) -> Self {
470        Self {
471            code: 0,
472            out: out.into(),
473            err: String::new(),
474            data: Some(data),
475            output: None,
476        }
477    }
478
479    /// Create a failed result with an error message.
480    pub fn failure(code: i64, err: impl Into<String>) -> Self {
481        Self {
482            code,
483            out: String::new(),
484            err: err.into(),
485            data: None,
486            output: None,
487        }
488    }
489
490    /// Create a result from raw output streams.
491    ///
492    /// **JSON auto-detection**: On success (code 0), stdout is checked for valid
493    /// JSON. If it parses, the result is stored in `.data` as structured data.
494    /// This enables `for i in $(external-command)` to iterate over JSON arrays
495    /// returned by MCP tools and external commands. This is intentional — external
496    /// tools communicate structured data via JSON stdout, and kaish makes it
497    /// available for iteration without requiring manual `jq` parsing.
498    pub fn from_output(code: i64, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
499        let out = stdout.into();
500        let data = if code == 0 {
501            Self::try_parse_json(&out)
502        } else {
503            None
504        };
505        Self {
506            code,
507            out,
508            err: stderr.into(),
509            data,
510            output: None,
511        }
512    }
513
514    /// True if the command succeeded (exit code 0).
515    pub fn ok(&self) -> bool {
516        self.code == 0
517    }
518
519    /// Get a field by name, for variable access like `${?.field}`.
520    pub fn get_field(&self, name: &str) -> Option<Value> {
521        match name {
522            "code" => Some(Value::Int(self.code)),
523            "ok" => Some(Value::Bool(self.ok())),
524            "out" => Some(Value::String(self.out.clone())),
525            "err" => Some(Value::String(self.err.clone())),
526            "data" => self.data.clone(),
527            _ => None,
528        }
529    }
530
531    /// Try to parse a string as JSON, returning a Value if successful.
532    fn try_parse_json(s: &str) -> Option<Value> {
533        let trimmed = s.trim();
534        if trimmed.is_empty() {
535            return None;
536        }
537        serde_json::from_str::<serde_json::Value>(trimmed)
538            .ok()
539            .map(json_to_value)
540    }
541}
542
543impl Default for ExecResult {
544    fn default() -> Self {
545        Self::success("")
546    }
547}
548
549/// Convert serde_json::Value to our AST Value.
550///
551/// Primitives are mapped to their corresponding Value variants.
552/// Arrays and objects are preserved as `Value::Json` - use `jq` to query them.
553pub fn json_to_value(json: serde_json::Value) -> Value {
554    match json {
555        serde_json::Value::Null => Value::Null,
556        serde_json::Value::Bool(b) => Value::Bool(b),
557        serde_json::Value::Number(n) => {
558            if let Some(i) = n.as_i64() {
559                Value::Int(i)
560            } else if let Some(f) = n.as_f64() {
561                Value::Float(f)
562            } else {
563                Value::String(n.to_string())
564            }
565        }
566        serde_json::Value::String(s) => Value::String(s),
567        // Arrays and objects are preserved as Json values
568        serde_json::Value::Array(_) | serde_json::Value::Object(_) => Value::Json(json),
569    }
570}
571
572/// Convert our AST Value to serde_json::Value for serialization.
573pub fn value_to_json(value: &Value) -> serde_json::Value {
574    match value {
575        Value::Null => serde_json::Value::Null,
576        Value::Bool(b) => serde_json::Value::Bool(*b),
577        Value::Int(i) => serde_json::Value::Number((*i).into()),
578        Value::Float(f) => {
579            serde_json::Number::from_f64(*f)
580                .map(serde_json::Value::Number)
581                .unwrap_or(serde_json::Value::Null)
582        }
583        Value::String(s) => serde_json::Value::String(s.clone()),
584        Value::Json(json) => json.clone(),
585        Value::Blob(blob) => {
586            let mut map = serde_json::Map::new();
587            map.insert("_type".to_string(), serde_json::Value::String("blob".to_string()));
588            map.insert("id".to_string(), serde_json::Value::String(blob.id.clone()));
589            map.insert("size".to_string(), serde_json::Value::Number(blob.size.into()));
590            map.insert("contentType".to_string(), serde_json::Value::String(blob.content_type.clone()));
591            if let Some(hash) = &blob.hash {
592                let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
593                map.insert("hash".to_string(), serde_json::Value::String(hash_hex));
594            }
595            serde_json::Value::Object(map)
596        }
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn success_creates_ok_result() {
606        let result = ExecResult::success("hello world");
607        assert!(result.ok());
608        assert_eq!(result.code, 0);
609        assert_eq!(result.out, "hello world");
610        assert!(result.err.is_empty());
611    }
612
613    #[test]
614    fn failure_creates_non_ok_result() {
615        let result = ExecResult::failure(1, "command not found");
616        assert!(!result.ok());
617        assert_eq!(result.code, 1);
618        assert_eq!(result.err, "command not found");
619    }
620
621    #[test]
622    fn json_stdout_is_parsed() {
623        // JSON objects/arrays are now stored as Value::Json for direct access
624        let result = ExecResult::success(r#"{"count": 42, "items": ["a", "b"]}"#);
625        assert!(result.data.is_some());
626        let data = result.data.unwrap();
627        // Objects are stored as Value::Json
628        assert!(matches!(data, Value::Json(_)));
629        // Verify the structure is preserved
630        if let Value::Json(json) = data {
631            assert_eq!(json.get("count"), Some(&serde_json::json!(42)));
632            assert_eq!(json.get("items"), Some(&serde_json::json!(["a", "b"])));
633        }
634    }
635
636    #[test]
637    fn non_json_stdout_has_no_data() {
638        let result = ExecResult::success("just plain text");
639        assert!(result.data.is_none());
640    }
641
642    #[test]
643    fn get_field_code() {
644        let result = ExecResult::failure(127, "not found");
645        assert_eq!(result.get_field("code"), Some(Value::Int(127)));
646    }
647
648    #[test]
649    fn get_field_ok() {
650        let success = ExecResult::success("hi");
651        let failure = ExecResult::failure(1, "err");
652        assert_eq!(success.get_field("ok"), Some(Value::Bool(true)));
653        assert_eq!(failure.get_field("ok"), Some(Value::Bool(false)));
654    }
655
656    #[test]
657    fn get_field_out_and_err() {
658        let result = ExecResult::from_output(1, "stdout text", "stderr text");
659        assert_eq!(result.get_field("out"), Some(Value::String("stdout text".into())));
660        assert_eq!(result.get_field("err"), Some(Value::String("stderr text".into())));
661    }
662
663    #[test]
664    fn get_field_data() {
665        let result = ExecResult::success(r#"{"key": "value"}"#);
666        let data = result.get_field("data");
667        assert!(data.is_some());
668    }
669
670    #[test]
671    fn get_field_unknown_returns_none() {
672        let result = ExecResult::success("");
673        assert_eq!(result.get_field("nonexistent"), None);
674    }
675
676    #[test]
677    fn success_data_creates_result_with_value() {
678        let value = Value::String("test data".into());
679        let result = ExecResult::success_data(value.clone());
680        assert!(result.ok());
681        assert_eq!(result.data, Some(value));
682    }
683
684    #[test]
685    fn entry_type_variants() {
686        // Just verify the enum variants exist and are distinct
687        assert_ne!(EntryType::File, EntryType::Directory);
688        assert_ne!(EntryType::Directory, EntryType::Executable);
689        assert_ne!(EntryType::Executable, EntryType::Symlink);
690    }
691
692    #[test]
693    fn to_json_simple_text() {
694        let output = OutputData::text("hello world");
695        assert_eq!(output.to_json(), serde_json::json!("hello world"));
696    }
697
698    #[test]
699    fn to_json_flat_list() {
700        let output = OutputData::nodes(vec![
701            OutputNode::new("file1"),
702            OutputNode::new("file2"),
703            OutputNode::new("file3"),
704        ]);
705        assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
706    }
707
708    #[test]
709    fn to_json_table() {
710        let output = OutputData::table(
711            vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
712            vec![
713                OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
714                OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
715            ],
716        );
717        assert_eq!(output.to_json(), serde_json::json!([
718            {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
719            {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
720        ]));
721    }
722
723    #[test]
724    fn to_json_tree() {
725        let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
726        let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
727        let subdir = OutputNode::new("lib")
728            .with_entry_type(EntryType::Directory)
729            .with_children(vec![child2]);
730        let root = OutputNode::new("src")
731            .with_entry_type(EntryType::Directory)
732            .with_children(vec![child1, subdir]);
733
734        let output = OutputData::nodes(vec![root]);
735        assert_eq!(output.to_json(), serde_json::json!({
736            "main.rs": null,
737            "lib": {"utils.rs": null},
738        }));
739    }
740
741    #[test]
742    fn to_json_tree_multiple_roots() {
743        let root1 = OutputNode::new("src")
744            .with_entry_type(EntryType::Directory)
745            .with_children(vec![OutputNode::new("main.rs")]);
746        let root2 = OutputNode::new("docs")
747            .with_entry_type(EntryType::Directory)
748            .with_children(vec![OutputNode::new("README.md")]);
749
750        let output = OutputData::nodes(vec![root1, root2]);
751        assert_eq!(output.to_json(), serde_json::json!({
752            "src": {"main.rs": null},
753            "docs": {"README.md": null},
754        }));
755    }
756
757    #[test]
758    fn to_json_empty() {
759        let output = OutputData::new();
760        assert_eq!(output.to_json(), serde_json::json!([]));
761    }
762
763    #[test]
764    fn apply_output_format_clears_sentinel() {
765        let output = OutputData::table(
766            vec!["NAME".into()],
767            vec![OutputNode::new("test")],
768        );
769        let result = ExecResult::with_output(output);
770        assert!(result.output.is_some(), "before: sentinel present");
771
772        let formatted = apply_output_format(result, OutputFormat::Json);
773        assert!(formatted.output.is_none(), "after Json: sentinel cleared");
774    }
775
776    #[test]
777    fn apply_output_format_no_double_encoding() {
778        // Simulate: user runs `ls --json` → kernel applies Json, clears sentinel
779        // A second apply_output_format call sees output=None → no-op
780        let output = OutputData::nodes(vec![
781            OutputNode::new("file1"),
782            OutputNode::new("file2"),
783        ]);
784        let result = ExecResult::with_output(output);
785
786        // First pass: explicit --json
787        let after_json = apply_output_format(result, OutputFormat::Json);
788        let json_out = after_json.out.clone();
789        assert!(after_json.output.is_none(), "sentinel cleared by Json");
790
791        // Verify the sentinel pattern works — output is None, so no re-encoding
792        assert!(after_json.output.is_none());
793        // The JSON output should be valid JSON
794        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
795        assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
796    }
797}