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() + self.text.as_ref().map_or(0, |t| t.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        if written >= budget {
149            return Ok(written);
150        }
151        w.write_all(b"/{")?;
152        written += 2;
153        for (i, child) in self.children.iter().enumerate() {
154            if written >= budget {
155                break;
156            }
157            if i > 0 {
158                w.write_all(b",")?;
159                written += 1;
160            }
161            written += child.write_canonical(w, budget.saturating_sub(written))?;
162        }
163        w.write_all(b"}")?;
164        written += 1;
165        Ok(written)
166    }
167
168    /// Get the display name, potentially with text content.
169    pub fn display_name(&self) -> &str {
170        if self.name.is_empty() {
171            self.text.as_deref().unwrap_or("")
172        } else {
173            &self.name
174        }
175    }
176}
177
178/// Structured output data from a command.
179///
180/// This is the top-level structure for command output.
181/// It contains optional column headers and a list of root nodes.
182///
183/// `headers` is `Option<Vec<String>>` because None means "not tabular" while
184/// Some(vec![]) means "tabular with no column headers." The rendering dispatch
185/// in to_json() and the REPL formatter branch on this distinction.
186///
187/// # Rendering Rules
188///
189/// | Structure | Interactive | Piped/Model |
190/// |-----------|-------------|-------------|
191/// | Single node with `text` | Print text | Print text |
192/// | Flat nodes, `name` only | Multi-column, colored | One per line |
193/// | Flat nodes with `cells` | Aligned table | TSV or names only |
194/// | Nested `children` | Box-drawing tree | Brace notation |
195#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197#[serde(default)]
198#[non_exhaustive]
199pub struct OutputData {
200    /// Column headers (optional, for table output).
201    pub headers: Option<Vec<String>>,
202    /// Top-level nodes.
203    pub root: Vec<OutputNode>,
204    /// Override for `--json` consumers. When `Some`, `to_json()` returns this
205    /// verbatim instead of inferring from `headers` / `root` / `cells`. Use it
206    /// when a builtin wants its `--json` shape to be richer than the table form
207    /// (e.g. grep emitting per-match objects with submatches and byte offsets).
208    ///
209    /// Serialized only when `Some` (`skip_serializing_if`), so it **persists**
210    /// through self-describing formats (JSON, CBOR): an embedder can carry a
211    /// builtin's rich `--json` payload onto a stored record and read it back.
212    /// Kept off the wire when `None` (the common case), which also keeps that
213    /// case safe for non-self-describing formats. Caveat: the risk is on
214    /// **deserialization** — `serde_json::Value`'s `Deserialize` calls
215    /// `deserialize_any`, which non-self-describing formats (postcard/bincode)
216    /// don't support, so decoding an `OutputData` whose `rich_json` is `Some`
217    /// from one of those fails. kaish and its embedders use only self-describing
218    /// formats (JSON/CBOR); don't decode `OutputData` from postcard/bincode
219    /// while `rich_json` may be set.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub rich_json: Option<serde_json::Value>,
222}
223
224impl OutputData {
225    /// Create new empty output data.
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Create output data with a single text node.
231    ///
232    /// This is the simplest form for commands like `echo`.
233    pub fn text(content: impl Into<String>) -> Self {
234        Self {
235            headers: None,
236            root: vec![OutputNode::text(content)],
237            rich_json: None,
238        }
239    }
240
241    /// Create output data with named nodes (for ls, etc.).
242    pub fn nodes(nodes: Vec<OutputNode>) -> Self {
243        Self {
244            headers: None,
245            root: nodes,
246            rich_json: None,
247        }
248    }
249
250    /// Create output data with headers and nodes (for ls -l, ps, etc.).
251    pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
252        Self {
253            headers: Some(headers),
254            root: nodes,
255            rich_json: None,
256        }
257    }
258
259    /// Set column headers.
260    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
261        self.headers = Some(headers);
262        self
263    }
264
265    /// Attach a render-only `--json` override. See `rich_json` field doc.
266    pub fn with_rich_json(mut self, value: serde_json::Value) -> Self {
267        self.rich_json = Some(value);
268        self
269    }
270
271    /// Check if this output is simple text (single text-only node).
272    pub fn is_simple_text(&self) -> bool {
273        self.root.len() == 1 && self.root[0].is_text_only()
274    }
275
276    /// Check if this output is a flat list (no nested children).
277    pub fn is_flat(&self) -> bool {
278        self.root.iter().all(|n| !n.has_children())
279    }
280
281    /// Check if this output has tabular data (nodes with cells).
282    pub fn is_tabular(&self) -> bool {
283        self.root.iter().any(|n| !n.cells.is_empty())
284    }
285
286    /// Get the text content if this is simple text output.
287    pub fn as_text(&self) -> Option<&str> {
288        if self.is_simple_text() {
289            self.root[0].text.as_deref()
290        } else {
291            None
292        }
293    }
294
295    /// Extract the owned String from a single-text-node OutputData.
296    /// Returns `Err(self)` for non-simple-text output (tables, trees, multi-node),
297    /// giving the caller back the unconsumed OutputData.
298    pub fn into_text(mut self) -> Result<String, Self> {
299        if self.root.len() == 1 && self.root[0].is_text_only() {
300            Ok(self.root.pop().and_then(|n| n.text).unwrap_or_default())
301        } else {
302            Err(self)
303        }
304    }
305
306    /// Estimate canonical string byte size without materializing.
307    ///
308    /// Lower bound — actual may be slightly larger due to formatting.
309    /// Mirrors `to_canonical_string()` structure but only accumulates sizes.
310    pub fn estimated_byte_size(&self) -> usize {
311        if self.root.len() == 1 && self.root[0].is_text_only() {
312            return self.root[0].text.as_ref().map_or(0, |t| t.len());
313        }
314
315        if self.is_flat() {
316            let mut size = 0;
317            for (i, n) in self.root.iter().enumerate() {
318                if i > 0 {
319                    size += 1; // newline separator
320                }
321                size += n.display_name().len();
322                for cell in &n.cells {
323                    size += 1 + cell.len(); // tab + cell
324                }
325            }
326            return size;
327        }
328
329        // Tree: estimate brace notation
330        let mut size = 0;
331        for (i, n) in self.root.iter().enumerate() {
332            if i > 0 {
333                size += 1; // newline separator
334            }
335            size += n.estimated_byte_size();
336        }
337        size
338    }
339
340    /// Write canonical representation to a writer with optional byte budget.
341    ///
342    /// Returns total bytes written. Stops after budget exceeded (imprecise:
343    /// one write past the limit is fine — caller uses this for spill detection,
344    /// not for exact truncation).
345    pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: Option<usize>) -> std::io::Result<usize> {
346        let mut written = 0usize;
347        let budget = budget.unwrap_or(usize::MAX);
348
349        if self.root.len() == 1 && self.root[0].is_text_only() {
350            if let Some(ref text) = self.root[0].text {
351                w.write_all(text.as_bytes())?;
352                return Ok(text.len());
353            }
354            return Ok(0);
355        }
356
357        if self.is_flat() {
358            for (i, n) in self.root.iter().enumerate() {
359                if i > 0 {
360                    w.write_all(b"\n")?;
361                    written += 1;
362                }
363                let name = n.display_name();
364                w.write_all(name.as_bytes())?;
365                written += name.len();
366                for cell in &n.cells {
367                    w.write_all(b"\t")?;
368                    w.write_all(cell.as_bytes())?;
369                    written += 1 + cell.len();
370                }
371                if written > budget {
372                    return Ok(written);
373                }
374            }
375            return Ok(written);
376        }
377
378        // Tree: brace notation
379        for (i, n) in self.root.iter().enumerate() {
380            if i > 0 {
381                w.write_all(b"\n")?;
382                written += 1;
383            }
384            written += n.write_canonical(w, budget.saturating_sub(written))?;
385            if written > budget {
386                return Ok(written);
387            }
388        }
389        Ok(written)
390    }
391
392    /// Convert to canonical string output (for pipes).
393    ///
394    /// This produces a simple string representation suitable for
395    /// piping to other commands:
396    /// - Text nodes: their text content
397    /// - Named nodes: names joined by newlines
398    /// - Tabular nodes (name + cells): TSV format (name\tcell1\tcell2...)
399    /// - Nested nodes: brace notation
400    pub fn to_canonical_string(&self) -> String {
401        if let Some(text) = self.as_text() {
402            return text.to_string();
403        }
404
405        // For flat lists (with or without cells), output one line per node
406        if self.is_flat() {
407            return self.root.iter()
408                .map(|n| {
409                    if n.cells.is_empty() {
410                        n.display_name().to_string()
411                    } else {
412                        // For tabular data, use TSV format
413                        let mut parts = vec![n.display_name().to_string()];
414                        parts.extend(n.cells.iter().cloned());
415                        parts.join("\t")
416                    }
417                })
418                .collect::<Vec<_>>()
419                .join("\n");
420        }
421
422        // For trees, use brace notation
423        fn format_node(node: &OutputNode) -> String {
424            if node.children.is_empty() {
425                node.name.clone()
426            } else {
427                let children: Vec<String> = node.children.iter()
428                    .map(format_node)
429                    .collect();
430                format!("{}/{{{}}}", node.name, children.join(","))
431            }
432        }
433
434        self.root.iter()
435            .map(format_node)
436            .collect::<Vec<_>>()
437            .join("\n")
438    }
439
440    /// Serialize to a JSON value for `--json` flag handling.
441    ///
442    /// Bare data, no envelope — optimized for `jq` patterns.
443    ///
444    /// | Structure | JSON |
445    /// |-----------|------|
446    /// | Simple text | `"hello world"` |
447    /// | Flat list (names only) | `["file1", "file2"]` |
448    /// | Table (headers + cells) | `[{"col1": "v1", ...}, ...]` |
449    /// | Tree (nested children) | `{"dir": {"file": null}}` |
450    pub fn to_json(&self) -> serde_json::Value {
451        // Builtin-supplied override wins (used by `grep --json` to expose
452        // submatches/byte offsets that don't fit the table model).
453        if let Some(rich) = &self.rich_json {
454            return rich.clone();
455        }
456        // Simple text -> JSON string
457        if let Some(text) = self.as_text() {
458            return serde_json::Value::String(text.to_string());
459        }
460
461        // Table -> array of objects keyed by headers. Nodes with children
462        // (e.g. `ls -lR`, where root nodes are directory groups holding the
463        // actual entries) nest those entries under a "children" key — dropping
464        // them would silently lose every file and size.
465        if let Some(ref headers) = self.headers {
466            fn row_to_json(node: &OutputNode, headers: &[String]) -> serde_json::Value {
467                let mut map = serde_json::Map::new();
468                // First header maps to node.name
469                if let Some(first) = headers.first() {
470                    map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
471                }
472                // Remaining headers map to cells
473                for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
474                    map.insert(header.clone(), serde_json::Value::String(cell.clone()));
475                }
476                if !node.children.is_empty() {
477                    let children: Vec<serde_json::Value> = node
478                        .children
479                        .iter()
480                        .map(|child| row_to_json(child, headers))
481                        .collect();
482                    map.insert("children".to_string(), serde_json::Value::Array(children));
483                }
484                serde_json::Value::Object(map)
485            }
486            let rows: Vec<serde_json::Value> = self
487                .root
488                .iter()
489                .map(|node| row_to_json(node, headers))
490                .collect();
491            return serde_json::Value::Array(rows);
492        }
493
494        // Tree -> nested object
495        if !self.is_flat() {
496            fn node_to_json(node: &OutputNode) -> serde_json::Value {
497                if node.children.is_empty() {
498                    serde_json::Value::Null
499                } else {
500                    let mut map = serde_json::Map::new();
501                    for child in &node.children {
502                        map.insert(child.name.clone(), node_to_json(child));
503                    }
504                    serde_json::Value::Object(map)
505                }
506            }
507
508            // Single root node -> its children as the top-level object
509            if self.root.len() == 1 {
510                return node_to_json(&self.root[0]);
511            }
512            // Multiple root nodes -> object with each root as a key
513            let mut map = serde_json::Map::new();
514            for node in &self.root {
515                map.insert(node.name.clone(), node_to_json(node));
516            }
517            return serde_json::Value::Object(map);
518        }
519
520        // Flat list -> array of strings
521        let items: Vec<serde_json::Value> = self.root.iter()
522            .map(|n| serde_json::Value::String(n.display_name().to_string()))
523            .collect();
524        serde_json::Value::Array(items)
525    }
526}
527
528// ============================================================
529// Output Format (Global --json flag)
530// ============================================================
531
532/// Output serialization format, requested via global flags.
533#[non_exhaustive]
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub enum OutputFormat {
536    /// JSON serialization via OutputData::to_json()
537    Json,
538}
539
540/// Transform an ExecResult into the requested output format.
541///
542/// Serializes regardless of exit code — commands like `diff` (exit 1 = files differ)
543/// and `grep` (exit 1 = no matches) use non-zero exits for semantic meaning,
544/// not errors. The `--json` contract must hold for all exit codes.
545pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
546    // Binary results serialize as the self-describing base64 envelope, never a
547    // lossy-decoded JSON string. See docs/binary-data.md.
548    if result.is_bytes() {
549        let envelope = crate::bytes::bytes_to_envelope(result.out_bytes().unwrap_or(&[]));
550        match format {
551            OutputFormat::Json => {
552                result.set_out(
553                    serde_json::to_string(&envelope).unwrap_or_else(|_| "null".to_string()),
554                );
555                result.data = Some(crate::result::json_to_value(envelope));
556                result.set_output(None);
557            }
558        }
559        return result;
560    }
561    if !result.has_output() && result.text_out().is_empty() {
562        // No stdout to format. A failure that carries a diagnostic message must
563        // still honor --json — otherwise the message leaks out as plain text
564        // even though structured output was requested. Emit a JSON error object
565        // so the contract holds on the error path. A clean non-zero exit with no
566        // message (e.g. `grep` no-match, exit 1) is not an error and stays empty.
567        if !result.ok() && !result.err.is_empty() {
568            match format {
569                OutputFormat::Json => {
570                    // The line terminator is a text-rendering contract (#363);
571                    // the JSON envelope carries the message as written.
572                    let mut obj = serde_json::json!({
573                        "error": result.err.trim_end_matches('\n'),
574                        "code": result.code,
575                    });
576                    // A tool that attached structured data to an error result
577                    // must keep it reachable under --json — nest it under `data`
578                    // so the envelope holds the diagnostic *and* the structured
579                    // truth instead of clobbering one with the other.
580                    if let Some(data) = &result.data {
581                        obj["data"] = crate::result::value_to_json(data);
582                    }
583                    let out =
584                        serde_json::to_string(&obj).unwrap_or_else(|_| "null".to_string());
585                    result.set_out(out);
586                    result.data = Some(crate::result::json_to_value(obj));
587                }
588            }
589        }
590        return result;
591    }
592    match format {
593        OutputFormat::Json => {
594            if let Some(output) = result.output() {
595                let json_value = output.to_json();
596                // NLL: borrow of result via output ends here (json_value is owned)
597                result.set_out(serde_json::to_string(&json_value)
598                    .unwrap_or_else(|_| "null".to_string()));
599                result.data = Some(crate::result::json_to_value(json_value));
600            } else if let Some(data) = &result.data {
601                // Structured data already present (e.g. jq, any `success_with_data`
602                // builtin): serialize that, not the rendered text. Re-wrapping the
603                // text as a JSON string would double-encode it (`"1"` not `1`) and
604                // clobber the real value. `.data` is the structured truth.
605                let json_out = serde_json::to_string(&crate::result::value_to_json(data))
606                    .unwrap_or_else(|_| "null".to_string());
607                result.set_out(json_out);
608            } else {
609                // Text-only: wrap as JSON string. .data mirrors the same string.
610                let text = result.text_out().into_owned();
611                let json_out = serde_json::to_string(&text)
612                    .unwrap_or_else(|_| "null".to_string());
613                result.data = Some(crate::value::Value::String(text));
614                result.set_out(json_out);
615            }
616            // Clear sentinel — format already applied, prevents double-encoding
617            result.set_output(None);
618            result
619        }
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn entry_type_variants() {
629        assert_ne!(EntryType::File, EntryType::Directory);
630        assert_ne!(EntryType::Directory, EntryType::Executable);
631        assert_ne!(EntryType::Executable, EntryType::Symlink);
632    }
633
634    #[test]
635    fn to_json_simple_text() {
636        let output = OutputData::text("hello world");
637        assert_eq!(output.to_json(), serde_json::json!("hello world"));
638    }
639
640    #[test]
641    fn to_json_flat_list() {
642        let output = OutputData::nodes(vec![
643            OutputNode::new("file1"),
644            OutputNode::new("file2"),
645            OutputNode::new("file3"),
646        ]);
647        assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
648    }
649
650    #[test]
651    fn to_json_table() {
652        let output = OutputData::table(
653            vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
654            vec![
655                OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
656                OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
657            ],
658        );
659        assert_eq!(output.to_json(), serde_json::json!([
660            {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
661            {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
662        ]));
663    }
664
665    #[test]
666    fn to_json_table_with_children() {
667        // `ls -lR --json` builds a table whose root nodes are directory
668        // groups and whose actual entries (with size/type cells) live in
669        // `children`. The table serializer must recurse into them or every
670        // file and size is silently dropped — corruption, not an error.
671        let output = OutputData::table(
672            vec!["NAME".into(), "TYPE".into(), "SIZE".into()],
673            vec![
674                OutputNode::new(".")
675                    .with_entry_type(EntryType::Directory)
676                    .with_children(vec![
677                        OutputNode::new("top.txt").with_cells(vec!["-".into(), "6".into()]),
678                        OutputNode::new("a").with_cells(vec!["d".into(), "60".into()]),
679                    ]),
680                OutputNode::new("a")
681                    .with_entry_type(EntryType::Directory)
682                    .with_children(vec![
683                        OutputNode::new("mid.txt").with_cells(vec!["-".into(), "3".into()]),
684                    ]),
685            ],
686        );
687        assert_eq!(output.to_json(), serde_json::json!([
688            {"NAME": ".", "children": [
689                {"NAME": "top.txt", "TYPE": "-", "SIZE": "6"},
690                {"NAME": "a", "TYPE": "d", "SIZE": "60"},
691            ]},
692            {"NAME": "a", "children": [
693                {"NAME": "mid.txt", "TYPE": "-", "SIZE": "3"},
694            ]},
695        ]));
696    }
697
698    #[test]
699    fn to_json_tree() {
700        let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
701        let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
702        let subdir = OutputNode::new("lib")
703            .with_entry_type(EntryType::Directory)
704            .with_children(vec![child2]);
705        let root = OutputNode::new("src")
706            .with_entry_type(EntryType::Directory)
707            .with_children(vec![child1, subdir]);
708
709        let output = OutputData::nodes(vec![root]);
710        assert_eq!(output.to_json(), serde_json::json!({
711            "main.rs": null,
712            "lib": {"utils.rs": null},
713        }));
714    }
715
716    #[test]
717    fn to_json_tree_multiple_roots() {
718        let root1 = OutputNode::new("src")
719            .with_entry_type(EntryType::Directory)
720            .with_children(vec![OutputNode::new("main.rs")]);
721        let root2 = OutputNode::new("docs")
722            .with_entry_type(EntryType::Directory)
723            .with_children(vec![OutputNode::new("README.md")]);
724
725        let output = OutputData::nodes(vec![root1, root2]);
726        assert_eq!(output.to_json(), serde_json::json!({
727            "src": {"main.rs": null},
728            "docs": {"README.md": null},
729        }));
730    }
731
732    #[test]
733    fn to_json_empty() {
734        let output = OutputData::new();
735        assert_eq!(output.to_json(), serde_json::json!([]));
736    }
737
738    #[test]
739    fn rich_json_round_trips_through_serde() {
740        // rich_json is now a persisted field on self-describing formats: a
741        // builtin's `--json` override survives serialize -> deserialize, so an
742        // embedder can store it on a record and read it back. (It was
743        // `#[serde(skip)]` before, which silently dropped it.)
744        let rich = serde_json::json!({"matches": [{"line": 1, "text": "hi"}]});
745        let output = OutputData::new().with_rich_json(rich.clone());
746
747        let encoded = serde_json::to_string(&output).unwrap();
748        let decoded: OutputData = serde_json::from_str(&encoded).unwrap();
749        assert_eq!(
750            decoded.rich_json,
751            Some(rich),
752            "rich_json must survive a serde round trip"
753        );
754    }
755
756    #[test]
757    fn rich_json_none_stays_off_the_wire() {
758        // The common case (no override) keeps the serialized shape minimal via
759        // `skip_serializing_if`, leaving nothing that could trip a
760        // non-self-describing decoder.
761        let output = OutputData::nodes(vec![OutputNode::new("f")]);
762        let encoded = serde_json::to_string(&output).unwrap();
763        assert!(
764            !encoded.contains("rich_json"),
765            "a None rich_json must not serialize: {encoded}"
766        );
767        let decoded: OutputData = serde_json::from_str(&encoded).unwrap();
768        assert_eq!(decoded.rich_json, None);
769    }
770
771    #[test]
772    fn apply_output_format_clears_sentinel() {
773        let output = OutputData::table(
774            vec!["NAME".into()],
775            vec![OutputNode::new("test")],
776        );
777        let result = ExecResult::with_output(output);
778        assert!(result.has_output(), "before: sentinel present");
779
780        let formatted = apply_output_format(result, OutputFormat::Json);
781        assert!(!formatted.has_output(), "after Json: sentinel cleared");
782    }
783
784    #[test]
785    fn apply_output_format_no_double_encoding() {
786        let output = OutputData::nodes(vec![
787            OutputNode::new("file1"),
788            OutputNode::new("file2"),
789        ]);
790        let result = ExecResult::with_output(output);
791
792        let after_json = apply_output_format(result, OutputFormat::Json);
793        let json_out = after_json.text_out().into_owned();
794        assert!(!after_json.has_output(), "sentinel cleared by Json");
795
796        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
797        assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
798    }
799
800    #[test]
801    fn apply_output_format_populates_data() {
802        let output = OutputData::nodes(vec![
803            OutputNode::new("file1"),
804            OutputNode::new("file2"),
805        ]);
806        let result = ExecResult::with_output(output);
807        assert!(result.data.is_none(), "before: no data on non-text output");
808
809        let formatted = apply_output_format(result, OutputFormat::Json);
810        assert!(formatted.data.is_some(), "after Json: data populated");
811
812        // data should match the JSON in out
813        let data = formatted.data.unwrap();
814        assert!(matches!(data, crate::value::Value::Json(_)), "data should be Json variant");
815        if let crate::value::Value::Json(json) = data {
816            assert_eq!(json, serde_json::json!(["file1", "file2"]));
817        }
818    }
819
820    #[test]
821    fn apply_output_format_prefers_structured_data_over_text() {
822        // A `success_with_data` result (e.g. jq '.a' on {"a":1}) carries the
823        // structured scalar in `.data` and the rendered text in stdout. --json
824        // must serialize the structured value (1), not re-wrap the text ("1").
825        use crate::value::Value;
826        let result = ExecResult::success_with_data("1", Value::Int(1));
827        assert!(!result.has_output(), "no OutputData sentinel on this path");
828
829        let formatted = apply_output_format(result, OutputFormat::Json);
830        let json_out = formatted.text_out().into_owned();
831        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
832        assert_eq!(parsed, serde_json::json!(1), "number, not the string \"1\"");
833        // The structured `.data` is preserved, not clobbered down to a string.
834        assert_eq!(formatted.data, Some(Value::Int(1)));
835    }
836
837    #[test]
838    fn apply_output_format_compact_json() {
839        let output = OutputData::nodes(vec![
840            OutputNode::new("file1"),
841            OutputNode::new("file2"),
842        ]);
843        let result = ExecResult::with_output(output);
844
845        let formatted = apply_output_format(result, OutputFormat::Json);
846        // Compact JSON: no pretty-printing (no newlines within the array)
847        let out = formatted.text_out();
848        assert!(!out.contains('\n'), "should be compact JSON, got: {}", out);
849        assert_eq!(&*out, r#"["file1","file2"]"#);
850    }
851
852    #[test]
853    fn apply_output_format_emits_json_error_object_on_failure() {
854        // A failure with empty stdout and a populated err must still honor
855        // --json: emit {"error", "code"} rather than leaking the message as
856        // plain text (e.g. `grep --json --bogus-flag`).
857        let result = ExecResult::failure(2, "grep: unknown flag --bogus-flag");
858        assert!(!result.has_output());
859        assert!(result.text_out().is_empty());
860
861        let formatted = apply_output_format(result, OutputFormat::Json);
862        let out = formatted.text_out().into_owned();
863        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
864        assert_eq!(
865            parsed,
866            serde_json::json!({"error": "grep: unknown flag --bogus-flag", "code": 2})
867        );
868        // .data mirrors the JSON object.
869        assert!(matches!(formatted.data, Some(crate::value::Value::Json(_))));
870    }
871
872    #[test]
873    fn apply_output_format_preserves_structured_data_on_error() {
874        // A failure (exit 2, empty stdout, populated err) that ALSO carries a
875        // structured payload on .data. Under --json the error-envelope path
876        // must not clobber that payload — it stays reachable, nested under
877        // `data`, alongside the error/code envelope.
878        let mut result = ExecResult::failure(2, "rm: refusing without --recursive");
879        result.data = Some(crate::value::Value::Json(serde_json::json!({
880            "operation": "fs.remove",
881            "paths": ["important.dat"],
882        })));
883
884        let formatted = apply_output_format(result, OutputFormat::Json);
885        let out = formatted.text_out().into_owned();
886        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
887        assert_eq!(parsed["error"], "rm: refusing without --recursive");
888        assert_eq!(parsed["code"], 2);
889        assert_eq!(parsed["data"]["operation"], "fs.remove");
890        // .data mirrors the serialized envelope (reachable from the struct too).
891        match &formatted.data {
892            Some(crate::value::Value::Json(v)) => assert_eq!(v["data"]["operation"], "fs.remove"),
893            other => panic!("expected nested JSON data, got {other:?}"),
894        }
895    }
896
897    #[test]
898    fn apply_output_format_leaves_clean_no_match_empty() {
899        // grep no-match: exit 1, empty stdout, empty err. Not an error — must
900        // NOT be wrapped in a JSON error object; stays empty.
901        let result = ExecResult::failure(1, "");
902        let formatted = apply_output_format(result, OutputFormat::Json);
903        assert!(formatted.text_out().is_empty());
904        assert!(formatted.data.is_none());
905    }
906
907    #[test]
908    fn apply_output_format_empty_success_stays_empty() {
909        let result = ExecResult::success("");
910        let formatted = apply_output_format(result, OutputFormat::Json);
911        assert!(formatted.text_out().is_empty());
912        assert!(formatted.data.is_none());
913    }
914
915    #[test]
916    fn estimated_byte_size_text_only_node() {
917        let node = OutputNode::text("hello world");
918        // Text-only node: name is empty, text is "hello world" (11 bytes)
919        assert_eq!(node.estimated_byte_size(), 11);
920    }
921
922    #[test]
923    fn estimated_byte_size_named_node() {
924        let node = OutputNode::new("file.txt");
925        assert_eq!(node.estimated_byte_size(), 8);
926    }
927
928    #[test]
929    fn write_canonical_respects_budget() {
930        let parent = OutputNode::new("root")
931            .with_children(vec![
932                OutputNode::new("aaaa"),
933                OutputNode::new("bbbb"),
934                OutputNode::new("cccc"),
935            ]);
936        // With a very small budget, should stop writing children early
937        let mut buf = Vec::new();
938        let written = parent.write_canonical(&mut buf, 8).unwrap();
939        let output = String::from_utf8(buf).unwrap();
940        // "root" (4) + "/{" (2) = 6, then budget check kicks in after first child
941        assert!(written <= 16, "should respect budget, wrote {} bytes: {}", written, output);
942        // Should at least write the root name
943        assert!(output.starts_with("root"), "should start with root: {}", output);
944    }
945
946    #[test]
947    fn into_text_simple() {
948        let data = OutputData::text("hello");
949        assert_eq!(data.into_text(), Ok("hello".to_string()));
950    }
951
952    #[test]
953    fn into_text_non_simple() {
954        let data = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
955        assert!(data.into_text().is_err());
956    }
957
958    #[test]
959    fn into_text_empty() {
960        let data = OutputData::text("");
961        assert_eq!(data.into_text(), Ok("".to_string()));
962    }
963}