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