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)]
198pub struct OutputData {
199    /// Column headers (optional, for table output).
200    pub headers: Option<Vec<String>>,
201    /// Top-level nodes.
202    pub root: Vec<OutputNode>,
203    /// Render-only override for `--json` consumers. When `Some`,
204    /// `to_json()` returns this verbatim instead of inferring from
205    /// `headers` / `root` / `cells`. Use it when a builtin wants its
206    /// `--json` shape to be richer than the table form (e.g. grep
207    /// emitting per-match objects with submatches and byte offsets).
208    ///
209    /// Skipped by serde (and thus by postcard / bincode) — this is a
210    /// transient render hint, not part of the persisted shape.
211    #[serde(skip)]
212    #[cfg_attr(feature = "schema", schemars(skip))]
213    pub rich_json: Option<serde_json::Value>,
214}
215
216impl OutputData {
217    /// Create new empty output data.
218    pub fn new() -> Self {
219        Self::default()
220    }
221
222    /// Create output data with a single text node.
223    ///
224    /// This is the simplest form for commands like `echo`.
225    pub fn text(content: impl Into<String>) -> Self {
226        Self {
227            headers: None,
228            root: vec![OutputNode::text(content)],
229            rich_json: None,
230        }
231    }
232
233    /// Create output data with named nodes (for ls, etc.).
234    pub fn nodes(nodes: Vec<OutputNode>) -> Self {
235        Self {
236            headers: None,
237            root: nodes,
238            rich_json: None,
239        }
240    }
241
242    /// Create output data with headers and nodes (for ls -l, ps, etc.).
243    pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
244        Self {
245            headers: Some(headers),
246            root: nodes,
247            rich_json: None,
248        }
249    }
250
251    /// Set column headers.
252    pub fn with_headers(mut self, headers: Vec<String>) -> Self {
253        self.headers = Some(headers);
254        self
255    }
256
257    /// Attach a render-only `--json` override. See `rich_json` field doc.
258    pub fn with_rich_json(mut self, value: serde_json::Value) -> Self {
259        self.rich_json = Some(value);
260        self
261    }
262
263    /// Check if this output is simple text (single text-only node).
264    pub fn is_simple_text(&self) -> bool {
265        self.root.len() == 1 && self.root[0].is_text_only()
266    }
267
268    /// Check if this output is a flat list (no nested children).
269    pub fn is_flat(&self) -> bool {
270        self.root.iter().all(|n| !n.has_children())
271    }
272
273    /// Check if this output has tabular data (nodes with cells).
274    pub fn is_tabular(&self) -> bool {
275        self.root.iter().any(|n| !n.cells.is_empty())
276    }
277
278    /// Get the text content if this is simple text output.
279    pub fn as_text(&self) -> Option<&str> {
280        if self.is_simple_text() {
281            self.root[0].text.as_deref()
282        } else {
283            None
284        }
285    }
286
287    /// Extract the owned String from a single-text-node OutputData.
288    /// Returns `Err(self)` for non-simple-text output (tables, trees, multi-node),
289    /// giving the caller back the unconsumed OutputData.
290    pub fn into_text(mut self) -> Result<String, Self> {
291        if self.root.len() == 1 && self.root[0].is_text_only() {
292            Ok(self.root.pop().and_then(|n| n.text).unwrap_or_default())
293        } else {
294            Err(self)
295        }
296    }
297
298    /// Estimate canonical string byte size without materializing.
299    ///
300    /// Lower bound — actual may be slightly larger due to formatting.
301    /// Mirrors `to_canonical_string()` structure but only accumulates sizes.
302    pub fn estimated_byte_size(&self) -> usize {
303        if self.root.len() == 1 && self.root[0].is_text_only() {
304            return self.root[0].text.as_ref().map_or(0, |t| t.len());
305        }
306
307        if self.is_flat() {
308            let mut size = 0;
309            for (i, n) in self.root.iter().enumerate() {
310                if i > 0 {
311                    size += 1; // newline separator
312                }
313                size += n.display_name().len();
314                for cell in &n.cells {
315                    size += 1 + cell.len(); // tab + cell
316                }
317            }
318            return size;
319        }
320
321        // Tree: estimate brace notation
322        let mut size = 0;
323        for (i, n) in self.root.iter().enumerate() {
324            if i > 0 {
325                size += 1; // newline separator
326            }
327            size += n.estimated_byte_size();
328        }
329        size
330    }
331
332    /// Write canonical representation to a writer with optional byte budget.
333    ///
334    /// Returns total bytes written. Stops after budget exceeded (imprecise:
335    /// one write past the limit is fine — caller uses this for spill detection,
336    /// not for exact truncation).
337    pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: Option<usize>) -> std::io::Result<usize> {
338        let mut written = 0usize;
339        let budget = budget.unwrap_or(usize::MAX);
340
341        if self.root.len() == 1 && self.root[0].is_text_only() {
342            if let Some(ref text) = self.root[0].text {
343                w.write_all(text.as_bytes())?;
344                return Ok(text.len());
345            }
346            return Ok(0);
347        }
348
349        if self.is_flat() {
350            for (i, n) in self.root.iter().enumerate() {
351                if i > 0 {
352                    w.write_all(b"\n")?;
353                    written += 1;
354                }
355                let name = n.display_name();
356                w.write_all(name.as_bytes())?;
357                written += name.len();
358                for cell in &n.cells {
359                    w.write_all(b"\t")?;
360                    w.write_all(cell.as_bytes())?;
361                    written += 1 + cell.len();
362                }
363                if written > budget {
364                    return Ok(written);
365                }
366            }
367            return Ok(written);
368        }
369
370        // Tree: brace notation
371        for (i, n) in self.root.iter().enumerate() {
372            if i > 0 {
373                w.write_all(b"\n")?;
374                written += 1;
375            }
376            written += n.write_canonical(w, budget.saturating_sub(written))?;
377            if written > budget {
378                return Ok(written);
379            }
380        }
381        Ok(written)
382    }
383
384    /// Convert to canonical string output (for pipes).
385    ///
386    /// This produces a simple string representation suitable for
387    /// piping to other commands:
388    /// - Text nodes: their text content
389    /// - Named nodes: names joined by newlines
390    /// - Tabular nodes (name + cells): TSV format (name\tcell1\tcell2...)
391    /// - Nested nodes: brace notation
392    pub fn to_canonical_string(&self) -> String {
393        if let Some(text) = self.as_text() {
394            return text.to_string();
395        }
396
397        // For flat lists (with or without cells), output one line per node
398        if self.is_flat() {
399            return self.root.iter()
400                .map(|n| {
401                    if n.cells.is_empty() {
402                        n.display_name().to_string()
403                    } else {
404                        // For tabular data, use TSV format
405                        let mut parts = vec![n.display_name().to_string()];
406                        parts.extend(n.cells.iter().cloned());
407                        parts.join("\t")
408                    }
409                })
410                .collect::<Vec<_>>()
411                .join("\n");
412        }
413
414        // For trees, use brace notation
415        fn format_node(node: &OutputNode) -> String {
416            if node.children.is_empty() {
417                node.name.clone()
418            } else {
419                let children: Vec<String> = node.children.iter()
420                    .map(format_node)
421                    .collect();
422                format!("{}/{{{}}}", node.name, children.join(","))
423            }
424        }
425
426        self.root.iter()
427            .map(format_node)
428            .collect::<Vec<_>>()
429            .join("\n")
430    }
431
432    /// Serialize to a JSON value for `--json` flag handling.
433    ///
434    /// Bare data, no envelope — optimized for `jq` patterns.
435    ///
436    /// | Structure | JSON |
437    /// |-----------|------|
438    /// | Simple text | `"hello world"` |
439    /// | Flat list (names only) | `["file1", "file2"]` |
440    /// | Table (headers + cells) | `[{"col1": "v1", ...}, ...]` |
441    /// | Tree (nested children) | `{"dir": {"file": null}}` |
442    pub fn to_json(&self) -> serde_json::Value {
443        // Builtin-supplied override wins (used by `grep --json` to expose
444        // submatches/byte offsets that don't fit the table model).
445        if let Some(rich) = &self.rich_json {
446            return rich.clone();
447        }
448        // Simple text -> JSON string
449        if let Some(text) = self.as_text() {
450            return serde_json::Value::String(text.to_string());
451        }
452
453        // Table -> array of objects keyed by headers
454        if let Some(ref headers) = self.headers {
455            let rows: Vec<serde_json::Value> = self.root.iter().map(|node| {
456                let mut map = serde_json::Map::new();
457                // First header maps to node.name
458                if let Some(first) = headers.first() {
459                    map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
460                }
461                // Remaining headers map to cells
462                for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
463                    map.insert(header.clone(), serde_json::Value::String(cell.clone()));
464                }
465                serde_json::Value::Object(map)
466            }).collect();
467            return serde_json::Value::Array(rows);
468        }
469
470        // Tree -> nested object
471        if !self.is_flat() {
472            fn node_to_json(node: &OutputNode) -> serde_json::Value {
473                if node.children.is_empty() {
474                    serde_json::Value::Null
475                } else {
476                    let mut map = serde_json::Map::new();
477                    for child in &node.children {
478                        map.insert(child.name.clone(), node_to_json(child));
479                    }
480                    serde_json::Value::Object(map)
481                }
482            }
483
484            // Single root node -> its children as the top-level object
485            if self.root.len() == 1 {
486                return node_to_json(&self.root[0]);
487            }
488            // Multiple root nodes -> object with each root as a key
489            let mut map = serde_json::Map::new();
490            for node in &self.root {
491                map.insert(node.name.clone(), node_to_json(node));
492            }
493            return serde_json::Value::Object(map);
494        }
495
496        // Flat list -> array of strings
497        let items: Vec<serde_json::Value> = self.root.iter()
498            .map(|n| serde_json::Value::String(n.display_name().to_string()))
499            .collect();
500        serde_json::Value::Array(items)
501    }
502}
503
504// ============================================================
505// Output Format (Global --json flag)
506// ============================================================
507
508/// Output serialization format, requested via global flags.
509#[non_exhaustive]
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum OutputFormat {
512    /// JSON serialization via OutputData::to_json()
513    Json,
514}
515
516/// Transform an ExecResult into the requested output format.
517///
518/// Serializes regardless of exit code — commands like `diff` (exit 1 = files differ)
519/// and `grep` (exit 1 = no matches) use non-zero exits for semantic meaning,
520/// not errors. The `--json` contract must hold for all exit codes.
521pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
522    if !result.has_output() && result.text_out().is_empty() {
523        return result;
524    }
525    match format {
526        OutputFormat::Json => {
527            if let Some(output) = result.output() {
528                let json_value = output.to_json();
529                // NLL: borrow of result via output ends here (json_value is owned)
530                result.set_out(serde_json::to_string(&json_value)
531                    .unwrap_or_else(|_| "null".to_string()));
532                result.data = Some(crate::result::json_to_value(json_value));
533            } else {
534                // Text-only: wrap as JSON string. .data mirrors the same string.
535                let text = result.text_out().into_owned();
536                let json_out = serde_json::to_string(&text)
537                    .unwrap_or_else(|_| "null".to_string());
538                result.data = Some(crate::value::Value::String(text));
539                result.set_out(json_out);
540            }
541            // Clear sentinel — format already applied, prevents double-encoding
542            result.set_output(None);
543            result
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn entry_type_variants() {
554        assert_ne!(EntryType::File, EntryType::Directory);
555        assert_ne!(EntryType::Directory, EntryType::Executable);
556        assert_ne!(EntryType::Executable, EntryType::Symlink);
557    }
558
559    #[test]
560    fn to_json_simple_text() {
561        let output = OutputData::text("hello world");
562        assert_eq!(output.to_json(), serde_json::json!("hello world"));
563    }
564
565    #[test]
566    fn to_json_flat_list() {
567        let output = OutputData::nodes(vec![
568            OutputNode::new("file1"),
569            OutputNode::new("file2"),
570            OutputNode::new("file3"),
571        ]);
572        assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
573    }
574
575    #[test]
576    fn to_json_table() {
577        let output = OutputData::table(
578            vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
579            vec![
580                OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
581                OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
582            ],
583        );
584        assert_eq!(output.to_json(), serde_json::json!([
585            {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
586            {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
587        ]));
588    }
589
590    #[test]
591    fn to_json_tree() {
592        let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
593        let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
594        let subdir = OutputNode::new("lib")
595            .with_entry_type(EntryType::Directory)
596            .with_children(vec![child2]);
597        let root = OutputNode::new("src")
598            .with_entry_type(EntryType::Directory)
599            .with_children(vec![child1, subdir]);
600
601        let output = OutputData::nodes(vec![root]);
602        assert_eq!(output.to_json(), serde_json::json!({
603            "main.rs": null,
604            "lib": {"utils.rs": null},
605        }));
606    }
607
608    #[test]
609    fn to_json_tree_multiple_roots() {
610        let root1 = OutputNode::new("src")
611            .with_entry_type(EntryType::Directory)
612            .with_children(vec![OutputNode::new("main.rs")]);
613        let root2 = OutputNode::new("docs")
614            .with_entry_type(EntryType::Directory)
615            .with_children(vec![OutputNode::new("README.md")]);
616
617        let output = OutputData::nodes(vec![root1, root2]);
618        assert_eq!(output.to_json(), serde_json::json!({
619            "src": {"main.rs": null},
620            "docs": {"README.md": null},
621        }));
622    }
623
624    #[test]
625    fn to_json_empty() {
626        let output = OutputData::new();
627        assert_eq!(output.to_json(), serde_json::json!([]));
628    }
629
630    #[test]
631    fn apply_output_format_clears_sentinel() {
632        let output = OutputData::table(
633            vec!["NAME".into()],
634            vec![OutputNode::new("test")],
635        );
636        let result = ExecResult::with_output(output);
637        assert!(result.has_output(), "before: sentinel present");
638
639        let formatted = apply_output_format(result, OutputFormat::Json);
640        assert!(!formatted.has_output(), "after Json: sentinel cleared");
641    }
642
643    #[test]
644    fn apply_output_format_no_double_encoding() {
645        let output = OutputData::nodes(vec![
646            OutputNode::new("file1"),
647            OutputNode::new("file2"),
648        ]);
649        let result = ExecResult::with_output(output);
650
651        let after_json = apply_output_format(result, OutputFormat::Json);
652        let json_out = after_json.text_out().into_owned();
653        assert!(!after_json.has_output(), "sentinel cleared by Json");
654
655        let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
656        assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
657    }
658
659    #[test]
660    fn apply_output_format_populates_data() {
661        let output = OutputData::nodes(vec![
662            OutputNode::new("file1"),
663            OutputNode::new("file2"),
664        ]);
665        let result = ExecResult::with_output(output);
666        assert!(result.data.is_none(), "before: no data on non-text output");
667
668        let formatted = apply_output_format(result, OutputFormat::Json);
669        assert!(formatted.data.is_some(), "after Json: data populated");
670
671        // data should match the JSON in out
672        let data = formatted.data.unwrap();
673        assert!(matches!(data, crate::value::Value::Json(_)), "data should be Json variant");
674        if let crate::value::Value::Json(json) = data {
675            assert_eq!(json, serde_json::json!(["file1", "file2"]));
676        }
677    }
678
679    #[test]
680    fn apply_output_format_compact_json() {
681        let output = OutputData::nodes(vec![
682            OutputNode::new("file1"),
683            OutputNode::new("file2"),
684        ]);
685        let result = ExecResult::with_output(output);
686
687        let formatted = apply_output_format(result, OutputFormat::Json);
688        // Compact JSON: no pretty-printing (no newlines within the array)
689        let out = formatted.text_out();
690        assert!(!out.contains('\n'), "should be compact JSON, got: {}", out);
691        assert_eq!(&*out, r#"["file1","file2"]"#);
692    }
693
694    #[test]
695    fn estimated_byte_size_text_only_node() {
696        let node = OutputNode::text("hello world");
697        // Text-only node: name is empty, text is "hello world" (11 bytes)
698        assert_eq!(node.estimated_byte_size(), 11);
699    }
700
701    #[test]
702    fn estimated_byte_size_named_node() {
703        let node = OutputNode::new("file.txt");
704        assert_eq!(node.estimated_byte_size(), 8);
705    }
706
707    #[test]
708    fn write_canonical_respects_budget() {
709        let parent = OutputNode::new("root")
710            .with_children(vec![
711                OutputNode::new("aaaa"),
712                OutputNode::new("bbbb"),
713                OutputNode::new("cccc"),
714            ]);
715        // With a very small budget, should stop writing children early
716        let mut buf = Vec::new();
717        let written = parent.write_canonical(&mut buf, 8).unwrap();
718        let output = String::from_utf8(buf).unwrap();
719        // "root" (4) + "/{" (2) = 6, then budget check kicks in after first child
720        assert!(written <= 16, "should respect budget, wrote {} bytes: {}", written, output);
721        // Should at least write the root name
722        assert!(output.starts_with("root"), "should start with root: {}", output);
723    }
724
725    #[test]
726    fn into_text_simple() {
727        let data = OutputData::text("hello");
728        assert_eq!(data.into_text(), Ok("hello".to_string()));
729    }
730
731    #[test]
732    fn into_text_non_simple() {
733        let data = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
734        assert!(data.into_text().is_err());
735    }
736
737    #[test]
738    fn into_text_empty() {
739        let data = OutputData::text("");
740        assert_eq!(data.into_text(), Ok("".to_string()));
741    }
742}