Skip to main content

vix_edit_value/
lib.rs

1//! The structured-value editor: a foldable tree for JSON and YAML.
2//!
3//! Vix's Tools menu offers *Edit JSON* and *Edit YAML* commands that parse the
4//! active buffer into a tree of objects, arrays, and scalars, then let the user
5//! fold/unfold containers, navigate, and edit scalar values — code folding, but
6//! for structured data. Saving serializes the tree back to JSON or YAML.
7//!
8//! JSON and YAML share one model: both parse (via `serde_norway`, which also reads
9//! JSON) into `Val` (private), and only the serializer differs (a key-order-preserving
10//! JSON pretty-printer, or `serde_norway` for YAML). The [`Format`] chosen at open
11//! time decides the title and the serializer.
12//!
13//! Like the other editors, this module owns the data, cursor, fold state, and an
14//! undo history, and interprets keys itself, returning an [`Outcome`] telling the
15//! host when to close or save.
16//!
17//! Keys: **↑/↓** (or `k`/`j`) move; **←/→** (or `h`/`l`) collapse/expand (or step
18//! to parent/first child); **Enter**/**F2** edit the selected scalar (or toggle a
19//! container); **Space** toggles fold; **u**/**Ctrl+R** undo/redo; **Ctrl+S**
20//! save; **Esc**/**q** close.
21
22#![warn(clippy::pedantic)]
23#![forbid(unsafe_code)]
24#![deny(missing_docs)]
25
26use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
27
28/// Which serialization the buffer uses.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Format {
31    /// JSON (pretty-printed on save, preserving key order).
32    Json,
33    /// YAML.
34    Yaml,
35}
36
37/// What the host should do after the tree handled a key.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Outcome {
40    /// Handled internally; nothing further for the host to do.
41    Consumed,
42    /// The user asked to close the editor (Esc/`q`).
43    Close,
44    /// The user asked to save (Ctrl+S); the host should persist the tree.
45    Save,
46}
47
48/// A structured value: scalar, array, or object (object key order preserved).
49#[derive(Clone, PartialEq)]
50enum Val {
51    Null,
52    Bool(bool),
53    /// A number kept in its textual form so it round-trips unchanged.
54    Num(String),
55    Str(String),
56    Arr {
57        collapsed: bool,
58        items: Vec<Val>,
59    },
60    Obj {
61        collapsed: bool,
62        entries: Vec<(String, Val)>,
63    },
64}
65
66/// One step of a path locating a node within the tree.
67#[derive(Clone, PartialEq)]
68enum Seg {
69    Key(String),
70    Index(usize),
71}
72
73/// Whether a display row is a scalar leaf or a container (with collapse state).
74#[derive(Clone, Copy, PartialEq)]
75enum Kind {
76    Scalar,
77    Container(bool),
78}
79
80/// A flattened display row computed from the tree and its fold state.
81struct Row {
82    depth: usize,
83    label: String,
84    value: String,
85    kind: Kind,
86    path: Vec<Seg>,
87}
88
89/// Current interaction mode.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum Mode {
92    Normal,
93    Edit,
94}
95
96/// Maximum number of undo steps retained.
97const HISTORY_CAP: usize = 200;
98
99/// A foldable, editable JSON/YAML value tree with a cursor and undo history.
100pub struct Tree {
101    root: Val,
102    format: Format,
103    rows: Vec<Row>,
104    sel: usize,
105    scroll: usize,
106    dirty: bool,
107    mode: Mode,
108    edit_buf: String,
109    undo: Vec<Val>,
110    redo: Vec<Val>,
111}
112
113impl Tree {
114    /// Parse `text` as JSON or YAML into a value tree. YAML's parser also
115    /// accepts JSON.
116    ///
117    /// # Errors
118    ///
119    /// Returns the parser's own message (line/column included) when `text`
120    /// does not parse, instead of discarding it (Run H, T543: this used to
121    /// return a bare `Option`, so the caller could only show a static "not
122    /// valid JSON or YAML" with no indication of what was actually wrong or
123    /// where).
124    pub fn from_text(text: &str, format: Format) -> Result<Self, String> {
125        let parsed: serde_norway::Value =
126            serde_norway::from_str(text).map_err(|e| e.to_string())?;
127        let mut tree = Tree {
128            root: from_yaml(&parsed),
129            format,
130            rows: Vec::new(),
131            sel: 0,
132            scroll: 0,
133            dirty: false,
134            mode: Mode::Normal,
135            edit_buf: String::new(),
136            undo: Vec::new(),
137            redo: Vec::new(),
138        };
139        tree.rebuild();
140        Ok(tree)
141    }
142
143    /// The chosen format.
144    #[must_use]
145    pub fn format(&self) -> Format {
146        self.format
147    }
148
149    /// Number of visible rows.
150    #[must_use]
151    pub fn row_count(&self) -> usize {
152        self.rows.len()
153    }
154
155    /// The selected row index.
156    #[must_use]
157    pub fn sel(&self) -> usize {
158        self.sel
159    }
160
161    /// The first visible row (scroll offset).
162    #[must_use]
163    pub fn scroll(&self) -> usize {
164        self.scroll
165    }
166
167    /// Whether there are unsaved edits.
168    #[must_use]
169    pub fn is_dirty(&self) -> bool {
170        self.dirty
171    }
172
173    /// Whether a scalar is currently being edited.
174    #[must_use]
175    pub fn is_editing(&self) -> bool {
176        matches!(self.mode, Mode::Edit)
177    }
178
179    /// The in-progress edit text (valid while [`Tree::is_editing`]).
180    #[must_use]
181    pub fn edit_buffer(&self) -> &str {
182        &self.edit_buf
183    }
184
185    /// Indentation depth of visible row `i`.
186    #[must_use]
187    pub fn depth(&self, i: usize) -> usize {
188        self.rows.get(i).map_or(0, |r| r.depth)
189    }
190
191    /// The label (key or index) of visible row `i`.
192    #[must_use]
193    pub fn label(&self, i: usize) -> &str {
194        self.rows.get(i).map_or("", |r| r.label.as_str())
195    }
196
197    /// The value/summary text of visible row `i`.
198    #[must_use]
199    pub fn value(&self, i: usize) -> &str {
200        self.rows.get(i).map_or("", |r| r.value.as_str())
201    }
202
203    /// Whether visible row `i` is a container (object/array).
204    #[must_use]
205    pub fn is_container(&self, i: usize) -> bool {
206        matches!(self.rows.get(i).map(|r| r.kind), Some(Kind::Container(_)))
207    }
208
209    /// Whether visible row `i` is a collapsed container.
210    #[must_use]
211    pub fn is_collapsed(&self, i: usize) -> bool {
212        matches!(
213            self.rows.get(i).map(|r| r.kind),
214            Some(Kind::Container(true))
215        )
216    }
217
218    /// Serialize the tree back to text in its format.
219    #[must_use]
220    pub fn to_text(&self) -> String {
221        match self.format {
222            Format::Json => {
223                let mut out = String::new();
224                json_push(&self.root, 0, &mut out);
225                out.push('\n');
226                out
227            }
228            Format::Yaml => serde_norway::to_string(&to_yaml(&self.root)).unwrap_or_default(),
229        }
230    }
231
232    /// Mark the tree as saved (called by the host after a successful write).
233    pub fn mark_saved(&mut self) {
234        self.dirty = false;
235    }
236
237    /// Adjust the scroll so the selected row stays within a `height`-row window.
238    pub fn ensure_visible(&mut self, height: usize) {
239        self.scroll =
240            vix_list_state::ensure_visible(self.sel, self.scroll, height, self.rows.len());
241    }
242
243    /// Interpret a key event and report what the host should do next.
244    pub fn handle_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
245        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
246        if ctrl && key.code == KeyCode::Char('s') {
247            if self.mode == Mode::Edit {
248                self.commit_edit();
249            }
250            return Outcome::Save;
251        }
252        if self.mode == Mode::Edit {
253            self.edit_key(key);
254            return Outcome::Consumed;
255        }
256        match key.code {
257            KeyCode::Up | KeyCode::Char('k') => self.sel = self.sel.saturating_sub(1),
258            KeyCode::Down | KeyCode::Char('j') => self.step_down(1),
259            KeyCode::PageUp => self.sel = self.sel.saturating_sub(page.max(1)),
260            KeyCode::PageDown => self.step_down(page.max(1)),
261            KeyCode::Home => self.sel = 0,
262            KeyCode::End => self.sel = self.rows.len().saturating_sub(1),
263            KeyCode::Left | KeyCode::Char('h') => self.collapse_or_parent(),
264            KeyCode::Right | KeyCode::Char('l') => self.expand_or_child(),
265            KeyCode::Char(' ') => self.toggle(),
266            KeyCode::Enter | KeyCode::F(2) => self.activate(),
267            KeyCode::Char('u') => self.undo(),
268            KeyCode::Char('r') if ctrl => self.redo(),
269            KeyCode::Esc | KeyCode::Char('q') => return Outcome::Close,
270            _ => {}
271        }
272        Outcome::Consumed
273    }
274
275    /// Handle a key while editing a scalar.
276    fn edit_key(&mut self, key: KeyEvent) {
277        match key.code {
278            KeyCode::Enter => self.commit_edit(),
279            KeyCode::Esc => self.mode = Mode::Normal,
280            KeyCode::Backspace => {
281                self.edit_buf.pop();
282            }
283            KeyCode::Char(c) => self.edit_buf.push(c),
284            _ => {}
285        }
286    }
287
288    /// Move the selection down by `n`, clamped.
289    fn step_down(&mut self, n: usize) {
290        self.sel = (self.sel + n).min(self.rows.len().saturating_sub(1));
291    }
292
293    /// Collapse the selected container, or jump to its parent.
294    fn collapse_or_parent(&mut self) {
295        let Some(row) = self.rows.get(self.sel) else {
296            return;
297        };
298        if matches!(row.kind, Kind::Container(false)) {
299            let path = row.path.clone();
300            self.set_collapsed(&path, true);
301            self.rebuild();
302            self.select_path(&path);
303        } else {
304            let path = row.path.clone();
305            if path.is_empty() {
306                return;
307            }
308            self.select_path(&path[..path.len() - 1]);
309        }
310    }
311
312    /// Expand the selected container, or step into its first child.
313    fn expand_or_child(&mut self) {
314        let Some(row) = self.rows.get(self.sel) else {
315            return;
316        };
317        match row.kind {
318            Kind::Container(true) => {
319                let path = row.path.clone();
320                self.set_collapsed(&path, false);
321                self.rebuild();
322                self.select_path(&path);
323            }
324            Kind::Container(false) => {
325                if self.sel + 1 < self.rows.len() {
326                    self.sel += 1;
327                }
328            }
329            Kind::Scalar => {}
330        }
331    }
332
333    /// Toggle the selected container's fold state.
334    fn toggle(&mut self) {
335        let Some(row) = self.rows.get(self.sel) else {
336            return;
337        };
338        if let Kind::Container(c) = row.kind {
339            let path = row.path.clone();
340            self.set_collapsed(&path, !c);
341            self.rebuild();
342            self.select_path(&path);
343        }
344    }
345
346    /// Enter: edit a scalar, or toggle a container.
347    fn activate(&mut self) {
348        match self.rows.get(self.sel).map(|r| r.kind) {
349            Some(Kind::Scalar) => {
350                self.edit_buf = self.rows[self.sel].value.clone();
351                self.mode = Mode::Edit;
352            }
353            Some(Kind::Container(_)) => self.toggle(),
354            None => {}
355        }
356    }
357
358    /// Commit the edit buffer into the selected scalar as one undo step.
359    fn commit_edit(&mut self) {
360        self.mode = Mode::Normal;
361        let Some(row) = self.rows.get(self.sel) else {
362            return;
363        };
364        let path = row.path.clone();
365        let new = parse_scalar(&self.edit_buf);
366        if let Some(node) = at_mut(&mut self.root, &path) {
367            if *node == new {
368                return;
369            }
370            // Delegates to `vix-capped-stack` (Run H, T525) -- this crate
371            // and four siblings used to each hand-roll this identical clamp.
372            let snap = self.root.clone();
373            vix_capped_stack::push_capped(&mut self.undo, snap, HISTORY_CAP);
374            self.redo.clear();
375            if let Some(node) = at_mut(&mut self.root, &path) {
376                *node = new;
377            }
378            self.dirty = true;
379            self.rebuild();
380            self.select_path(&path);
381        }
382    }
383
384    /// Set the collapse flag of the container at `path`, if any.
385    fn set_collapsed(&mut self, path: &[Seg], collapsed: bool) {
386        if let Some(Val::Arr { collapsed: c, .. } | Val::Obj { collapsed: c, .. }) =
387            at_mut(&mut self.root, path)
388        {
389            *c = collapsed;
390        }
391    }
392
393    /// Recompute the visible rows from the tree, clamping the selection.
394    fn rebuild(&mut self) {
395        self.rows.clear();
396        flatten(&self.root, 0, String::new(), Vec::new(), &mut self.rows);
397        if self.sel >= self.rows.len() {
398            self.sel = self.rows.len().saturating_sub(1);
399        }
400    }
401
402    /// Select the row whose path equals `path` (else leave the selection clamped).
403    fn select_path(&mut self, path: &[Seg]) {
404        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
405            self.sel = i;
406        }
407    }
408
409    /// Undo the most recent value edit.
410    fn undo(&mut self) {
411        if let Some(prev) = self.undo.pop() {
412            self.redo.push(std::mem::replace(&mut self.root, prev));
413            self.dirty = true;
414            self.rebuild();
415        }
416    }
417
418    /// Redo the most recently undone edit.
419    fn redo(&mut self) {
420        if let Some(next) = self.redo.pop() {
421            self.undo.push(std::mem::replace(&mut self.root, next));
422            self.dirty = true;
423            self.rebuild();
424        }
425    }
426}
427
428/// Locate a mutable node by path.
429fn at_mut<'a>(mut node: &'a mut Val, path: &[Seg]) -> Option<&'a mut Val> {
430    for seg in path {
431        node = match (node, seg) {
432            (Val::Arr { items, .. }, Seg::Index(i)) => items.get_mut(*i)?,
433            (Val::Obj { entries, .. }, Seg::Key(k)) => {
434                entries.iter_mut().find(|(ek, _)| ek == k).map(|(_, v)| v)?
435            }
436            _ => return None,
437        };
438    }
439    Some(node)
440}
441
442/// The display text of a scalar (`""` for containers).
443fn scalar_text(val: &Val) -> String {
444    match val {
445        Val::Null => "null".to_string(),
446        Val::Bool(b) => b.to_string(),
447        Val::Num(s) | Val::Str(s) => s.clone(),
448        _ => String::new(),
449    }
450}
451
452/// Flatten `val` (named `label`, located at `path`) into display rows; recurses
453/// into expanded containers only.
454fn flatten(val: &Val, depth: usize, label: String, path: Vec<Seg>, out: &mut Vec<Row>) {
455    match val {
456        Val::Arr { collapsed, items } => {
457            out.push(Row {
458                depth,
459                label,
460                value: format!("[{}]", items.len()),
461                kind: Kind::Container(*collapsed),
462                path: path.clone(),
463            });
464            if !collapsed {
465                for (i, item) in items.iter().enumerate() {
466                    let mut p = path.clone();
467                    p.push(Seg::Index(i));
468                    flatten(item, depth + 1, format!("[{i}]"), p, out);
469                }
470            }
471        }
472        Val::Obj { collapsed, entries } => {
473            out.push(Row {
474                depth,
475                label,
476                value: format!("{{{}}}", entries.len()),
477                kind: Kind::Container(*collapsed),
478                path: path.clone(),
479            });
480            if !collapsed {
481                for (k, v) in entries {
482                    let mut p = path.clone();
483                    p.push(Seg::Key(k.clone()));
484                    flatten(v, depth + 1, k.clone(), p, out);
485                }
486            }
487        }
488        scalar => out.push(Row {
489            depth,
490            label,
491            value: scalar_text(scalar),
492            kind: Kind::Scalar,
493            path,
494        }),
495    }
496}
497
498/// Parse edited text into a scalar: `true`/`false`/`null`, else a number when it
499/// parses as one, else a string.
500fn parse_scalar(s: &str) -> Val {
501    match s {
502        "true" => Val::Bool(true),
503        "false" => Val::Bool(false),
504        "null" => Val::Null,
505        _ if s.parse::<f64>().is_ok() => Val::Num(s.to_string()),
506        _ => Val::Str(s.to_string()),
507    }
508}
509
510/// Convert a parsed `serde_norway` value into [`Val`] (preserving map order).
511fn from_yaml(v: &serde_norway::Value) -> Val {
512    use serde_norway::Value as Y;
513    match v {
514        Y::Null => Val::Null,
515        Y::Bool(b) => Val::Bool(*b),
516        Y::Number(n) => Val::Num(n.to_string()),
517        Y::String(s) => Val::Str(s.clone()),
518        Y::Sequence(items) => Val::Arr {
519            collapsed: false,
520            items: items.iter().map(from_yaml).collect(),
521        },
522        Y::Mapping(m) => Val::Obj {
523            collapsed: false,
524            entries: m.iter().map(|(k, v)| (yaml_key(k), from_yaml(v))).collect(),
525        },
526        Y::Tagged(t) => from_yaml(&t.value),
527    }
528}
529
530/// Render a mapping key as a string.
531fn yaml_key(k: &serde_norway::Value) -> String {
532    use serde_norway::Value as Y;
533    match k {
534        Y::String(s) => s.clone(),
535        Y::Bool(b) => b.to_string(),
536        Y::Number(n) => n.to_string(),
537        Y::Null => "null".to_string(),
538        _ => "?".to_string(),
539    }
540}
541
542/// Convert [`Val`] into a `serde_norway` value for YAML serialization.
543fn to_yaml(val: &Val) -> serde_norway::Value {
544    use serde_norway::Value as Y;
545    match val {
546        Val::Null => Y::Null,
547        Val::Bool(b) => Y::Bool(*b),
548        Val::Num(s) => s
549            .parse::<i64>()
550            .map(Y::from)
551            .or_else(|_| s.parse::<f64>().map(Y::from))
552            .unwrap_or_else(|_| Y::String(s.clone())),
553        Val::Str(s) => Y::String(s.clone()),
554        Val::Arr { items, .. } => Y::Sequence(items.iter().map(to_yaml).collect()),
555        Val::Obj { entries, .. } => {
556            let mut m = serde_norway::Mapping::new();
557            for (k, v) in entries {
558                m.insert(Y::String(k.clone()), to_yaml(v));
559            }
560            Y::Mapping(m)
561        }
562    }
563}
564
565/// Append two spaces per `depth` to `out`.
566fn indent(out: &mut String, depth: usize) {
567    for _ in 0..depth {
568        out.push_str("  ");
569    }
570}
571
572/// A JSON string literal with proper escaping.
573fn json_quote(s: &str) -> String {
574    serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
575}
576
577/// Pretty-print `val` as JSON into `out`, preserving object key order.
578fn json_push(val: &Val, depth: usize, out: &mut String) {
579    match val {
580        Val::Null => out.push_str("null"),
581        Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
582        Val::Num(s) => out.push_str(s),
583        Val::Str(s) => out.push_str(&json_quote(s)),
584        Val::Arr { items, .. } => {
585            if items.is_empty() {
586                out.push_str("[]");
587                return;
588            }
589            out.push_str("[\n");
590            for (i, item) in items.iter().enumerate() {
591                indent(out, depth + 1);
592                json_push(item, depth + 1, out);
593                if i + 1 < items.len() {
594                    out.push(',');
595                }
596                out.push('\n');
597            }
598            indent(out, depth);
599            out.push(']');
600        }
601        Val::Obj { entries, .. } => {
602            if entries.is_empty() {
603                out.push_str("{}");
604                return;
605            }
606            out.push_str("{\n");
607            for (i, (k, v)) in entries.iter().enumerate() {
608                indent(out, depth + 1);
609                out.push_str(&json_quote(k));
610                out.push_str(": ");
611                json_push(v, depth + 1, out);
612                if i + 1 < entries.len() {
613                    out.push(',');
614                }
615                out.push('\n');
616            }
617            indent(out, depth);
618            out.push('}');
619        }
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    fn key(c: char) -> KeyEvent {
628        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
629    }
630
631    fn code(code: KeyCode) -> KeyEvent {
632        KeyEvent::new(code, KeyModifiers::NONE)
633    }
634
635    fn ctrl(c: char) -> KeyEvent {
636        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
637    }
638
639    fn json() -> Tree {
640        Tree::from_text(r#"{"a": 1, "b": [10, 20], "c": "hi"}"#, Format::Json).unwrap()
641    }
642
643    #[test]
644    fn parses_json_into_rows_in_order() {
645        let t = json();
646        // root + a + b + b[0] + b[1] + c = 6 rows
647        assert_eq!(t.row_count(), 6);
648        assert_eq!(t.label(1), "a");
649        assert_eq!(t.value(1), "1");
650        assert_eq!(t.label(2), "b");
651        assert!(t.is_container(2));
652        assert_eq!(t.label(5), "c");
653        assert_eq!(t.value(5), "hi");
654    }
655
656    #[test]
657    fn invalid_input_is_an_error() {
658        assert!(Tree::from_text("{ not valid", Format::Json).is_err());
659    }
660
661    #[test]
662    fn parse_error_names_the_real_problem() {
663        // T543 (Run H): this used to be a bare `Option`, so the caller could
664        // only show a generic "not valid JSON or YAML" -- now the parser's
665        // own message (line/column included) is available.
666        // `Tree` isn't `Debug`, so `unwrap_err` isn't available; match instead.
667        let Err(err) = Tree::from_text("a: [1, 2\nb: 3", Format::Yaml) else {
668            panic!("expected a parse error");
669        };
670        assert!(
671            err.to_lowercase().contains("line"),
672            "the parser's own diagnostic names a location: {err:?}"
673        );
674    }
675
676    #[test]
677    fn collapsing_hides_children() {
678        let mut t = json();
679        t.handle_key(code(KeyCode::Down), 8); // a
680        t.handle_key(code(KeyCode::Down), 8); // b (array)
681        assert!(t.is_container(t.sel()));
682        t.handle_key(code(KeyCode::Left), 8); // collapse b
683        assert!(t.is_collapsed(t.sel()));
684        // root, a, b(collapsed), c = 4 rows
685        assert_eq!(t.row_count(), 4);
686    }
687
688    #[test]
689    fn edits_a_scalar_value() {
690        let mut t = json();
691        t.handle_key(code(KeyCode::Down), 8); // a = 1
692        t.handle_key(code(KeyCode::Enter), 8); // edit
693        assert!(t.is_editing());
694        t.handle_key(code(KeyCode::Backspace), 8);
695        t.handle_key(key('9'), 8);
696        t.handle_key(code(KeyCode::Enter), 8); // commit
697        assert_eq!(t.value(1), "9");
698        assert!(t.is_dirty());
699        assert!(t.to_text().contains("\"a\": 9"));
700    }
701
702    #[test]
703    fn undo_and_redo_a_value_edit() {
704        let mut t = json();
705        t.handle_key(code(KeyCode::Down), 8); // a
706        t.handle_key(code(KeyCode::Enter), 8);
707        t.handle_key(code(KeyCode::Backspace), 8);
708        t.handle_key(key('7'), 8);
709        t.handle_key(code(KeyCode::Enter), 8);
710        assert_eq!(t.value(1), "7");
711        t.handle_key(key('u'), 8);
712        assert_eq!(t.value(1), "1", "undo restores");
713        t.handle_key(ctrl('r'), 8);
714        assert_eq!(t.value(1), "7", "redo reapplies");
715    }
716
717    #[test]
718    fn json_round_trips_and_preserves_key_order() {
719        let t = Tree::from_text(r#"{"b": 1, "a": 2}"#, Format::Json).unwrap();
720        assert_eq!(
721            t.to_text(),
722            "{\n  \"b\": 1,\n  \"a\": 2\n}\n",
723            "keys stay in source order"
724        );
725    }
726
727    #[test]
728    fn yaml_parses_and_serializes() {
729        let t = Tree::from_text("name: vix\ntags:\n  - tui\n  - editor\n", Format::Yaml).unwrap();
730        assert_eq!(t.label(1), "name");
731        assert_eq!(t.value(1), "vix");
732        let out = t.to_text();
733        assert!(out.contains("name: vix"), "got: {out:?}");
734        assert!(out.contains("- tui"), "got: {out:?}");
735    }
736
737    #[test]
738    fn save_and_close_outcomes() {
739        let mut t = json();
740        assert_eq!(t.handle_key(ctrl('s'), 8), Outcome::Save);
741        assert_eq!(t.handle_key(key('q'), 8), Outcome::Close);
742        assert_eq!(t.handle_key(code(KeyCode::Esc), 8), Outcome::Close);
743    }
744
745    proptest::proptest! {
746        // Parsing arbitrary text as JSON or YAML never panics (invalid → None).
747        #[test]
748        fn from_text_never_panics(s in ".*") {
749            let _ = Tree::from_text(&s, Format::Json);
750            let _ = Tree::from_text(&s, Format::Yaml);
751        }
752    }
753
754    #[test]
755    fn deeply_nested_value_does_not_overflow() {
756        // The recursive `from_yaml`/`rebuild` walk must not stack-overflow on
757        // pathologically nested input; serde's depth limit rejects it as an error.
758        let deep = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
759        assert!(Tree::from_text(&deep, Format::Json).is_err());
760    }
761}