Skip to main content

kaish_types/
output.rs

1//! Structured output model (Tree-of-Tables) and output format handling.
2//!
3//! The unified output model uses `OutputData` containing a tree of `OutputNode`s:
4//!
5//! - **Builtins**: Pure data producers returning `OutputData`
6//! - **Frontends**: Handle all rendering (REPL, MCP, kaijutsu)
7
8use serde::{Deserialize, Serialize};
9
10use crate::result::ExecResult;
11
12// ============================================================
13// Structured Output (Tree-of-Tables Model)
14// ============================================================
15
16/// Entry type for rendering hints (colors, icons).
17///
18/// This unified enum is used by both the new OutputNode system
19/// and the legacy DisplayHint::Table for backward compatibility.
20#[non_exhaustive]
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(rename_all = "lowercase")]
24pub enum EntryType {
25    /// Generic text content.
26    #[default]
27    Text,
28    /// Regular file.
29    File,
30    /// Directory.
31    Directory,
32    /// Executable file.
33    Executable,
34    /// Symbolic link.
35    Symlink,
36}
37
38/// A node in the output tree.
39///
40/// All fields are always serialized (no skip_serializing_if) for predictable shape
41/// across JSON, postcard, and bincode formats.
42///
43/// `text` is Option<String> because None and Some("") are semantically distinct:
44/// - None: this is a named entry (file listing, table row), not a text node
45/// - Some(""): this IS a text node whose content is empty (e.g. `echo ""`)
46///
47/// The `is_text_only()` method depends on this distinction.
48#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[serde(default)]
51pub struct OutputNode {
52    /// Primary identifier (filename, key, label).
53    pub name: String,
54    /// Rendering hint (colors, icons).
55    pub entry_type: EntryType,
56    /// Text content (for echo, cat, exec).
57    ///
58    /// Three-state semantics:
59    /// - `None` — named entry (file listing, table row), not text
60    /// - `Some("")` — text node with empty content (e.g., `echo ""`)
61    /// - `Some("x")` — text node with content
62    ///
63    /// `is_text_only()` returns true iff text is Some AND name/cells/children are empty.
64    pub text: Option<String>,
65    /// Additional columns (for ls -l, ps, env).
66    pub cells: Vec<String>,
67    /// Child nodes (for tree, find).
68    pub children: Vec<OutputNode>,
69}
70
71impl OutputNode {
72    /// Create a new node with a name.
73    pub fn new(name: impl Into<String>) -> Self {
74        Self {
75            name: name.into(),
76            ..Default::default()
77        }
78    }
79
80    /// Create a text-only node (for echo, cat, etc.).
81    pub fn text(content: impl Into<String>) -> Self {
82        Self {
83            text: Some(content.into()),
84            ..Default::default()
85        }
86    }
87
88    /// Set the entry type for rendering hints.
89    pub fn with_entry_type(mut self, entry_type: EntryType) -> Self {
90        self.entry_type = entry_type;
91        self
92    }
93
94    /// Set additional columns for tabular output.
95    pub fn with_cells(mut self, cells: Vec<String>) -> Self {
96        self.cells = cells;
97        self
98    }
99
100    /// Set child nodes for tree output.
101    pub fn with_children(mut self, children: Vec<OutputNode>) -> Self {
102        self.children = children;
103        self
104    }
105
106    /// Set text content.
107    pub fn with_text(mut self, text: impl Into<String>) -> Self {
108        self.text = Some(text.into());
109        self
110    }
111
112    /// Check if this is a text-only node.
113    pub fn is_text_only(&self) -> bool {
114        self.text.is_some() && self.name.is_empty() && self.cells.is_empty() && self.children.is_empty()
115    }
116
117    /// Check if this node has children.
118    pub fn has_children(&self) -> bool {
119        !self.children.is_empty()
120    }
121
122    /// Get the display name, potentially with text content.
123    pub fn display_name(&self) -> &str {
124        if self.name.is_empty() {
125            self.text.as_deref().unwrap_or("")
126        } else {
127            &self.name
128        }
129    }
130}
131
132/// Structured output data from a command.
133///
134/// This is the top-level structure for command output.
135/// It contains optional column headers and a list of root nodes.
136///
137/// `headers` is Option<Vec<String>> because None means "not tabular" while
138/// Some(vec![]) means "tabular with no column headers." The rendering dispatch
139/// in to_json() and the REPL formatter branch on this distinction.
140///
141/// # Rendering Rules
142///
143/// | Structure | Interactive | Piped/Model |
144/// |-----------|-------------|-------------|
145/// | Single node with `text` | Print text | Print text |
146/// | Flat nodes, `name` only | Multi-column, colored | One per line |
147/// | Flat nodes with `cells` | Aligned table | TSV or names only |
148/// | Nested `children` | Box-drawing tree | Brace notation |
149#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151#[serde(default)]
152pub struct OutputData {
153    /// Column headers (optional, for table output).
154    pub headers: Option<Vec<String>>,
155    /// Top-level nodes.
156    pub root: Vec<OutputNode>,
157}
158
159impl OutputData {
160    /// Create new empty output data.
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Create output data with a single text node.
166    ///
167    /// This is the simplest form for commands like `echo`.
168    pub fn text(content: impl Into<String>) -> Self {
169        Self {
170            headers: None,
171            root: vec![OutputNode::text(content)],
172        }
173    }
174
175    /// Create output data with named nodes (for ls, etc.).
176    pub fn nodes(nodes: Vec<OutputNode>) -> Self {
177        Self {
178            headers: None,
179            root: nodes,
180        }
181    }
182
183    /// Create output data with headers and nodes (for ls -l, ps, etc.).
184    pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
185        Self {
186            headers: Some(headers),
187            root: nodes,
188        }
189    }
190
191    /// Set column headers.
192    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
193        self.headers = Some(headers);
194        self
195    }
196
197    /// Check if this output is simple text (single text-only node).
198    pub fn is_simple_text(&self) -> bool {
199        self.root.len() == 1 && self.root[0].is_text_only()
200    }
201
202    /// Check if this output is a flat list (no nested children).
203    pub fn is_flat(&self) -> bool {
204        self.root.iter().all(|n| !n.has_children())
205    }
206
207    /// Check if this output has tabular data (nodes with cells).
208    pub fn is_tabular(&self) -> bool {
209        self.root.iter().any(|n| !n.cells.is_empty())
210    }
211
212    /// Get the text content if this is simple text output.
213    pub fn as_text(&self) -> Option<&str> {
214        if self.is_simple_text() {
215            self.root[0].text.as_deref()
216        } else {
217            None
218        }
219    }
220
221    /// Convert to canonical string output (for pipes).
222    ///
223    /// This produces a simple string representation suitable for
224    /// piping to other commands:
225    /// - Text nodes: their text content
226    /// - Named nodes: names joined by newlines
227    /// - Tabular nodes (name + cells): TSV format (name\tcell1\tcell2...)
228    /// - Nested nodes: brace notation
229    pub fn to_canonical_string(&self) -> String {
230        if let Some(text) = self.as_text() {
231            return text.to_string();
232        }
233
234        // For flat lists (with or without cells), output one line per node
235        if self.is_flat() {
236            return self.root.iter()
237                .map(|n| {
238                    if n.cells.is_empty() {
239                        n.display_name().to_string()
240                    } else {
241                        // For tabular data, use TSV format
242                        let mut parts = vec![n.display_name().to_string()];
243                        parts.extend(n.cells.iter().cloned());
244                        parts.join("\t")
245                    }
246                })
247                .collect::<Vec<_>>()
248                .join("\n");
249        }
250
251        // For trees, use brace notation
252        fn format_node(node: &OutputNode) -> String {
253            if node.children.is_empty() {
254                node.name.clone()
255            } else {
256                let children: Vec<String> = node.children.iter()
257                    .map(format_node)
258                    .collect();
259                format!("{}/{{{}}}", node.name, children.join(","))
260            }
261        }
262
263        self.root.iter()
264            .map(format_node)
265            .collect::<Vec<_>>()
266            .join("\n")
267    }
268
269    /// Serialize to a JSON value for `--json` flag handling.
270    ///
271    /// Bare data, no envelope — optimized for `jq` patterns.
272    ///
273    /// | Structure | JSON |
274    /// |-----------|------|
275    /// | Simple text | `"hello world"` |
276    /// | Flat list (names only) | `["file1", "file2"]` |
277    /// | Table (headers + cells) | `[{"col1": "v1", ...}, ...]` |
278    /// | Tree (nested children) | `{"dir": {"file": null}}` |
279    pub fn to_json(&self) -> serde_json::Value {
280        // Simple text -> JSON string
281        if let Some(text) = self.as_text() {
282            return serde_json::Value::String(text.to_string());
283        }
284
285        // Table -> array of objects keyed by headers
286        if let Some(ref headers) = self.headers {
287            let rows: Vec<serde_json::Value> = self.root.iter().map(|node| {
288                let mut map = serde_json::Map::new();
289                // First header maps to node.name
290                if let Some(first) = headers.first() {
291                    map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
292                }
293                // Remaining headers map to cells
294                for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
295                    map.insert(header.clone(), serde_json::Value::String(cell.clone()));
296                }
297                serde_json::Value::Object(map)
298            }).collect();
299            return serde_json::Value::Array(rows);
300        }
301
302        // Tree -> nested object
303        if !self.is_flat() {
304            fn node_to_json(node: &OutputNode) -> serde_json::Value {
305                if node.children.is_empty() {
306                    serde_json::Value::Null
307                } else {
308                    let mut map = serde_json::Map::new();
309                    for child in &node.children {
310                        map.insert(child.name.clone(), node_to_json(child));
311                    }
312                    serde_json::Value::Object(map)
313                }
314            }
315
316            // Single root node -> its children as the top-level object
317            if self.root.len() == 1 {
318                return node_to_json(&self.root[0]);
319            }
320            // Multiple root nodes -> object with each root as a key
321            let mut map = serde_json::Map::new();
322            for node in &self.root {
323                map.insert(node.name.clone(), node_to_json(node));
324            }
325            return serde_json::Value::Object(map);
326        }
327
328        // Flat list -> array of strings
329        let items: Vec<serde_json::Value> = self.root.iter()
330            .map(|n| serde_json::Value::String(n.display_name().to_string()))
331            .collect();
332        serde_json::Value::Array(items)
333    }
334}
335
336// ============================================================
337// Output Format (Global --json flag)
338// ============================================================
339
340/// Output serialization format, requested via global flags.
341#[non_exhaustive]
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum OutputFormat {
344    /// JSON serialization via OutputData::to_json()
345    Json,
346}
347
348/// Transform an ExecResult into the requested output format.
349///
350/// Serializes regardless of exit code — commands like `diff` (exit 1 = files differ)
351/// and `grep` (exit 1 = no matches) use non-zero exits for semantic meaning,
352/// not errors. The `--json` contract must hold for all exit codes.
353pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
354    if result.output.is_none() && result.out.is_empty() {
355        return result;
356    }
357    match format {
358        OutputFormat::Json => {
359            let json_str = if let Some(ref output) = result.output {
360                serde_json::to_string_pretty(&output.to_json())
361                    .unwrap_or_else(|_| "null".to_string())
362            } else {
363                // Text-only: wrap as JSON string
364                serde_json::to_string(&result.out)
365                    .unwrap_or_else(|_| "null".to_string())
366            };
367            result.out = json_str;
368            // Clear sentinel — format already applied, prevents double-encoding
369            result.output = None;
370            result
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn entry_type_variants() {
381        assert_ne!(EntryType::File, EntryType::Directory);
382        assert_ne!(EntryType::Directory, EntryType::Executable);
383        assert_ne!(EntryType::Executable, EntryType::Symlink);
384    }
385
386    #[test]
387    fn to_json_simple_text() {
388        let output = OutputData::text("hello world");
389        assert_eq!(output.to_json(), serde_json::json!("hello world"));
390    }
391
392    #[test]
393    fn to_json_flat_list() {
394        let output = OutputData::nodes(vec![
395            OutputNode::new("file1"),
396            OutputNode::new("file2"),
397            OutputNode::new("file3"),
398        ]);
399        assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
400    }
401
402    #[test]
403    fn to_json_table() {
404        let output = OutputData::table(
405            vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
406            vec![
407                OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
408                OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
409            ],
410        );
411        assert_eq!(output.to_json(), serde_json::json!([
412            {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
413            {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
414        ]));
415    }
416
417    #[test]
418    fn to_json_tree() {
419        let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
420        let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
421        let subdir = OutputNode::new("lib")
422            .with_entry_type(EntryType::Directory)
423            .with_children(vec![child2]);
424        let root = OutputNode::new("src")
425            .with_entry_type(EntryType::Directory)
426            .with_children(vec![child1, subdir]);
427
428        let output = OutputData::nodes(vec![root]);
429        assert_eq!(output.to_json(), serde_json::json!({
430            "main.rs": null,
431            "lib": {"utils.rs": null},
432        }));
433    }
434
435    #[test]
436    fn to_json_tree_multiple_roots() {
437        let root1 = OutputNode::new("src")
438            .with_entry_type(EntryType::Directory)
439            .with_children(vec![OutputNode::new("main.rs")]);
440        let root2 = OutputNode::new("docs")
441            .with_entry_type(EntryType::Directory)
442            .with_children(vec![OutputNode::new("README.md")]);
443
444        let output = OutputData::nodes(vec![root1, root2]);
445        assert_eq!(output.to_json(), serde_json::json!({
446            "src": {"main.rs": null},
447            "docs": {"README.md": null},
448        }));
449    }
450
451    #[test]
452    fn to_json_empty() {
453        let output = OutputData::new();
454        assert_eq!(output.to_json(), serde_json::json!([]));
455    }
456
457    #[test]
458    fn apply_output_format_clears_sentinel() {
459        let output = OutputData::table(
460            vec!["NAME".into()],
461            vec![OutputNode::new("test")],
462        );
463        let result = ExecResult::with_output(output);
464        assert!(result.output.is_some(), "before: sentinel present");
465
466        let formatted = apply_output_format(result, OutputFormat::Json);
467        assert!(formatted.output.is_none(), "after Json: sentinel cleared");
468    }
469
470    #[test]
471    fn apply_output_format_no_double_encoding() {
472        let output = OutputData::nodes(vec![
473            OutputNode::new("file1"),
474            OutputNode::new("file2"),
475        ]);
476        let result = ExecResult::with_output(output);
477
478        let after_json = apply_output_format(result, OutputFormat::Json);
479        let json_out = after_json.out.clone();
480        assert!(after_json.output.is_none(), "sentinel cleared by Json");
481
482        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
483        assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
484    }
485}