Skip to main content

flower_core/
model.rs

1//! The frontend-neutral editor model and its structural operations.
2//!
3//! `Model` is generic over a [`Backend`]: it builds path-addressed [`EditOp`]s,
4//! applies them through the backend, and re-derives its view from
5//! [`Backend::to_value`] after each change. It owns no editor, no format, no
6//! filesystem, and no terminal — the backend owns the document; the embedder
7//! owns file I/O and rendering.
8
9use std::collections::HashSet;
10
11use anyhow::Result;
12use fig::Value;
13
14use crate::backend::{Backend, EditOp};
15use crate::schema::{FieldRule, Schema};
16use fig_schema::{Issue, SegPat, Validation};
17use crate::tree::{self, Row, Seg};
18
19/// Interaction mode: normal navigation, or editing a scalar's text.
20pub enum Mode {
21    Normal,
22    Editing { buffer: String },
23}
24
25pub struct Model<B> {
26    backend: B,
27
28    /// Derived view state, rebuilt from `backend.to_value()` after every edit.
29    value: Value,
30    pub rows: Vec<Row>,
31    collapsed: HashSet<Vec<Seg>>,
32    /// Top-level mapping keys to hide from the row projection (but keep in the
33    /// document). Empty for a standalone config; a prov/diaryx embedder passes the
34    /// managed-key set so those fields stay lossless and out of view.
35    hidden: HashSet<String>,
36    /// Top-level mapping keys the *workspace* maintains: shown, but not editable.
37    ///
38    /// The complement of [`hidden`](Self::hidden), for the other kind of managed
39    /// field. A hidden key is edited through some other affordance (a title bar,
40    /// a link view) and would only clutter the list; a derived key — a recomputed
41    /// timestamp, a content hash — has no other affordance because *nothing*
42    /// edits it by hand: the workspace overwrites it on the next write. Hiding
43    /// those two alike leaves a user wondering where a field they can see in the
44    /// file went, so a derived key keeps its row and declines edits instead.
45    derived: HashSet<String>,
46    /// The schema governing this document, if any — from the backend
47    /// ([`Backend::schema`]) or injected by the embedder ([`Model::set_schema`]).
48    /// Drives type-directed parsing and commit-time value validation; absent, the
49    /// model behaves exactly as before.
50    schema: Option<Schema>,
51
52    pub selected: usize,
53    pub mode: Mode,
54    pub status: String,
55    pub dirty: bool,
56}
57
58impl<B: Backend> Model<B> {
59    /// Build a model over `backend`.
60    pub fn new(backend: B) -> Result<Self> {
61        Self::with_hidden(backend, Vec::new())
62    }
63
64    /// Build a model that hides the given **top-level** mapping keys from the row
65    /// projection while keeping them in the document (see
66    /// [`tree::build_rows`](crate::tree::build_rows)). For an embedder whose
67    /// format reserves some top-level keys (prov/diaryx-managed frontmatter).
68    pub fn with_hidden(backend: B, hidden: Vec<String>) -> Result<Self> {
69        Self::with_managed(backend, hidden, Vec::new())
70    }
71
72    /// Build a model over `backend` distinguishing the two kinds of managed key:
73    /// `hidden` ones produce no row (edited through another affordance), while
74    /// `derived` ones keep their row but decline every edit (the workspace
75    /// maintains them — see [`derived`](Self::derived)).
76    ///
77    /// A key in both is hidden: no row means nothing to mark read-only.
78    pub fn with_managed(backend: B, hidden: Vec<String>, derived: Vec<String>) -> Result<Self> {
79        Self::with_collapsed(backend, hidden, derived, Vec::new())
80    }
81
82    /// Build a model whose containers at `collapsed` arrive **shut**, before the
83    /// first row list is ever built.
84    ///
85    /// A document can have one field nobody reads as a list: an index document's
86    /// `contents` is one row per child — ninety-five of them in a year index,
87    /// ahead of the four fields anyone types by hand. Such a section wants to open
88    /// as a summary, not a wall you scroll past. Toggling it afterwards through
89    /// [`activate`](Self::activate) would work, but that is the *interactive*
90    /// door: it moves the selection and rebuilds the row list once per container.
91    /// Seeding the set here costs neither — the paths are in place before
92    /// `reload`, so the opening frame is already correct.
93    ///
94    /// A path that names a scalar (or nothing at all) is inert rather than an
95    /// error, so a caller can name the keys it *wants* collapsed without first
96    /// checking which of them turned out to be containers.
97    pub fn with_collapsed(
98        backend: B,
99        hidden: Vec<String>,
100        derived: Vec<String>,
101        collapsed: Vec<Vec<Seg>>,
102    ) -> Result<Self> {
103        // The backend supplies the schema when it knows one (a prov backend);
104        // otherwise it stays `None` until an embedder injects one.
105        let schema = backend.schema();
106        let mut model = Model {
107            backend,
108            value: Value::Null,
109            rows: Vec::new(),
110            collapsed: collapsed.into_iter().collect(),
111            hidden: hidden.into_iter().collect(),
112            derived: derived.into_iter().collect(),
113            schema,
114            selected: 0,
115            mode: Mode::Normal,
116            status: "opened".to_string(),
117            dirty: false,
118        };
119        model.reload()?;
120        Ok(model)
121    }
122
123    /// Inject a schema out-of-band — the embedder precedent, mirroring
124    /// [`with_hidden`](Self::with_hidden). For a host whose backend does not
125    /// supply one but that *knows* the governing schema (a diaryx host feeding a
126    /// fig-backed frontmatter block plus its resolved workspace config).
127    pub fn set_schema(&mut self, schema: Schema) {
128        self.schema = Some(schema);
129    }
130
131    /// The schema governing the document, if any.
132    pub fn schema(&self) -> Option<&Schema> {
133        self.schema.as_ref()
134    }
135
136    /// The schema rule governing the node at `path`, if any — for a frontend
137    /// deciding a widget (a picker for an enum field) or presentation.
138    pub fn rule_at(&self, path: &[Seg]) -> Option<&FieldRule> {
139        self.schema.as_ref().and_then(|s| s.rule_for(path))
140    }
141
142    /// The kind of the document root, for a frontend deciding how to add a
143    /// top-level entry: `"map"`, `"seq"`, or `"scalar"`.
144    pub fn root_kind(&self) -> &'static str {
145        match self.value {
146            Value::Map(_) => "map",
147            Value::Seq(_) => "seq",
148            _ => "scalar",
149        }
150    }
151
152    /// How many of the hidden top-level keys are actually present in the document
153    /// — for a "N managed fields" affordance.
154    pub fn hidden_present(&self) -> usize {
155        match &self.value {
156            Value::Map(entries) => entries
157                .iter()
158                .filter(|(k, _)| matches!(k, Value::Str(s) if self.hidden.contains(s)))
159                .count(),
160            _ => 0,
161        }
162    }
163
164    /// Whether the node at `path` sits under a workspace-maintained (derived)
165    /// top-level key — for a frontend rendering it read-only rather than as an
166    /// editable control. Edits to it are declined at the commit funnel regardless.
167    pub fn is_derived(&self, path: &[Seg]) -> bool {
168        matches!(path.first(), Some(Seg::Key(k)) if self.derived.contains(k))
169    }
170
171    /// The schema-declared top-level fields the document does **not** yet carry
172    /// — what an "add field" affordance offers, so a declared field is reachable
173    /// before it exists.
174    ///
175    /// Rows are projected from the *document*
176    /// ([`build_rows`](crate::tree::build_rows)), so a field the schema declares
177    /// but the document omits has no row and is otherwise unreachable: the user
178    /// would have to know the key and type it exactly. This closes that gap —
179    /// it is the schema's half of the row list, and the reason a declared type
180    /// is worth writing down for a field that is empty.
181    ///
182    /// Only a rule addressing exactly one top-level key names an addable field:
183    /// an each-item or subtree rule governs *within* a field rather than naming
184    /// one. Hidden (managed) keys are never offered — the embedder reserves
185    /// those. Order follows the schema's own rule order, so a caller can present
186    /// them as declared.
187    pub fn addable_fields(&self) -> Vec<&FieldRule> {
188        let Some(schema) = &self.schema else {
189            return Vec::new();
190        };
191        // Only a map root can take a top-level key at all.
192        let Value::Map(entries) = &self.value else {
193            return Vec::new();
194        };
195        let present: HashSet<&str> = entries
196            .iter()
197            .filter_map(|(k, _)| match k {
198                Value::Str(s) => Some(s.as_str()),
199                _ => None,
200            })
201            .collect();
202        let mut seen = HashSet::new();
203        schema
204            .rules()
205            .iter()
206            .filter(|rule| {
207                let [SegPat::Key(name)] = rule.at.0.as_slice() else {
208                    return false;
209                };
210                !present.contains(name.as_str())
211                    && !self.hidden.contains(name)
212                    && seen.insert(name.as_str())
213            })
214            .collect()
215    }
216
217    /// The canonical serialized document — what the embedder writes on save.
218    pub fn source_snapshot(&self) -> String {
219        self.backend.source().unwrap_or_default()
220    }
221
222    /// The backend, for backend-specific reads (e.g. a prov backend's body).
223    pub fn backend(&self) -> &B {
224        &self.backend
225    }
226
227    /// The backend, for backend-specific operations that do **not** change the
228    /// metadata tree flower renders (e.g. replacing a prov document's prose
229    /// body). An op that *does* change the metadata leaves the view stale — go
230    /// through the model's own edit methods for those.
231    pub fn backend_mut(&mut self) -> &mut B {
232        &mut self.backend
233    }
234
235    pub fn set_status(&mut self, s: impl Into<String>) {
236        self.status = s.into();
237    }
238
239    /// Clear the dirty flag after the embedder has persisted the source.
240    pub fn mark_saved(&mut self) {
241        self.dirty = false;
242    }
243
244    // ── view derivation ───────────────────────────────────────────────────────
245
246    /// Re-derive `value` + `rows` from the backend's current tree.
247    fn reload(&mut self) -> Result<()> {
248        self.value = self
249            .backend
250            .to_value()
251            .map_err(|e| anyhow::anyhow!("reading value tree: {e}"))?;
252        self.rebuild_rows();
253        Ok(())
254    }
255
256    fn rebuild_rows(&mut self) {
257        self.rows = tree::build_rows(&self.value, &self.collapsed, &self.hidden);
258        if self.selected >= self.rows.len() {
259            self.selected = self.rows.len().saturating_sub(1);
260        }
261    }
262
263    fn selected_row(&self) -> Option<&Row> {
264        self.rows.get(self.selected)
265    }
266
267    /// Re-anchor selection onto `path` after a rebuild, or clamp if it's gone.
268    fn select_path(&mut self, path: &[Seg]) {
269        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
270            self.selected = i;
271        } else if self.selected >= self.rows.len() {
272            self.selected = self.rows.len().saturating_sub(1);
273        }
274    }
275
276    // ── navigation ────────────────────────────────────────────────────────────
277
278    pub fn move_down(&mut self) {
279        if self.selected + 1 < self.rows.len() {
280            self.selected += 1;
281        }
282    }
283
284    pub fn move_up(&mut self) {
285        self.selected = self.selected.saturating_sub(1);
286    }
287
288    /// `l`: expand a collapsed container, else step into its first child.
289    pub fn expand_or_enter(&mut self) {
290        let Some(row) = self.selected_row() else {
291            return;
292        };
293        if row.is_container() {
294            if !row.expanded {
295                let path = row.path.clone();
296                self.collapsed.remove(&path);
297                self.rebuild_rows();
298                self.select_path(&path);
299            } else if self.selected + 1 < self.rows.len()
300                && self.rows[self.selected + 1].depth > row.depth
301            {
302                self.selected += 1;
303            }
304        }
305    }
306
307    /// `h`: collapse an expanded container, else step out to the parent row.
308    pub fn collapse_or_leave(&mut self) {
309        let Some(row) = self.selected_row() else {
310            return;
311        };
312        if row.is_container() && row.expanded {
313            let path = row.path.clone();
314            self.collapsed.insert(path.clone());
315            self.rebuild_rows();
316            self.select_path(&path);
317            return;
318        }
319        // Step out: the nearest earlier row at a shallower depth is the parent.
320        let depth = row.depth;
321        if depth == 0 {
322            return;
323        }
324        for i in (0..self.selected).rev() {
325            if self.rows[i].depth < depth {
326                self.selected = i;
327                return;
328            }
329        }
330    }
331
332    /// Whether the container at `path` is collapsed. Answers for a node with no
333    /// row too (one nested inside another collapsed container), which
334    /// [`Row::expanded`](crate::Row) cannot.
335    pub fn is_collapsed(&self, path: &[Seg]) -> bool {
336        self.collapsed.contains(path)
337    }
338
339    /// Collapse or expand the container at `path`, leaving the selection where the
340    /// user put it — the by-path, non-interactive counterpart to
341    /// [`activate`](Self::activate).
342    ///
343    /// `activate` folds *the selected row*, so driving it from a path means moving
344    /// the selection first and putting it back after. This doesn't: it re-anchors
345    /// onto whatever was selected before, and only falls back to `path` itself when
346    /// the selection was a descendant that the fold just took off screen.
347    ///
348    /// A path naming a scalar (or nothing) is inert — see
349    /// [`with_collapsed`](Self::with_collapsed).
350    pub fn set_collapsed(&mut self, path: &[Seg], collapsed: bool) {
351        let changed = if collapsed {
352            self.collapsed.insert(path.to_vec())
353        } else {
354            self.collapsed.remove(path)
355        };
356        if !changed {
357            return;
358        }
359        let was = self.selected_row().map(|r| r.path.clone());
360        self.rebuild_rows();
361        if let Some(was) = was {
362            // A row swallowed by the fold has no path to return to; its nearest
363            // surviving ancestor is the container the user just shut.
364            if collapsed && was.len() > path.len() && was.starts_with(path) {
365                self.select_path(path);
366            } else {
367                self.select_path(&was);
368            }
369        }
370    }
371
372    /// `Enter`/`Space`: toggle a container's expansion, or edit a scalar.
373    pub fn activate(&mut self) {
374        let Some(row) = self.selected_row() else {
375            return;
376        };
377        if row.is_container() {
378            let path = row.path.clone();
379            if row.expanded {
380                self.collapsed.insert(path.clone());
381            } else {
382                self.collapsed.remove(&path);
383            }
384            self.rebuild_rows();
385            self.select_path(&path);
386        } else {
387            self.begin_edit();
388        }
389    }
390
391    // ── editing ───────────────────────────────────────────────────────────────
392
393    pub fn begin_edit(&mut self) {
394        let Some(row) = self.selected_row() else {
395            return;
396        };
397        if !row.is_scalar() {
398            self.status = "can only edit scalar values".to_string();
399            return;
400        }
401        let seed = self
402            .value_at(&row.path)
403            .map(tree::edit_seed)
404            .unwrap_or_default();
405        self.mode = Mode::Editing { buffer: seed };
406    }
407
408    pub fn edit_push(&mut self, c: char) {
409        if let Mode::Editing { buffer } = &mut self.mode {
410            buffer.push(c);
411        }
412    }
413
414    pub fn edit_backspace(&mut self) {
415        if let Mode::Editing { buffer } = &mut self.mode {
416            buffer.pop();
417        }
418    }
419
420    pub fn edit_cancel(&mut self) {
421        self.mode = Mode::Normal;
422        self.status = "edit cancelled".to_string();
423    }
424
425    pub fn edit_commit(&mut self) {
426        let Mode::Editing { buffer } = &mut self.mode else {
427            return;
428        };
429        let buffer = std::mem::take(buffer);
430        self.mode = Mode::Normal;
431
432        let Some(row) = self.selected_row() else {
433            return;
434        };
435        let path = row.path.clone();
436        let value = self.coerce_text(&path, &buffer);
437        self.commit(
438            EditOp::ReplaceValue {
439                path: path.clone(),
440                value,
441            },
442            path,
443            "value updated",
444        );
445    }
446
447    /// Programmatically replace the value at `path` (any depth), refreshing the
448    /// view. The non-interactive counterpart to [`edit_commit`](Self::edit_commit)
449    /// — for an embedder or FFI that edits by path rather than through the
450    /// selection.
451    pub fn set_value_at(&mut self, path: &[Seg], value: Value) {
452        self.commit(
453            EditOp::ReplaceValue {
454                path: path.to_vec(),
455                value,
456            },
457            path.to_vec(),
458            "value updated",
459        );
460    }
461
462    /// Set the scalar at `path` from an edit-buffer `text`, coercing by the
463    /// schema's expected type when known (a `str` field keeps `"123"` a string)
464    /// and otherwise guessing by literal shape — the by-path, schema-aware analog
465    /// of [`edit_commit`](Self::edit_commit). Validation (closed-vocabulary
466    /// rejection) still happens at the commit funnel.
467    pub fn set_scalar_text(&mut self, path: &[Seg], text: &str) {
468        let value = self.coerce_text(path, text);
469        self.set_value_at(path, value);
470    }
471
472    /// Turn edit-buffer `text` into the value that belongs at `path`: the type the
473    /// schema declares for that path when it declares one, and otherwise a guess
474    /// from the literal's shape.
475    ///
476    /// The single rule behind [`edit_commit`](Self::edit_commit),
477    /// [`set_scalar_text`](Self::set_scalar_text),
478    /// [`insert_key_text`](Self::insert_key_text) and
479    /// [`append_item_text`](Self::append_item_text). It is keyed on the path of the
480    /// value being *written*, not of its container — that is what lets an
481    /// each-item rule type a list's items independently of the list.
482    fn coerce_text(&self, path: &[Seg], text: &str) -> Value {
483        match self.rule_at(path).and_then(|r| r.ty) {
484            Some(ty) => ty.coerce(text),
485            None => tree::parse_scalar(text),
486        }
487    }
488
489    /// Rename the mapping entry at `path` to `new_key`, keeping its value and
490    /// re-anchoring the selection onto the renamed entry. A no-op (with a status
491    /// hint) when `path` doesn't end in a key — a sequence item has no key. The
492    /// backend rejects a name that collides with an existing sibling key.
493    pub fn rename_key(&mut self, path: &[Seg], new_key: &str) {
494        match path.last() {
495            Some(Seg::Key(_)) => {
496                let mut anchor = path[..path.len() - 1].to_vec();
497                anchor.push(Seg::Key(new_key.to_string()));
498                self.commit(
499                    EditOp::RenameKey {
500                        path: path.to_vec(),
501                        new_key: new_key.to_string(),
502                    },
503                    anchor,
504                    "renamed",
505                );
506            }
507            _ => self.status = "only mapping keys can be renamed".to_string(),
508        }
509    }
510
511    /// Insert `key = value` into the mapping at `map_path`, selecting the new
512    /// entry. A frontend offers this on a map container; the backend rejects a
513    /// duplicate key or a non-mapping target, leaving the document untouched.
514    pub fn insert_key(&mut self, map_path: &[Seg], key: &str, value: Value) {
515        let mut anchor = map_path.to_vec();
516        anchor.push(Seg::Key(key.to_string()));
517        self.commit(
518            EditOp::InsertKey {
519                map_path: map_path.to_vec(),
520                key: key.to_string(),
521                value,
522            },
523            anchor,
524            "inserted",
525        );
526    }
527
528    /// Insert `key = text` into the mapping at `map_path`, coercing `text` by the
529    /// type the schema declares for the new entry and otherwise guessing by literal
530    /// shape — the insert-shaped analog of
531    /// [`set_scalar_text`](Self::set_scalar_text).
532    ///
533    /// Prefer this to [`insert_key`](Self::insert_key) whenever the value comes
534    /// from a user's text: a caller that shape-guesses on its own writes `2026` as
535    /// an integer into a field the schema declares `str`, and gets no say from the
536    /// schema it is otherwise honoring everywhere else.
537    pub fn insert_key_text(&mut self, map_path: &[Seg], key: &str, text: &str) {
538        let mut target = map_path.to_vec();
539        target.push(Seg::Key(key.to_string()));
540        let value = self.coerce_text(&target, text);
541        self.insert_key(map_path, key, value);
542    }
543
544    /// Append `value` to the sequence at `seq_path`, selecting the new item.
545    pub fn append_item(&mut self, seq_path: &[Seg], value: Value) {
546        let idx = self.seq_len(seq_path);
547        let mut anchor = seq_path.to_vec();
548        anchor.push(Seg::Index(idx));
549        self.commit(
550            EditOp::AppendItem {
551                seq_path: seq_path.to_vec(),
552                value,
553            },
554            anchor,
555            "appended",
556        );
557    }
558
559    /// Append `text` to the sequence at `seq_path`, coercing it by the type the
560    /// schema declares for the sequence's *items* and otherwise guessing by literal
561    /// shape — the append-shaped analog of
562    /// [`set_scalar_text`](Self::set_scalar_text).
563    ///
564    /// The item's type comes from the rule matching the item path (an each-item or
565    /// subtree rule), not from the rule on the list itself: `tags` is a `seq`, its
566    /// items are `str`.
567    pub fn append_item_text(&mut self, seq_path: &[Seg], text: &str) {
568        let mut target = seq_path.to_vec();
569        target.push(Seg::Index(self.seq_len(seq_path)));
570        let value = self.coerce_text(&target, text);
571        self.append_item(seq_path, value);
572    }
573
574    /// Move the selected row one place earlier among its siblings — a sequence
575    /// item via fig's array-move, a mapping entry via a one-swap reorder.
576    pub fn move_selected_up(&mut self) {
577        self.reorder_selected(-1);
578    }
579
580    /// Move the selected row one place later among its siblings.
581    pub fn move_selected_down(&mut self) {
582        self.reorder_selected(1);
583    }
584
585    /// The shared body of [`move_selected_up`](Self::move_selected_up) /
586    /// [`move_selected_down`](Self::move_selected_down): shift the selected row by
587    /// `delta` positions within its parent container.
588    fn reorder_selected(&mut self, delta: isize) {
589        let Some(row) = self.selected_row() else {
590            return;
591        };
592        let path = row.path.clone();
593        let Some(last) = path.last().cloned() else {
594            self.status = "cannot move the document root".to_string();
595            return;
596        };
597        let parent = path[..path.len() - 1].to_vec();
598        match last {
599            Seg::Index(i) => {
600                let len = self.seq_len(&parent);
601                let to = i as isize + delta;
602                if to < 0 || to as usize >= len {
603                    self.status = "already at the edge".to_string();
604                    return;
605                }
606                let to = to as usize;
607                let mut anchor = parent.clone();
608                anchor.push(Seg::Index(to));
609                self.commit(
610                    EditOp::MoveItem {
611                        seq_path: parent,
612                        from: i,
613                        to,
614                    },
615                    anchor,
616                    "moved",
617                );
618            }
619            Seg::Key(k) => {
620                let keys = self.map_keys(&parent);
621                let Some(pos) = keys.iter().position(|x| *x == k) else {
622                    return;
623                };
624                let target = pos as isize + delta;
625                if target < 0 || target as usize >= keys.len() {
626                    self.status = "already at the edge".to_string();
627                    return;
628                }
629                let mut order = keys;
630                order.swap(pos, target as usize);
631                self.commit(
632                    EditOp::ReorderKeys {
633                        map_path: parent,
634                        keys: order,
635                    },
636                    path,
637                    "moved",
638                );
639            }
640        }
641    }
642
643    /// The value the document currently holds at `path` (the whole tree for the
644    /// empty path), or `None` when the path doesn't resolve — for a frontend
645    /// reading a row's value without reaching for the backend.
646    pub fn value_at(&self, path: &[Seg]) -> Option<&Value> {
647        tree::value_at(&self.value, path)
648    }
649
650    /// The mapping keys at `path`, in document order (empty for a non-mapping).
651    fn map_keys(&self, path: &[Seg]) -> Vec<String> {
652        tree::map_keys(&self.value, path).unwrap_or_default()
653    }
654
655    /// The length of the sequence at `path` (0 for a non-sequence) — the index an
656    /// append will land at.
657    pub fn seq_len(&self, path: &[Seg]) -> usize {
658        tree::seq_len(&self.value, path).unwrap_or(0)
659    }
660
661    /// `x`: delete the selected mapping entry or sequence item.
662    pub fn delete_selected(&mut self) {
663        let Some(row) = self.selected_row() else {
664            return;
665        };
666        let path = row.path.clone();
667        let (op, anchor) = match path.last() {
668            Some(Seg::Index(i)) => {
669                let seq_path = path[..path.len() - 1].to_vec();
670                (
671                    EditOp::RemoveItem {
672                        seq_path: seq_path.clone(),
673                        index: *i,
674                    },
675                    seq_path,
676                )
677            }
678            Some(Seg::Key(_)) => (
679                EditOp::DeleteKey { path: path.clone() },
680                path[..path.len() - 1].to_vec(),
681            ),
682            None => {
683                self.status = "cannot delete the document root".to_string();
684                return;
685            }
686        };
687        self.commit(op, anchor, "deleted");
688    }
689
690    /// Apply one edit through the backend, then refresh the view (or report the
691    /// rollback). The single path every mutation funnels through — and the choke
692    /// point where the schema validates values: a closed vocabulary rejects an
693    /// unknown value here, before it reaches the backend; an open one applies but
694    /// surfaces a soft warning. fig's reparse stays the last-resort backstop.
695    fn commit(&mut self, op: EditOp, anchor: Vec<Seg>, msg: &str) {
696        // A workspace-maintained field declines every mutation, not just a value
697        // edit: renaming or deleting one would be undone on the next write just
698        // as surely as retyping it.
699        if let Some(key) = op_root_key(&op)
700            && self.derived.contains(key)
701        {
702            self.status = format!("rejected: `{key}` is maintained by the workspace");
703            return;
704        }
705        let mut warn: Option<Issue> = None;
706        if let Some((path, value)) = op_target(&op)
707            && let Some(rule) = self.rule_at(&path)
708        {
709            match rule.validate(value) {
710                Validation::Reject(why) => {
711                    self.status = format!("rejected: {why}");
712                    return;
713                }
714                Validation::Warn(why) => warn = Some(why),
715                Validation::Ok => {}
716            }
717        }
718        match self.backend.apply(op) {
719            Ok(()) => {
720                self.after_edit(&anchor, msg);
721                // A soft-warn overrides the success status so the user sees it.
722                if let Some(why) = warn {
723                    self.status = why.to_string();
724                }
725            }
726            // The backend rolled back / declined; the document is untouched.
727            Err(e) => self.status = format!("rejected: {e}"),
728        }
729    }
730
731    /// Shared tail of a successful mutation: refresh the view, re-anchor
732    /// selection, mark dirty, set the status line.
733    fn after_edit(&mut self, anchor: &[Seg], msg: &str) {
734        if let Err(e) = self.reload() {
735            self.status = format!("view refresh failed: {e}");
736            return;
737        }
738        self.select_path(anchor);
739        self.dirty = true;
740        self.status = msg.to_string();
741    }
742}
743
744/// The (target path, value) a value-bearing [`EditOp`] writes — what schema
745/// validation checks. An append's item index isn't known here, so a placeholder
746/// `Index(0)` stands in; it only serves to match an `EachItem` rule pattern, which
747/// is index-agnostic. Structural ops (delete, move, reorder, rename) carry no new
748/// value and return `None`.
749/// The top-level mapping key an op would change, if any — the unit at which a
750/// document's managed fields are declared, so an edit anywhere beneath one
751/// (an item of a managed list, a nested key) is caught along with the field
752/// itself.
753fn op_root_key(op: &EditOp) -> Option<&str> {
754    fn first_key(path: &[Seg]) -> Option<&str> {
755        match path.first() {
756            Some(Seg::Key(k)) => Some(k.as_str()),
757            _ => None,
758        }
759    }
760    match op {
761        EditOp::ReplaceValue { path, .. }
762        | EditOp::DeleteKey { path }
763        | EditOp::RenameKey { path, .. } => first_key(path),
764        EditOp::RemoveItem { seq_path, .. }
765        | EditOp::AppendItem { seq_path, .. }
766        | EditOp::MoveItem { seq_path, .. } => first_key(seq_path),
767        // An insert *at the root* names the new top-level key itself; deeper, the
768        // container it lands in is what matters.
769        EditOp::InsertKey { map_path, key, .. } => match map_path.first() {
770            None => Some(key.as_str()),
771            _ => first_key(map_path),
772        },
773        // Reordering the root's own keys moves no field's value.
774        EditOp::ReorderKeys { map_path, .. } => first_key(map_path),
775    }
776}
777
778fn op_target(op: &EditOp) -> Option<(Vec<Seg>, &Value)> {
779    match op {
780        EditOp::ReplaceValue { path, value } => Some((path.clone(), value)),
781        EditOp::InsertKey {
782            map_path,
783            key,
784            value,
785        } => {
786            let mut p = map_path.clone();
787            p.push(Seg::Key(key.clone()));
788            Some((p, value))
789        }
790        EditOp::AppendItem { seq_path, value } => {
791            let mut p = seq_path.clone();
792            p.push(Seg::Index(0));
793            Some((p, value))
794        }
795        _ => None,
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::backend::FigBackend;
803    use fig::Format;
804
805    const SAMPLE: &str = "\
806# flower sample config — comments and formatting below should survive edits
807title = \"flower\"
808version = 1
809enabled = true
810
811# the server block
812[server]
813host = \"localhost\"
814port = 8080
815tags = [\"alpha\", \"beta\"]
816
817[server.limits]
818max_connections = 100
819timeout = 30.5
820";
821
822    fn sample_model() -> Model<FigBackend> {
823        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open backend");
824        Model::new(backend).expect("build model")
825    }
826
827    fn select(model: &mut Model<FigBackend>, path: &[Seg]) {
828        model.selected = model
829            .rows
830            .iter()
831            .position(|r| r.path == path)
832            .unwrap_or_else(|| panic!("no row for {path:?}"));
833    }
834
835    fn type_value(model: &mut Model<FigBackend>, text: &str) {
836        if let Mode::Editing { buffer } = &mut model.mode {
837            buffer.clear();
838        }
839        for c in text.chars() {
840            model.edit_push(c);
841        }
842        model.edit_commit();
843    }
844
845    #[test]
846    fn edits_a_scalar_losslessly() {
847        let mut model = sample_model();
848
849        select(&mut model, &[Seg::Key("version".into())]);
850        model.begin_edit();
851        type_value(&mut model, "2");
852
853        let src = model.source_snapshot();
854        assert!(src.contains("version = 2"), "value changed:\n{src}");
855        assert!(src.contains("# the server block"), "comment preserved:\n{src}");
856        assert!(
857            src.contains("# flower sample config"),
858            "header preserved:\n{src}"
859        );
860        assert!(model.dirty);
861    }
862
863    #[test]
864    fn edits_a_nested_string() {
865        let mut model = sample_model();
866
867        select(
868            &mut model,
869            &[Seg::Key("server".into()), Seg::Key("host".into())],
870        );
871        model.begin_edit();
872        type_value(&mut model, "example.com");
873
874        let src = model.source_snapshot();
875        assert!(src.contains("host = \"example.com\""), "nested edit:\n{src}");
876        assert!(src.contains("port = 8080"), "sibling untouched:\n{src}");
877    }
878
879    #[test]
880    fn deletes_a_key() {
881        let mut model = sample_model();
882
883        select(&mut model, &[Seg::Key("enabled".into())]);
884        model.delete_selected();
885
886        let src = model.source_snapshot();
887        assert!(!src.contains("enabled = true"), "key removed:\n{src}");
888        assert!(src.contains("title = \"flower\""), "siblings kept:\n{src}");
889    }
890
891    #[test]
892    fn appends_a_sequence_item() {
893        let mut model = sample_model();
894        let tags = vec![Seg::Key("server".into()), Seg::Key("tags".into())];
895        model.append_item(&tags, Value::Str("gamma".into()));
896
897        let src = model.source_snapshot();
898        assert!(src.contains("gamma"), "item appended:\n{src}");
899        assert!(src.contains("alpha") && src.contains("beta"), "siblings kept");
900        assert!(model.dirty);
901    }
902
903    #[test]
904    fn inserts_a_mapping_key() {
905        let mut model = sample_model();
906        let server = vec![Seg::Key("server".into())];
907        model.insert_key(&server, "scheme", Value::Str("https".into()));
908
909        let src = model.source_snapshot();
910        // fig may quote the inserted key (`"scheme" = …`); both are valid TOML.
911        assert!(
912            src.contains("scheme") && src.contains("= \"https\""),
913            "key inserted:\n{src}"
914        );
915        assert!(src.contains("host = \"localhost\""), "siblings kept");
916    }
917
918    #[test]
919    fn moves_a_sequence_item_and_reorders_keys() {
920        let mut model = sample_model();
921
922        // Move the second tag ("beta", index 1) up to index 0.
923        select(
924            &mut model,
925            &[
926                Seg::Key("server".into()),
927                Seg::Key("tags".into()),
928                Seg::Index(1),
929            ],
930        );
931        model.move_selected_up();
932        let src = model.source_snapshot();
933        let a = src.find("alpha").unwrap();
934        let b = src.find("beta").unwrap();
935        assert!(b < a, "beta now precedes alpha:\n{src}");
936
937        // Move a top-level mapping entry down: title should follow version.
938        select(&mut model, &[Seg::Key("title".into())]);
939        model.move_selected_down();
940        let src = model.source_snapshot();
941        assert!(
942            src.find("version").unwrap() < src.find("title").unwrap(),
943            "version now precedes title:\n{src}"
944        );
945    }
946
947    #[test]
948    fn hidden_top_level_keys_are_projected_out_but_kept_lossless() {
949        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
950        let mut model =
951            Model::with_hidden(backend, vec!["title".into(), "enabled".into()]).expect("model");
952
953        // Hidden keys produce no rows…
954        assert!(!model.rows.iter().any(|r| r.path == [Seg::Key("title".into())]));
955        assert!(!model.rows.iter().any(|r| r.path == [Seg::Key("enabled".into())]));
956        // …but a visible sibling is still there,
957        assert!(model.rows.iter().any(|r| r.path == [Seg::Key("version".into())]));
958        // …and the hidden keys remain in the document bytes.
959        assert!(model.source_snapshot().contains("title = \"flower\""));
960        assert!(model.source_snapshot().contains("enabled = true"));
961
962        // Editing a visible key doesn't disturb the hidden ones.
963        select(&mut model, &[Seg::Key("version".into())]);
964        model.begin_edit();
965        type_value(&mut model, "9");
966        let src = model.source_snapshot();
967        assert!(src.contains("version = 9"));
968        assert!(src.contains("title = \"flower\"") && src.contains("enabled = true"));
969    }
970
971    #[test]
972    fn reorder_leaves_hidden_keys_in_place() {
973        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
974        let mut model = Model::with_hidden(backend, vec!["title".into()]).expect("model");
975
976        // Move a visible top-level key; the hidden `title` must keep its position.
977        select(&mut model, &[Seg::Key("enabled".into())]);
978        model.move_selected_up(); // enabled moves above version
979        let src = model.source_snapshot();
980        // title stays first (it was declared before version/enabled).
981        let title = src.find("title").unwrap();
982        let version = src.find("version").unwrap();
983        let enabled = src.find("enabled").unwrap();
984        assert!(title < version && title < enabled, "title stayed put:\n{src}");
985        assert!(enabled < version, "enabled moved above version:\n{src}");
986    }
987
988    #[test]
989    fn inserts_a_root_level_key() {
990        let mut model = sample_model();
991        model.insert_key(&[], "root_flag", Value::Bool(true));
992        let src = model.source_snapshot();
993        assert!(src.contains("root_flag"), "root key inserted:\n{src}");
994        assert!(src.contains("title = \"flower\""), "existing kept");
995    }
996
997    #[test]
998    fn renames_a_key_losslessly() {
999        let mut model = sample_model();
1000        select(&mut model, &[Seg::Key("version".into())]);
1001        model.rename_key(&[Seg::Key("version".into())], "revision");
1002        let src = model.source_snapshot();
1003        // fig may quote the new key (`"revision" = 1`); both are valid TOML.
1004        assert!(
1005            src.contains("revision") && src.contains("= 1"),
1006            "renamed with value kept:\n{src}"
1007        );
1008        assert!(!src.contains("version = 1"), "old key gone");
1009        // Selection re-anchored onto the renamed entry.
1010        assert_eq!(model.rows[model.selected].path, [Seg::Key("revision".into())]);
1011    }
1012
1013    #[test]
1014    fn rename_rejects_a_sequence_item() {
1015        let mut model = sample_model();
1016        model.rename_key(
1017            &[
1018                Seg::Key("server".into()),
1019                Seg::Key("tags".into()),
1020                Seg::Index(0),
1021            ],
1022            "nope",
1023        );
1024        assert!(model.status.contains("mapping keys"));
1025    }
1026
1027    #[test]
1028    fn schema_closed_vocabulary_rejects_an_unknown_edit() {
1029        use crate::schema::{Constraint, FieldRule};
1030        use fig_schema::{FieldType, PathPat, Presentation, Term};
1031        let src = "audience = [\"public\"]\ntitle = \"note\"\n";
1032        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1033        let mut model = Model::new(backend).expect("model");
1034        model.set_schema(crate::schema::Schema::new(vec![FieldRule {
1035            at: PathPat::each_item_of("audience"),
1036            ty: Some(FieldType::Str),
1037            constraint: Some(Constraint::Enum {
1038                values: vec![Term::value("public"), Term::value("private")],
1039                closed: true,
1040            }),
1041            present: Presentation::default(),
1042        }]));
1043
1044        // An unknown value is rejected at the commit funnel; the document is
1045        // untouched (fig never sees the edit).
1046        select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
1047        model.begin_edit();
1048        type_value(&mut model, "familly");
1049        assert!(model.status.contains("rejected"), "status: {}", model.status);
1050        assert!(
1051            model.source_snapshot().contains("public"),
1052            "document unchanged:\n{}",
1053            model.source_snapshot()
1054        );
1055
1056        // A known value commits normally.
1057        model.begin_edit();
1058        type_value(&mut model, "private");
1059        let out = model.source_snapshot();
1060        assert!(out.contains("private"), "known value applied:\n{out}");
1061        assert!(!out.contains("public"), "old value replaced:\n{out}");
1062    }
1063
1064    /// A declared field the document omits is otherwise unreachable — it has no
1065    /// row, because rows come from the document. This is what lets a frontend
1066    /// offer it.
1067    #[test]
1068    fn addable_fields_are_the_declared_keys_the_document_lacks() {
1069        use crate::schema::{Constraint, FieldRule};
1070        use fig_schema::{FieldType, PathPat, Presentation, Term};
1071        let src = "audience = [\"public\"]\ntitle = \"note\"\n";
1072        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1073        let mut model =
1074            Model::with_hidden(backend, vec!["title".into(), "updated".into()]).expect("model");
1075        model.set_schema(crate::schema::Schema::new(vec![
1076            // Present in the document — already reachable, so never offered.
1077            FieldRule {
1078                at: PathPat::key("audience"),
1079                ty: Some(FieldType::Str),
1080                constraint: None,
1081                present: Presentation::default(),
1082            },
1083            // An each-item rule governs *within* a field; it names none.
1084            FieldRule {
1085                at: PathPat::each_item_of("audience"),
1086                ty: Some(FieldType::Str),
1087                constraint: Some(Constraint::Enum {
1088                    values: vec![Term::value("public")],
1089                    closed: true,
1090                }),
1091                present: Presentation::default(),
1092            },
1093            // Declared, absent, not managed — the one to offer.
1094            FieldRule {
1095                at: PathPat::key("created"),
1096                ty: Some(FieldType::Str),
1097                constraint: None,
1098                present: Presentation::default(),
1099            },
1100            // Declared and absent, but the embedder manages it.
1101            FieldRule {
1102                at: PathPat::key("updated"),
1103                ty: Some(FieldType::Str),
1104                constraint: None,
1105                present: Presentation::default(),
1106            },
1107        ]));
1108
1109        let offered: Vec<_> = model
1110            .addable_fields()
1111            .iter()
1112            .map(|r| match r.at.0.as_slice() {
1113                [SegPat::Key(k)] => k.clone(),
1114                _ => unreachable!("only single-key rules are offered"),
1115            })
1116            .collect();
1117        assert_eq!(offered, vec!["created".to_string()]);
1118
1119        // Once added it is a real row, so it stops being offered.
1120        model.insert_key(&[], "created", Value::Str("2026-07-24".into()));
1121        assert!(model.addable_fields().is_empty());
1122    }
1123
1124    /// A derived field keeps its row — unlike a hidden one — but declines every
1125    /// mutation, because the workspace rewrites it on the next save regardless.
1126    #[test]
1127    fn a_derived_field_is_visible_but_declines_edits() {
1128        let src = "title = \"note\"\nupdated = \"2026-07-01\"\ncreated = \"2026-06-01\"\n";
1129        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1130        let mut model =
1131            Model::with_managed(backend, vec!["title".into()], vec!["updated".into()])
1132                .expect("model");
1133
1134        // Hidden means no row; derived means a row that is marked.
1135        let labels: Vec<&str> = model.rows.iter().map(|r| r.label.as_str()).collect();
1136        assert_eq!(labels, vec!["updated", "created"]);
1137        assert!(model.is_derived(&[Seg::Key("updated".into())]));
1138        assert!(!model.is_derived(&[Seg::Key("created".into())]));
1139
1140        // Every shape of mutation is declined, and the document is untouched.
1141        model.set_scalar_text(&[Seg::Key("updated".into())], "2026-01-01");
1142        assert!(model.status.contains("maintained by the workspace"));
1143        model.rename_key(&[Seg::Key("updated".into())], "modified");
1144        assert!(model.status.contains("maintained by the workspace"));
1145        model.selected = 0;
1146        model.delete_selected();
1147        assert!(model.status.contains("maintained by the workspace"));
1148        let out = model.source_snapshot();
1149        assert!(out.contains("updated = \"2026-07-01\""), "unchanged:\n{out}");
1150
1151        // A neighbouring ordinary field still edits normally.
1152        model.set_scalar_text(&[Seg::Key("created".into())], "2026-06-15");
1153        assert!(model.source_snapshot().contains("2026-06-15"));
1154    }
1155
1156    /// Without a schema there is nothing to declare, so nothing is offered —
1157    /// a standalone config keeps the free-text add path.
1158    #[test]
1159    fn addable_fields_are_empty_without_a_schema() {
1160        let src = "title = \"note\"\n";
1161        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1162        let model = Model::new(backend).expect("model");
1163        assert!(model.addable_fields().is_empty());
1164    }
1165
1166    #[test]
1167    fn schema_typed_field_keeps_a_numeric_string_as_text() {
1168        use crate::schema::FieldRule;
1169        use fig_schema::{FieldType, PathPat, Presentation};
1170        let src = "code = \"x\"\n";
1171        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1172        let mut model = Model::new(backend).expect("model");
1173        model.set_schema(crate::schema::Schema::new(vec![FieldRule {
1174            at: PathPat::key("code"),
1175            ty: Some(FieldType::Str),
1176            constraint: None,
1177            present: Presentation::default(),
1178        }]));
1179
1180        select(&mut model, &[Seg::Key("code".into())]);
1181        model.begin_edit();
1182        type_value(&mut model, "123");
1183        // Schema says `str`, so the buffer stays a quoted string rather than being
1184        // coerced to an integer the way the shape-guessing heuristic would.
1185        let out = model.source_snapshot();
1186        assert!(out.contains("code = \"123\""), "kept as string:\n{out}");
1187    }
1188
1189    /// The point of a default-collapsed set: the *opening* frame is already
1190    /// folded, without a toggle pass that walks the selection across the document.
1191    #[test]
1192    fn containers_can_arrive_collapsed() {
1193        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
1194        let model = Model::with_collapsed(
1195            backend,
1196            Vec::new(),
1197            Vec::new(),
1198            vec![
1199                vec![Seg::Key("server".into())],
1200                // Naming a scalar is inert, not an error — a caller collapses the
1201                // keys it means to without first sorting containers from scalars.
1202                vec![Seg::Key("title".into())],
1203            ],
1204        )
1205        .expect("model");
1206
1207        let server = model
1208            .rows
1209            .iter()
1210            .find(|r| r.path == [Seg::Key("server".into())])
1211            .expect("server row");
1212        assert!(!server.expanded, "collapsed before the first frame");
1213        assert!(
1214            !model.rows.iter().any(|r| r.path.len() > 1),
1215            "no descendant rows: {:?}",
1216            model.rows.iter().map(|r| &r.label).collect::<Vec<_>>()
1217        );
1218        // The inert scalar path didn't cost `title` its row.
1219        assert!(
1220            model
1221                .rows
1222                .iter()
1223                .any(|r| r.path == [Seg::Key("title".into())])
1224        );
1225        assert_eq!(model.selected, 0, "selection untouched");
1226    }
1227
1228    /// Unlike `activate`, folding by path is not a selection move — that is the
1229    /// whole reason a caller reaches for it.
1230    #[test]
1231    fn set_collapsed_folds_by_path_without_moving_the_selection() {
1232        let mut model = sample_model();
1233        select(&mut model, &[Seg::Key("title".into())]);
1234
1235        model.set_collapsed(&[Seg::Key("server".into())], true);
1236        assert!(model.is_collapsed(&[Seg::Key("server".into())]));
1237        assert!(
1238            !model.rows.iter().any(|r| r.path.len() > 1),
1239            "children hidden"
1240        );
1241        assert_eq!(
1242            model.rows[model.selected].path,
1243            [Seg::Key("title".into())],
1244            "selection stayed on title"
1245        );
1246
1247        model.set_collapsed(&[Seg::Key("server".into())], false);
1248        assert!(!model.is_collapsed(&[Seg::Key("server".into())]));
1249        assert!(
1250            model
1251                .rows
1252                .iter()
1253                .any(|r| r.path == [Seg::Key("server".into()), Seg::Key("host".into())])
1254        );
1255        assert_eq!(model.rows[model.selected].path, [Seg::Key("title".into())]);
1256    }
1257
1258    /// The one case where the selection *must* move: it was inside the fold.
1259    #[test]
1260    fn set_collapsed_reanchors_a_selection_it_swallowed() {
1261        let mut model = sample_model();
1262        select(
1263            &mut model,
1264            &[Seg::Key("server".into()), Seg::Key("host".into())],
1265        );
1266        model.set_collapsed(&[Seg::Key("server".into())], true);
1267        assert_eq!(
1268            model.rows[model.selected].path,
1269            [Seg::Key("server".into())],
1270            "landed on the container that swallowed it"
1271        );
1272    }
1273
1274    /// The insert/append counterparts of the type-directed scalar edit: without
1275    /// them a caller shape-guesses, and `2026` lands in a `str` list as an integer.
1276    #[test]
1277    fn insert_and_append_are_type_directed_by_the_schema() {
1278        use crate::schema::FieldRule;
1279        use fig_schema::{FieldType, PathPat, Presentation};
1280        let src = "tags = [\"alpha\"]\n\n[meta]\nk = \"v\"\n";
1281        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1282        let mut model = Model::new(backend).expect("model");
1283        model.set_schema(crate::schema::Schema::new(vec![
1284            // The *items* of `tags` are strings — the list itself is a seq.
1285            FieldRule {
1286                at: PathPat::each_item_of("tags"),
1287                ty: Some(FieldType::Str),
1288                constraint: None,
1289                present: Presentation::default(),
1290            },
1291            FieldRule {
1292                at: PathPat::key("year"),
1293                ty: Some(FieldType::Str),
1294                constraint: None,
1295                present: Presentation::default(),
1296            },
1297            FieldRule {
1298                at: PathPat(vec![
1299                    fig_schema::SegPat::Key("meta".into()),
1300                    fig_schema::SegPat::Key("code".into()),
1301                ]),
1302                ty: Some(FieldType::Str),
1303                constraint: None,
1304                present: Presentation::default(),
1305            },
1306        ]));
1307
1308        model.append_item_text(&[Seg::Key("tags".into())], "2026");
1309        model.insert_key_text(&[], "year", "2026");
1310        // The nested case flower-ffi and Diaryx both shape-guessed.
1311        model.insert_key_text(&[Seg::Key("meta".into())], "code", "2026");
1312
1313        let out = model.source_snapshot();
1314        assert!(
1315            !out.contains("2026,") && !out.contains("[2026]") && !out.contains("= 2026"),
1316            "no bare integers survived the schema:\n{out}"
1317        );
1318        assert_eq!(
1319            model.value_at(&[Seg::Key("tags".into()), Seg::Index(1)]),
1320            Some(&Value::Str("2026".into())),
1321            "list item took the each-item type:\n{out}"
1322        );
1323        assert_eq!(
1324            model.value_at(&[Seg::Key("year".into())]),
1325            Some(&Value::Str("2026".into()))
1326        );
1327        assert_eq!(
1328            model.value_at(&[Seg::Key("meta".into()), Seg::Key("code".into())]),
1329            Some(&Value::Str("2026".into()))
1330        );
1331    }
1332
1333    /// With no rule to consult they fall back to the same shape-guessing the raw
1334    /// `insert_key`/`append_item` callers do today, so a standalone config is
1335    /// unaffected.
1336    #[test]
1337    fn insert_and_append_text_shape_guess_without_a_schema() {
1338        let mut model = sample_model();
1339        model.append_item_text(&[Seg::Key("server".into()), Seg::Key("tags".into())], "42");
1340        model.insert_key_text(&[], "count", "7");
1341        assert_eq!(
1342            model.value_at(&[
1343                Seg::Key("server".into()),
1344                Seg::Key("tags".into()),
1345                Seg::Index(2)
1346            ]),
1347            Some(&Value::Int(42))
1348        );
1349        assert_eq!(
1350            model.value_at(&[Seg::Key("count".into())]),
1351            Some(&Value::Int(7))
1352        );
1353    }
1354
1355    /// The walkers a backend needs, over a plain `Value` — no `Model` in reach.
1356    #[test]
1357    fn tree_walkers_resolve_paths_and_reject_mismatches() {
1358        let model = sample_model();
1359        let root = model.value_at(&[]).expect("root");
1360
1361        assert_eq!(
1362            tree::value_at(root, &[Seg::Key("server".into()), Seg::Key("port".into())]),
1363            Some(&Value::Int(8080))
1364        );
1365        assert_eq!(
1366            tree::seq_len(root, &[Seg::Key("server".into()), Seg::Key("tags".into())]),
1367            Some(2)
1368        );
1369        // Not a sequence, versus not there at all — both `None`, and neither is a
1370        // length of zero a caller could mistake for an empty list.
1371        assert_eq!(tree::seq_len(root, &[Seg::Key("title".into())]), None);
1372        assert_eq!(tree::seq_len(root, &[Seg::Key("absent".into())]), None);
1373        assert_eq!(
1374            tree::map_keys(root, &[Seg::Key("server".into())]),
1375            Some(vec![
1376                "host".to_string(),
1377                "port".to_string(),
1378                "tags".to_string(),
1379                "limits".to_string()
1380            ])
1381        );
1382        assert_eq!(tree::map_keys(root, &[Seg::Key("title".into())]), None);
1383        // A key step into a sequence resolves to nothing rather than guessing.
1384        assert_eq!(
1385            tree::value_at(
1386                root,
1387                &[
1388                    Seg::Key("server".into()),
1389                    Seg::Key("tags".into()),
1390                    Seg::Key("0".into())
1391                ]
1392            ),
1393            None
1394        );
1395    }
1396
1397    #[test]
1398    fn navigation_folds_and_reanchors() {
1399        let mut model = sample_model();
1400
1401        select(&mut model, &[Seg::Key("server".into())]);
1402        model.collapse_or_leave();
1403        assert!(
1404            !model
1405                .rows
1406                .iter()
1407                .any(|r| r.path == [Seg::Key("server".into()), Seg::Key("host".into())]),
1408            "collapsed children hidden"
1409        );
1410        assert_eq!(model.rows[model.selected].path, [Seg::Key("server".into())]);
1411    }
1412}