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    /// Estimate brace-notation byte size without materializing.
123    pub fn estimated_byte_size(&self) -> usize {
124        if self.children.is_empty() {
125            self.name.len()
126        } else {
127            // "name/{child1,child2,...}"
128            let mut size = self.name.len() + 2; // name + "/{" + "}"
129            for (i, child) in self.children.iter().enumerate() {
130                if i > 0 {
131                    size += 1; // comma separator
132                }
133                size += child.estimated_byte_size();
134            }
135            size + 1 // closing brace
136        }
137    }
138
139    /// Write brace-notation to a writer. Returns bytes written.
140    pub fn write_canonical(&self, w: &mut dyn std::io::Write, _budget: usize) -> std::io::Result<usize> {
141        if self.children.is_empty() {
142            w.write_all(self.name.as_bytes())?;
143            return Ok(self.name.len());
144        }
145        let mut written = 0;
146        w.write_all(self.name.as_bytes())?;
147        written += self.name.len();
148        w.write_all(b"/{")?;
149        written += 2;
150        for (i, child) in self.children.iter().enumerate() {
151            if i > 0 {
152                w.write_all(b",")?;
153                written += 1;
154            }
155            written += child.write_canonical(w, _budget.saturating_sub(written))?;
156        }
157        w.write_all(b"}")?;
158        written += 1;
159        Ok(written)
160    }
161
162    /// Get the display name, potentially with text content.
163    pub fn display_name(&self) -> &str {
164        if self.name.is_empty() {
165            self.text.as_deref().unwrap_or("")
166        } else {
167            &self.name
168        }
169    }
170}
171
172/// Structured output data from a command.
173///
174/// This is the top-level structure for command output.
175/// It contains optional column headers and a list of root nodes.
176///
177/// `headers` is Option<Vec<String>> because None means "not tabular" while
178/// Some(vec![]) means "tabular with no column headers." The rendering dispatch
179/// in to_json() and the REPL formatter branch on this distinction.
180///
181/// # Rendering Rules
182///
183/// | Structure | Interactive | Piped/Model |
184/// |-----------|-------------|-------------|
185/// | Single node with `text` | Print text | Print text |
186/// | Flat nodes, `name` only | Multi-column, colored | One per line |
187/// | Flat nodes with `cells` | Aligned table | TSV or names only |
188/// | Nested `children` | Box-drawing tree | Brace notation |
189#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191#[serde(default)]
192pub struct OutputData {
193    /// Column headers (optional, for table output).
194    pub headers: Option<Vec<String>>,
195    /// Top-level nodes.
196    pub root: Vec<OutputNode>,
197}
198
199impl OutputData {
200    /// Create new empty output data.
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Create output data with a single text node.
206    ///
207    /// This is the simplest form for commands like `echo`.
208    pub fn text(content: impl Into<String>) -> Self {
209        Self {
210            headers: None,
211            root: vec![OutputNode::text(content)],
212        }
213    }
214
215    /// Create output data with named nodes (for ls, etc.).
216    pub fn nodes(nodes: Vec<OutputNode>) -> Self {
217        Self {
218            headers: None,
219            root: nodes,
220        }
221    }
222
223    /// Create output data with headers and nodes (for ls -l, ps, etc.).
224    pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
225        Self {
226            headers: Some(headers),
227            root: nodes,
228        }
229    }
230
231    /// Set column headers.
232    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
233        self.headers = Some(headers);
234        self
235    }
236
237    /// Check if this output is simple text (single text-only node).
238    pub fn is_simple_text(&self) -> bool {
239        self.root.len() == 1 && self.root[0].is_text_only()
240    }
241
242    /// Check if this output is a flat list (no nested children).
243    pub fn is_flat(&self) -> bool {
244        self.root.iter().all(|n| !n.has_children())
245    }
246
247    /// Check if this output has tabular data (nodes with cells).
248    pub fn is_tabular(&self) -> bool {
249        self.root.iter().any(|n| !n.cells.is_empty())
250    }
251
252    /// Get the text content if this is simple text output.
253    pub fn as_text(&self) -> Option<&str> {
254        if self.is_simple_text() {
255            self.root[0].text.as_deref()
256        } else {
257            None
258        }
259    }
260
261    /// Estimate canonical string byte size without materializing.
262    ///
263    /// Lower bound — actual may be slightly larger due to formatting.
264    /// Mirrors `to_canonical_string()` structure but only accumulates sizes.
265    pub fn estimated_byte_size(&self) -> usize {
266        if self.root.len() == 1 && self.root[0].is_text_only() {
267            return self.root[0].text.as_ref().map_or(0, |t| t.len());
268        }
269
270        if self.is_flat() {
271            let mut size = 0;
272            for (i, n) in self.root.iter().enumerate() {
273                if i > 0 {
274                    size += 1; // newline separator
275                }
276                size += n.display_name().len();
277                for cell in &n.cells {
278                    size += 1 + cell.len(); // tab + cell
279                }
280            }
281            return size;
282        }
283
284        // Tree: estimate brace notation
285        let mut size = 0;
286        for (i, n) in self.root.iter().enumerate() {
287            if i > 0 {
288                size += 1; // newline separator
289            }
290            size += n.estimated_byte_size();
291        }
292        size
293    }
294
295    /// Write canonical representation to a writer with optional byte budget.
296    ///
297    /// Returns total bytes written. Stops after budget exceeded (imprecise:
298    /// one write past the limit is fine — caller uses this for spill detection,
299    /// not for exact truncation).
300    pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: Option<usize>) -> std::io::Result<usize> {
301        let mut written = 0usize;
302        let budget = budget.unwrap_or(usize::MAX);
303
304        if self.root.len() == 1 && self.root[0].is_text_only() {
305            if let Some(ref text) = self.root[0].text {
306                w.write_all(text.as_bytes())?;
307                return Ok(text.len());
308            }
309            return Ok(0);
310        }
311
312        if self.is_flat() {
313            for (i, n) in self.root.iter().enumerate() {
314                if i > 0 {
315                    w.write_all(b"\n")?;
316                    written += 1;
317                }
318                let name = n.display_name();
319                w.write_all(name.as_bytes())?;
320                written += name.len();
321                for cell in &n.cells {
322                    w.write_all(b"\t")?;
323                    w.write_all(cell.as_bytes())?;
324                    written += 1 + cell.len();
325                }
326                if written > budget {
327                    return Ok(written);
328                }
329            }
330            return Ok(written);
331        }
332
333        // Tree: brace notation
334        for (i, n) in self.root.iter().enumerate() {
335            if i > 0 {
336                w.write_all(b"\n")?;
337                written += 1;
338            }
339            written += n.write_canonical(w, budget.saturating_sub(written))?;
340            if written > budget {
341                return Ok(written);
342            }
343        }
344        Ok(written)
345    }
346
347    /// Convert to canonical string output (for pipes).
348    ///
349    /// This produces a simple string representation suitable for
350    /// piping to other commands:
351    /// - Text nodes: their text content
352    /// - Named nodes: names joined by newlines
353    /// - Tabular nodes (name + cells): TSV format (name\tcell1\tcell2...)
354    /// - Nested nodes: brace notation
355    pub fn to_canonical_string(&self) -> String {
356        if let Some(text) = self.as_text() {
357            return text.to_string();
358        }
359
360        // For flat lists (with or without cells), output one line per node
361        if self.is_flat() {
362            return self.root.iter()
363                .map(|n| {
364                    if n.cells.is_empty() {
365                        n.display_name().to_string()
366                    } else {
367                        // For tabular data, use TSV format
368                        let mut parts = vec![n.display_name().to_string()];
369                        parts.extend(n.cells.iter().cloned());
370                        parts.join("\t")
371                    }
372                })
373                .collect::<Vec<_>>()
374                .join("\n");
375        }
376
377        // For trees, use brace notation
378        fn format_node(node: &OutputNode) -> String {
379            if node.children.is_empty() {
380                node.name.clone()
381            } else {
382                let children: Vec<String> = node.children.iter()
383                    .map(format_node)
384                    .collect();
385                format!("{}/{{{}}}", node.name, children.join(","))
386            }
387        }
388
389        self.root.iter()
390            .map(format_node)
391            .collect::<Vec<_>>()
392            .join("\n")
393    }
394
395    /// Serialize to a JSON value for `--json` flag handling.
396    ///
397    /// Bare data, no envelope — optimized for `jq` patterns.
398    ///
399    /// | Structure | JSON |
400    /// |-----------|------|
401    /// | Simple text | `"hello world"` |
402    /// | Flat list (names only) | `["file1", "file2"]` |
403    /// | Table (headers + cells) | `[{"col1": "v1", ...}, ...]` |
404    /// | Tree (nested children) | `{"dir": {"file": null}}` |
405    pub fn to_json(&self) -> serde_json::Value {
406        // Simple text -> JSON string
407        if let Some(text) = self.as_text() {
408            return serde_json::Value::String(text.to_string());
409        }
410
411        // Table -> array of objects keyed by headers
412        if let Some(ref headers) = self.headers {
413            let rows: Vec<serde_json::Value> = self.root.iter().map(|node| {
414                let mut map = serde_json::Map::new();
415                // First header maps to node.name
416                if let Some(first) = headers.first() {
417                    map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
418                }
419                // Remaining headers map to cells
420                for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
421                    map.insert(header.clone(), serde_json::Value::String(cell.clone()));
422                }
423                serde_json::Value::Object(map)
424            }).collect();
425            return serde_json::Value::Array(rows);
426        }
427
428        // Tree -> nested object
429        if !self.is_flat() {
430            fn node_to_json(node: &OutputNode) -> serde_json::Value {
431                if node.children.is_empty() {
432                    serde_json::Value::Null
433                } else {
434                    let mut map = serde_json::Map::new();
435                    for child in &node.children {
436                        map.insert(child.name.clone(), node_to_json(child));
437                    }
438                    serde_json::Value::Object(map)
439                }
440            }
441
442            // Single root node -> its children as the top-level object
443            if self.root.len() == 1 {
444                return node_to_json(&self.root[0]);
445            }
446            // Multiple root nodes -> object with each root as a key
447            let mut map = serde_json::Map::new();
448            for node in &self.root {
449                map.insert(node.name.clone(), node_to_json(node));
450            }
451            return serde_json::Value::Object(map);
452        }
453
454        // Flat list -> array of strings
455        let items: Vec<serde_json::Value> = self.root.iter()
456            .map(|n| serde_json::Value::String(n.display_name().to_string()))
457            .collect();
458        serde_json::Value::Array(items)
459    }
460}
461
462// ============================================================
463// Output Format (Global --json flag)
464// ============================================================
465
466/// Output serialization format, requested via global flags.
467#[non_exhaustive]
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum OutputFormat {
470    /// JSON serialization via OutputData::to_json()
471    Json,
472}
473
474/// Transform an ExecResult into the requested output format.
475///
476/// Serializes regardless of exit code — commands like `diff` (exit 1 = files differ)
477/// and `grep` (exit 1 = no matches) use non-zero exits for semantic meaning,
478/// not errors. The `--json` contract must hold for all exit codes.
479pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
480    if result.output.is_none() && result.text_out().is_empty() {
481        return result;
482    }
483    match format {
484        OutputFormat::Json => {
485            if let Some(ref output) = result.output {
486                let json_value = output.to_json();
487                result.out = serde_json::to_string(&json_value)
488                    .unwrap_or_else(|_| "null".to_string());
489                result.data = Some(crate::result::json_to_value(json_value));
490            } else {
491                // Text-only: wrap as JSON string, try parse for data
492                let text = result.text_out().into_owned();
493                result.out = serde_json::to_string(&text)
494                    .unwrap_or_else(|_| "null".to_string());
495                result.data = ExecResult::try_parse_json(&result.out);
496            }
497            // Clear sentinel — format already applied, prevents double-encoding
498            result.output = None;
499            result
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn entry_type_variants() {
510        assert_ne!(EntryType::File, EntryType::Directory);
511        assert_ne!(EntryType::Directory, EntryType::Executable);
512        assert_ne!(EntryType::Executable, EntryType::Symlink);
513    }
514
515    #[test]
516    fn to_json_simple_text() {
517        let output = OutputData::text("hello world");
518        assert_eq!(output.to_json(), serde_json::json!("hello world"));
519    }
520
521    #[test]
522    fn to_json_flat_list() {
523        let output = OutputData::nodes(vec![
524            OutputNode::new("file1"),
525            OutputNode::new("file2"),
526            OutputNode::new("file3"),
527        ]);
528        assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
529    }
530
531    #[test]
532    fn to_json_table() {
533        let output = OutputData::table(
534            vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
535            vec![
536                OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
537                OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
538            ],
539        );
540        assert_eq!(output.to_json(), serde_json::json!([
541            {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
542            {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
543        ]));
544    }
545
546    #[test]
547    fn to_json_tree() {
548        let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
549        let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
550        let subdir = OutputNode::new("lib")
551            .with_entry_type(EntryType::Directory)
552            .with_children(vec![child2]);
553        let root = OutputNode::new("src")
554            .with_entry_type(EntryType::Directory)
555            .with_children(vec![child1, subdir]);
556
557        let output = OutputData::nodes(vec![root]);
558        assert_eq!(output.to_json(), serde_json::json!({
559            "main.rs": null,
560            "lib": {"utils.rs": null},
561        }));
562    }
563
564    #[test]
565    fn to_json_tree_multiple_roots() {
566        let root1 = OutputNode::new("src")
567            .with_entry_type(EntryType::Directory)
568            .with_children(vec![OutputNode::new("main.rs")]);
569        let root2 = OutputNode::new("docs")
570            .with_entry_type(EntryType::Directory)
571            .with_children(vec![OutputNode::new("README.md")]);
572
573        let output = OutputData::nodes(vec![root1, root2]);
574        assert_eq!(output.to_json(), serde_json::json!({
575            "src": {"main.rs": null},
576            "docs": {"README.md": null},
577        }));
578    }
579
580    #[test]
581    fn to_json_empty() {
582        let output = OutputData::new();
583        assert_eq!(output.to_json(), serde_json::json!([]));
584    }
585
586    #[test]
587    fn apply_output_format_clears_sentinel() {
588        let output = OutputData::table(
589            vec!["NAME".into()],
590            vec![OutputNode::new("test")],
591        );
592        let result = ExecResult::with_output(output);
593        assert!(result.output.is_some(), "before: sentinel present");
594
595        let formatted = apply_output_format(result, OutputFormat::Json);
596        assert!(formatted.output.is_none(), "after Json: sentinel cleared");
597    }
598
599    #[test]
600    fn apply_output_format_no_double_encoding() {
601        let output = OutputData::nodes(vec![
602            OutputNode::new("file1"),
603            OutputNode::new("file2"),
604        ]);
605        let result = ExecResult::with_output(output);
606
607        let after_json = apply_output_format(result, OutputFormat::Json);
608        let json_out = after_json.out.clone();
609        assert!(after_json.output.is_none(), "sentinel cleared by Json");
610
611        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
612        assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
613    }
614
615    #[test]
616    fn apply_output_format_populates_data() {
617        let output = OutputData::nodes(vec![
618            OutputNode::new("file1"),
619            OutputNode::new("file2"),
620        ]);
621        let result = ExecResult::with_output(output);
622        assert!(result.data.is_none(), "before: no data on non-text output");
623
624        let formatted = apply_output_format(result, OutputFormat::Json);
625        assert!(formatted.data.is_some(), "after Json: data populated");
626
627        // data should match the JSON in out
628        let data = formatted.data.unwrap();
629        assert!(matches!(data, crate::value::Value::Json(_)), "data should be Json variant");
630        if let crate::value::Value::Json(json) = data {
631            assert_eq!(json, serde_json::json!(["file1", "file2"]));
632        }
633    }
634
635    #[test]
636    fn apply_output_format_compact_json() {
637        let output = OutputData::nodes(vec![
638            OutputNode::new("file1"),
639            OutputNode::new("file2"),
640        ]);
641        let result = ExecResult::with_output(output);
642
643        let formatted = apply_output_format(result, OutputFormat::Json);
644        // Compact JSON: no pretty-printing (no newlines within the array)
645        assert!(!formatted.out.contains('\n'), "should be compact JSON, got: {}", formatted.out);
646        assert_eq!(formatted.out, r#"["file1","file2"]"#);
647    }
648}