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