Skip to main content

flower_core/
backend.rs

1//! The commit sink: what [`Model`](crate::Model) edits *through*.
2//!
3//! `Model` never talks to a concrete editor. It builds path-addressed [`EditOp`]s
4//! and hands them to a [`Backend`], and reads the current tree back via
5//! [`Backend::to_value`]. That indirection is the integration seam:
6//!
7//! - [`FigBackend`] drives a raw [`fig::Editor`] — a standalone config file.
8//! - A future prov backend will drive prov's frontmatter editor, applying the
9//!   same ops but *also* maintaining inverse links, fixity, and the journal —
10//!   so the GUI gets those invariants for free without `Model` knowing about
11//!   them.
12//!
13//! Every op is atomic: on error the document is left exactly as it was (fig
14//! reparses and rolls back; a prov backend declines and stages nothing).
15//!
16//! Because a second implementation is where a one-implementation "contract"
17//! quietly forks, [`EditOp`]'s guarantees are written down per variant below, and
18//! [`conformance`] is a suite any implementation can run against them.
19
20use fig::{Format, Value};
21
22use crate::tree::{self, Seg};
23
24/// One path-addressed edit. The vocabulary grows as `Model` gains operations
25/// (insert, reorder, comments, …); today it covers what the editor issues.
26///
27/// # Contract
28///
29/// These hold for every variant, and [`conformance::check`] tests them:
30///
31/// - **Atomic.** On `Err` the document is byte-for-byte what it was.
32/// - **Addressed by the pre-edit tree.** Every path and index is resolved against
33///   the document as it stands *before* the op — an empty path is the root.
34/// - **Local.** Nothing outside the addressed node changes: sibling values,
35///   sibling order, comments, and formatting all survive.
36/// - **Unresolvable is an error, not a guess.** A path that doesn't resolve, or
37///   that names the wrong kind of node (a key step into a sequence, an index into
38///   a mapping), is an `Err` — with the two documented exceptions below.
39///
40/// Two cases are deliberately **unspecified**, because backends differ on them and
41/// `Model` never relies on either: what [`ReplaceValue`](Self::ReplaceValue) does
42/// at an absent path, and what [`InsertKey`](Self::InsertKey) does at a key that
43/// already exists. A backend over an upserting editor collapses both onto "write
44/// it anyway"; one over a stricter editor errors. A caller that wants a value to
45/// exist regardless must therefore not lean on the coincidence — it should read
46/// the tree first ([`tree::value_at`]) and pick the op that fits.
47#[derive(Debug, Clone)]
48pub enum EditOp {
49    /// Replace the scalar/subtree at `path` with `value`, in place: the node keeps
50    /// its position among its siblings and its key (or index).
51    ///
52    /// `path` must resolve to an existing node. **Unspecified** for an absent path
53    /// — an upserting backend creates it, a strict one errors. Use
54    /// [`InsertKey`](Self::InsertKey) or [`AppendItem`](Self::AppendItem) to
55    /// create.
56    ReplaceValue { path: Vec<Seg>, value: Value },
57    /// Delete the mapping entry at `path`, closing the gap in its parent's key
58    /// order. `path` must end in a [`Seg::Key`] and must resolve; use
59    /// [`RemoveItem`](Self::RemoveItem) for a sequence item.
60    DeleteKey { path: Vec<Seg> },
61    /// Remove item `index` from the sequence at `seq_path`, shifting every later
62    /// item down one. `index` must be `< len`.
63    RemoveItem { seq_path: Vec<Seg>, index: usize },
64    /// Insert `key = value` into the mapping at `map_path` (the root when empty),
65    /// **appended** after its existing entries.
66    ///
67    /// **Unspecified** when `key` is already present — an upserting backend
68    /// overwrites in place, a strict one errors.
69    InsertKey {
70        map_path: Vec<Seg>,
71        key: String,
72        value: Value,
73    },
74    /// Append `value` to the sequence at `seq_path`, at index `len`.
75    AppendItem { seq_path: Vec<Seg>, value: Value },
76    /// Move the item at `from` to `to` in the sequence at `seq_path`: a removal
77    /// followed by a reinsertion, so `to` is read against the sequence *with the
78    /// item already taken out*, and the items between the two shift by one. The
79    /// length is unchanged and both indices must be `< len`.
80    ///
81    /// An editor with no native move lowers this through
82    /// [`move_permutation`] rather than deriving the index arithmetic again.
83    MoveItem {
84        seq_path: Vec<Seg>,
85        from: usize,
86        to: usize,
87    },
88    /// Reorder the mapping at `map_path` so its entries follow `keys`. `keys` is a
89    /// permutation of the mapping's current keys: reordering moves entries, it
90    /// never adds, drops, or renames one.
91    ReorderKeys {
92        map_path: Vec<Seg>,
93        keys: Vec<String>,
94    },
95    /// Rename the mapping entry at `path` to `new_key`, keeping its value and its
96    /// position in the key order. `path` must end in a [`Seg::Key`]; `new_key` must
97    /// not collide with an existing sibling.
98    RenameKey { path: Vec<Seg>, new_key: String },
99    /// Set the own-line comment block **above** the node at `path` (the key's
100    /// line for a mapping entry) to `text`, replacing whatever block was there;
101    /// `None` removes it. `text` may span lines — one comment line per line.
102    ///
103    /// The comment is the node's, not its position's: it moves with the entry
104    /// under a reorder and goes with it under a delete, which is what fig's
105    /// editor already guarantees for a block it owns. A format with no comment
106    /// syntax (strict JSON) refuses. The value is untouched, so a schema has
107    /// nothing to validate here.
108    SetLeadingComment {
109        path: Vec<Seg>,
110        text: Option<String>,
111    },
112    /// Set the same-line comment **after** the value at `path` to `text`,
113    /// replacing an existing one; `None` removes it. `text` must be a single
114    /// line. On a block container (a TOML table, a YAML block mapping) the
115    /// comment rides the key's line, since the value has no line of its own.
116    SetTrailingComment {
117        path: Vec<Seg>,
118        text: Option<String>,
119    },
120}
121
122/// Lower a [`EditOp::MoveItem`] into the index permutation that a
123/// `reorder_items`-style primitive takes, for a backend whose editor has no native
124/// move. `None` when either index is out of range for `len` — nothing to do.
125///
126/// The arithmetic is one line and wrong in two ways if you rederive it (whether
127/// `to` counts the moved item, and which direction the middle shifts), and it is
128/// generic to *any* backend over such an editor — so it lives here rather than in
129/// each one.
130pub fn move_permutation(len: usize, from: usize, to: usize) -> Option<Vec<usize>> {
131    if from >= len || to >= len {
132        return None;
133    }
134    let mut order: Vec<usize> = (0..len).collect();
135    let moved = order.remove(from);
136    order.insert(to, moved);
137    Some(order)
138}
139
140/// A backend failure, carrying the underlying message. An error means the edit
141/// did not apply; the document is unchanged.
142#[derive(Debug)]
143pub struct BackendError(pub String);
144
145impl std::fmt::Display for BackendError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.write_str(&self.0)
148    }
149}
150
151impl std::error::Error for BackendError {}
152
153fn err(e: impl std::fmt::Display) -> BackendError {
154    BackendError(e.to_string())
155}
156
157/// The editing surface `Model` drives. Implementors own the document's bytes and
158/// apply edits losslessly.
159pub trait Backend {
160    /// Apply one edit. Atomic: on `Err`, the document is unchanged.
161    fn apply(&mut self, op: EditOp) -> Result<(), BackendError>;
162
163    /// The current value tree to render (for an embed backend, the metadata
164    /// region — *not* the whole host file).
165    fn to_value(&self) -> Result<Value, BackendError>;
166
167    /// The canonical serialized form the embedder persists on save (for an embed
168    /// backend, the full rendered host file).
169    fn source(&self) -> Result<String, BackendError>;
170
171    /// The schema governing this document, if the backend knows one. The backend
172    /// is exactly the component that knows *where the document came from*, so it is
173    /// the right place to know what governs it — a prov backend returns the schema
174    /// resolved from the workspace config; a standalone config file has none.
175    /// Defaulted to `None` so existing backends are unaffected.
176    fn schema(&self) -> Option<crate::schema::Schema> {
177        None
178    }
179
180    /// The own-line comment block immediately above the node at `path`, lines
181    /// joined by `\n` with markers and indentation stripped. `None` when there is
182    /// no block — and, by default, always: a backend that never reads comments
183    /// renders every page exactly as it did before this method existed, so the
184    /// comment surface is opt-in for an implementor and never a blank column
185    /// for a document that has none.
186    ///
187    /// `Some("")` is a present-but-empty comment (a bare marker), which a
188    /// renderer may treat as absent but an editor must not: it is a line the
189    /// document contains.
190    fn leading_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
191        let _ = path;
192        Ok(None)
193    }
194
195    /// The same-line comment after the value at `path`, marker stripped. `None`
196    /// when there is none, and by default always — see
197    /// [`leading_comment`](Self::leading_comment).
198    fn trailing_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
199        let _ = path;
200        Ok(None)
201    }
202
203    /// The values a picker at `path` should offer, when the backend can
204    /// enumerate them. `None` — the default — means it cannot, and a frontend
205    /// falls back to free text.
206    ///
207    /// **The injection point for a reference field.** A controlled vocabulary
208    /// is in the schema, so [`Model::choices_at`](crate::Model::choices_at)
209    /// answers those itself; a link field's candidates are *other documents*,
210    /// which a single-document, filesystem-free core can never enumerate. The
211    /// backend is the component that knows where the document came from, so it
212    /// is the one that can ask the workspace — a prov backend answers a
213    /// `contents` or `part_of` path with the archive's nodes;
214    /// [`FigBackend`], over a standalone file, has nothing to offer and says so.
215    ///
216    /// Asked about the *item* path for a list — `contents.4`, or the append
217    /// position `contents.<len>` — so one answer serves both replacing an entry
218    /// and adding one. A backend that keys on the relation rather than on the
219    /// index sees the same path either way.
220    fn candidates(&self, path: &[Seg]) -> Result<Option<Vec<crate::schema::Choice>>, BackendError> {
221        let _ = path;
222        Ok(None)
223    }
224
225    /// A stable identity for item `index` of the sequence at `seq_path`, if the
226    /// backend has one. `None` — the default — leaves the model to infer one.
227    ///
228    /// A path addresses a sequence item by *position*, so reordering a list or
229    /// deleting an earlier sibling silently re-points every path after it: a
230    /// page opened on item 2 goes on showing item 2, which is now a different
231    /// item. fig has no per-item identity to fix that with, and inventing one
232    /// in the model would mean inventing it for documents that already have
233    /// one — so this is the seam. A backend over a list of links returns the
234    /// link target; one over a list of records returns the record's id.
235    ///
236    /// Only an identity is wanted here, not a label: two items that return the
237    /// same string are indistinguishable to everything that uses this, and the
238    /// first of them wins. Return `None` for an item you cannot name uniquely
239    /// rather than something approximate.
240    fn item_key(&self, seq_path: &[Seg], index: usize) -> Result<Option<String>, BackendError> {
241        let _ = (seq_path, index);
242        Ok(None)
243    }
244}
245
246/// A [`Backend`] over a standalone config file, backed by [`fig::Editor`].
247pub struct FigBackend {
248    editor: fig::Editor,
249    format: Format,
250}
251
252impl FigBackend {
253    /// Open an editor over a copy of `source` parsed as `format`.
254    pub fn open(source: &[u8], format: Format) -> Result<Self, BackendError> {
255        let editor = fig::Editor::open(source, format).map_err(err)?;
256        Ok(Self { editor, format })
257    }
258}
259
260impl Backend for FigBackend {
261    fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
262        match op {
263            EditOp::ReplaceValue { path, value } => self
264                .editor
265                .replace_value(&tree::to_fig(&path), value)
266                .map_err(err),
267            EditOp::DeleteKey { path } => self.editor.delete(&tree::to_fig(&path)).map_err(err),
268            EditOp::RemoveItem { seq_path, index } => self
269                .editor
270                .remove_item(&tree::to_fig(&seq_path), index)
271                .map_err(err),
272            EditOp::InsertKey {
273                map_path,
274                key,
275                value,
276            } => self
277                .editor
278                .insert_value(&tree::to_fig(&map_path), &key, value)
279                .map_err(err),
280            EditOp::AppendItem { seq_path, value } => self
281                .editor
282                .append_value(&tree::to_fig(&seq_path), value)
283                .map_err(err),
284            EditOp::MoveItem { seq_path, from, to } => self
285                .editor
286                .move_item(&tree::to_fig(&seq_path), from, to)
287                .map_err(err),
288            EditOp::ReorderKeys { map_path, keys } => self
289                .editor
290                .reorder_keys(&tree::to_fig(&map_path), &keys)
291                .map_err(err),
292            EditOp::RenameKey { path, new_key } => self
293                .editor
294                .replace_key(&tree::to_fig(&path), &new_key)
295                .map_err(err),
296            // Two fig calls, so the atomicity the trait promises is checked up
297            // front: the one way the second can fail after the first has spliced
298            // is a format without comment syntax, and that refuses the first too.
299            // Anything else (a path that does not resolve) fails before either
300            // touches the source.
301            EditOp::SetLeadingComment { path, text } => {
302                let path = tree::to_fig(&path);
303                self.editor.delete_leading_comments(&path).map_err(err)?;
304                match text {
305                    Some(text) => self.editor.add_leading_comment(&path, &text).map_err(err),
306                    None => Ok(()),
307                }
308            }
309            EditOp::SetTrailingComment { path, text } => {
310                let path = tree::to_fig(&path);
311                match text {
312                    Some(text) => self.editor.set_trailing_comment(&path, &text).map_err(err),
313                    None => self.editor.delete_trailing_comment(&path).map_err(err),
314                }
315            }
316        }
317    }
318
319    fn to_value(&self) -> Result<Value, BackendError> {
320        let src = self.editor.source().map_err(err)?;
321        let doc = fig::Document::parse(src.as_bytes(), self.format).map_err(err)?;
322        doc.to_value().map_err(err)
323    }
324
325    fn source(&self) -> Result<String, BackendError> {
326        self.editor.source().map(|s| s.to_string()).map_err(err)
327    }
328
329    fn leading_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
330        if !self.has_comments() {
331            return Ok(None);
332        }
333        self.editor
334            .leading_comment(&tree::to_fig(path))
335            .map_err(err)
336    }
337
338    fn trailing_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
339        if !self.has_comments() {
340            return Ok(None);
341        }
342        self.editor
343            .trailing_comment(&tree::to_fig(path))
344            .map_err(err)
345    }
346}
347
348impl FigBackend {
349    /// Whether the format has a comment syntax at all. Strict JSON does not, and
350    /// fig answers a comment *read* on it with an error — which is the right
351    /// answer to a write, and the wrong one to "is there a comment here": a page
352    /// over a JSON file has no comments, rather than a read failure on every
353    /// row.
354    fn has_comments(&self) -> bool {
355        !matches!(self.format, Format::Json)
356    }
357}
358
359/// A contract suite any [`Backend`] implementation can run against [`EditOp`]'s
360/// documented guarantees.
361///
362/// A trait with one implementation has no contract, only a behavior; the second
363/// implementation is where the two silently part ways. This is the check that
364/// catches that — a prov backend, an embed backend, or anything else built later
365/// runs [`check`] in its own test module and finds out where it drifted.
366///
367/// It asserts only what [`EditOp`] actually promises: the two cases documented as
368/// unspecified there are not probed, so a backend is free to differ on them.
369pub mod conformance {
370    use super::{Backend, EditOp};
371    use crate::tree::{self, Seg};
372    use fig::Value;
373
374    /// The document shape every check starts from. A caller's `open` closure must
375    /// hand back a fresh backend over a document equivalent to:
376    ///
377    /// ```text
378    /// title = "note"
379    /// tags  = ["alpha", "beta", "gamma"]
380    /// nested = { k = "v", j = "w" }
381    /// ```
382    ///
383    /// — written in whatever format that backend reads. [`FIXTURE_TOML`] is that
384    /// document for a TOML-parsing backend; [`fixture`] is the tree it must parse
385    /// to, which [`check`] verifies first so a mistyped fixture reports as itself
386    /// rather than as nine failing ops.
387    pub const FIXTURE_TOML: &str = "\
388title = \"note\"
389tags = [\"alpha\", \"beta\", \"gamma\"]
390
391[nested]
392k = \"v\"
393j = \"w\"
394";
395
396    /// The value tree [`FIXTURE_TOML`] (or its equivalent in another format) parses
397    /// to — the starting state each check assumes.
398    pub fn fixture() -> Value {
399        fn s(v: &str) -> Value {
400            Value::Str(v.to_string())
401        }
402        Value::Map(vec![
403            (s("title"), s("note")),
404            (
405                s("tags"),
406                Value::Seq(vec![s("alpha"), s("beta"), s("gamma")]),
407            ),
408            (
409                s("nested"),
410                Value::Map(vec![(s("k"), s("v")), (s("j"), s("w"))]),
411            ),
412        ])
413    }
414
415    /// Everything that didn't hold, one entry per violated guarantee.
416    ///
417    /// `Debug` prints the same as `Display`, so `check(..).unwrap()` in a test
418    /// reports readably instead of as one escaped line.
419    pub struct Report(pub Vec<String>);
420
421    impl std::fmt::Display for Report {
422        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423            writeln!(f, "{} backend contract violation(s):", self.0.len())?;
424            for failure in &self.0 {
425                writeln!(f, "  - {failure}")?;
426            }
427            Ok(())
428        }
429    }
430
431    impl std::fmt::Debug for Report {
432        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433            write!(f, "\n{self}")
434        }
435    }
436
437    fn key(k: &str) -> Seg {
438        Seg::Key(k.to_string())
439    }
440
441    /// Run the suite. `open` must return a **fresh** backend over the [`fixture`]
442    /// document on every call — each check re-opens, so one failure can't cascade
443    /// into the next.
444    ///
445    /// ```no_run
446    /// # use flower_core::backend::{FigBackend, conformance};
447    /// # use fig::Format;
448    /// conformance::check(|| {
449    ///     FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).unwrap()
450    /// })
451    /// .unwrap();
452    /// ```
453    pub fn check<B: Backend>(open: impl Fn() -> B) -> Result<(), Report> {
454        let mut failures = Vec::new();
455
456        // Precondition: the caller's fixture really is the document the rest of
457        // the suite reasons about. Bail rather than report the fallout.
458        match open().to_value() {
459            Ok(v) if v == fixture() => {}
460            Ok(v) => {
461                return Err(Report(vec![format!(
462                    "`open` does not yield the fixture document.\n      expected: {:?}\n      got:      {v:?}",
463                    fixture()
464                )]));
465            }
466            Err(e) => return Err(Report(vec![format!("`open().to_value()` failed: {e}")])),
467        }
468
469        // Apply `op` to a fresh document and hand the resulting tree to `assert`,
470        // which names whatever went wrong.
471        let mut case = |name: &str, op: EditOp, assert: &dyn Fn(&Value) -> Option<String>| {
472            let mut backend = open();
473            match backend.apply(op) {
474                Err(e) => failures.push(format!("{name}: apply failed: {e}")),
475                Ok(()) => match backend.to_value() {
476                    Err(e) => failures.push(format!("{name}: to_value failed: {e}")),
477                    Ok(tree) => {
478                        if let Some(why) = assert(&tree) {
479                            failures.push(format!("{name}: {why}"));
480                        }
481                    }
482                },
483            }
484        };
485
486        // Compare the node at `path` against `want`, and name the mismatch.
487        let at = |tree: &Value, path: &[Seg], want: Value| -> Option<String> {
488            match tree::value_at(tree, path) {
489                Some(got) if *got == want => None,
490                Some(got) => Some(format!("at {path:?}: expected {want:?}, got {got:?}")),
491                None => Some(format!("at {path:?}: path did not resolve")),
492            }
493        };
494        let str_v = |v: &str| Value::Str(v.to_string());
495        let strs = |vs: &[&str]| Value::Seq(vs.iter().map(|v| Value::Str(v.to_string())).collect());
496
497        // ── ReplaceValue: in place, siblings untouched ────────────────────────
498        case(
499            "ReplaceValue on a scalar key",
500            EditOp::ReplaceValue {
501                path: vec![key("title")],
502                value: str_v("REPLACED"),
503            },
504            &|t| {
505                at(t, &[key("title")], str_v("REPLACED"))
506                    .or_else(|| at(t, &[key("tags")], strs(&["alpha", "beta", "gamma"])))
507            },
508        );
509        case(
510            "ReplaceValue on a sequence item",
511            EditOp::ReplaceValue {
512                path: vec![key("tags"), Seg::Index(1)],
513                value: str_v("REPLACED"),
514            },
515            &|t| at(t, &[key("tags")], strs(&["alpha", "REPLACED", "gamma"])),
516        );
517        // A subtree is a value like any other: replacing a mapping with a scalar
518        // must not merge into what was there.
519        case(
520            "ReplaceValue on a container",
521            EditOp::ReplaceValue {
522                path: vec![key("nested")],
523                value: str_v("REPLACED"),
524            },
525            &|t| at(t, &[key("nested")], str_v("REPLACED")),
526        );
527
528        // ── DeleteKey / RemoveItem ────────────────────────────────────────────
529        case(
530            "DeleteKey",
531            EditOp::DeleteKey {
532                path: vec![key("nested"), key("k")],
533            },
534            &|t| {
535                at(
536                    t,
537                    &[key("nested")],
538                    Value::Map(vec![(str_v("j"), str_v("w"))]),
539                )
540                .or_else(|| at(t, &[key("title")], str_v("note")))
541            },
542        );
543        case(
544            "RemoveItem shifts later items down",
545            EditOp::RemoveItem {
546                seq_path: vec![key("tags")],
547                index: 0,
548            },
549            &|t| at(t, &[key("tags")], strs(&["beta", "gamma"])),
550        );
551
552        // ── InsertKey / AppendItem: appended, existing entries kept ───────────
553        case(
554            "InsertKey into a nested mapping",
555            EditOp::InsertKey {
556                map_path: vec![key("nested")],
557                key: "added".to_string(),
558                value: str_v("x"),
559            },
560            &|t| {
561                at(
562                    t,
563                    &[key("nested")],
564                    Value::Map(vec![
565                        (str_v("k"), str_v("v")),
566                        (str_v("j"), str_v("w")),
567                        (str_v("added"), str_v("x")),
568                    ]),
569                )
570            },
571        );
572        case(
573            "InsertKey at the root (empty path)",
574            EditOp::InsertKey {
575                map_path: Vec::new(),
576                key: "added".to_string(),
577                value: str_v("x"),
578            },
579            &|t| {
580                at(t, &[key("added")], str_v("x")).or_else(|| at(t, &[key("title")], str_v("note")))
581            },
582        );
583        case(
584            "AppendItem lands at the end",
585            EditOp::AppendItem {
586                seq_path: vec![key("tags")],
587                value: str_v("delta"),
588            },
589            &|t| {
590                at(
591                    t,
592                    &[key("tags")],
593                    strs(&["alpha", "beta", "gamma", "delta"]),
594                )
595            },
596        );
597
598        // ── MoveItem: remove-then-reinsert, both directions ───────────────────
599        case(
600            "MoveItem backwards",
601            EditOp::MoveItem {
602                seq_path: vec![key("tags")],
603                from: 2,
604                to: 0,
605            },
606            &|t| at(t, &[key("tags")], strs(&["gamma", "alpha", "beta"])),
607        );
608        case(
609            "MoveItem forwards",
610            EditOp::MoveItem {
611                seq_path: vec![key("tags")],
612                from: 0,
613                to: 2,
614            },
615            &|t| at(t, &[key("tags")], strs(&["beta", "gamma", "alpha"])),
616        );
617
618        // ── ReorderKeys / RenameKey ───────────────────────────────────────────
619        // Deliberately on the *nested* mapping: a root-level reorder is the same op
620        // but some formats constrain what may follow a section header, and that is
621        // the format's business rather than the backend's.
622        case(
623            "ReorderKeys",
624            EditOp::ReorderKeys {
625                map_path: vec![key("nested")],
626                keys: vec!["j".to_string(), "k".to_string()],
627            },
628            &|t| {
629                at(
630                    t,
631                    &[key("nested")],
632                    Value::Map(vec![(str_v("j"), str_v("w")), (str_v("k"), str_v("v"))]),
633                )
634            },
635        );
636        case(
637            "RenameKey keeps the value and the position",
638            EditOp::RenameKey {
639                path: vec![key("nested"), key("k")],
640                new_key: "renamed".to_string(),
641            },
642            &|t| {
643                at(
644                    t,
645                    &[key("nested")],
646                    Value::Map(vec![
647                        (str_v("renamed"), str_v("v")),
648                        (str_v("j"), str_v("w")),
649                    ]),
650                )
651            },
652        );
653
654        // ── Atomicity: a rejected op leaves the document exactly as it was ────
655        // Whether an out-of-range index errors or is declined as a no-op is the
656        // backend's call; that the document survives it is not.
657        for (name, op) in [
658            (
659                "RemoveItem past the end",
660                EditOp::RemoveItem {
661                    seq_path: vec![key("tags")],
662                    index: 99,
663                },
664            ),
665            (
666                "MoveItem past the end",
667                EditOp::MoveItem {
668                    seq_path: vec![key("tags")],
669                    from: 0,
670                    to: 99,
671                },
672            ),
673            (
674                "DeleteKey on an absent key",
675                EditOp::DeleteKey {
676                    path: vec![key("nope")],
677                },
678            ),
679            (
680                "AppendItem onto a non-sequence",
681                EditOp::AppendItem {
682                    seq_path: vec![key("title")],
683                    value: str_v("x"),
684                },
685            ),
686        ] {
687            let mut backend = open();
688            let _ = backend.apply(op);
689            match backend.to_value() {
690                Ok(tree) if tree == fixture() => {}
691                Ok(tree) => failures.push(format!(
692                    "{name}: document changed by a rejected op.\n      expected: {:?}\n      got:      {tree:?}",
693                    fixture()
694                )),
695                Err(e) => {
696                    failures.push(format!("{name}: document unreadable after a rejected op: {e}"))
697                }
698            }
699        }
700
701        // ── source(): the edit reached the bytes, not just the tree ───────────
702        {
703            let mut backend = open();
704            let op = EditOp::ReplaceValue {
705                path: vec![key("title")],
706                value: str_v("REPLACED"),
707            };
708            match backend.apply(op).and_then(|()| backend.source()) {
709                Ok(src) if src.contains("REPLACED") => {
710                    if !src.contains("gamma") {
711                        failures.push(
712                            "source() after an edit dropped an untouched sibling value".to_string(),
713                        );
714                    }
715                }
716                Ok(src) => failures.push(format!(
717                    "source() does not carry the committed edit:\n{src}"
718                )),
719                Err(e) => failures.push(format!("source() after an edit failed: {e}")),
720            }
721        }
722
723        if failures.is_empty() {
724            Ok(())
725        } else {
726            Err(Report(failures))
727        }
728    }
729
730    /// `move_permutation` is the lowering backends share, so its own arithmetic is
731    /// checked here rather than in each of them.
732    #[cfg(test)]
733    mod permutation_tests {
734        use crate::backend::move_permutation;
735
736        #[test]
737        fn move_permutation_matches_remove_then_reinsert() {
738            assert_eq!(move_permutation(3, 2, 0), Some(vec![2, 0, 1]));
739            assert_eq!(move_permutation(3, 0, 2), Some(vec![1, 2, 0]));
740            assert_eq!(move_permutation(3, 1, 1), Some(vec![0, 1, 2]));
741            assert_eq!(move_permutation(3, 0, 3), None);
742            assert_eq!(move_permutation(0, 0, 0), None);
743        }
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn open_fixture() -> FigBackend {
752        FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).expect("open fixture")
753    }
754
755    /// flower's own backend is the suite's first subject.
756    ///
757    /// It passes every guarantee but one, and that one is an upstream limit
758    /// rather than a contract that was written too strictly: `ReplaceValue` at a
759    /// TOML **table-header** table (`[nested]`) is refused. It is specific to that
760    /// one shape — an inline table, an array, and every YAML/JSON container
761    /// replace correctly.
762    ///
763    /// Until fig 3.2 this was a far worse deviation: the op reported `Ok` and
764    /// rewrote the *header's key*, silently renaming the section to
765    /// `["REPLACED"]` and stranding the table's body under it. fig 3.2 refuses
766    /// instead and leaves the document alone, which is why the count below did
767    /// not change while its meaning did — an unsupported op is a deviation from
768    /// the contract, but it is no longer a corruption.
769    ///
770    /// The deviation stays pinned here rather than dropped from
771    /// [`conformance::check`], so it cannot grow quietly and so this test turns
772    /// red (and the block goes away) the day fig supports the shape.
773    #[test]
774    fn fig_backend_satisfies_the_edit_op_contract_but_for_a_known_fig_defect() {
775        let report = conformance::check(open_fixture)
776            .expect_err("if this now passes, delete the allowance below");
777        assert_eq!(
778            report.0.len(),
779            1,
780            "only the known deviation is allowed:{report}"
781        );
782        assert!(
783            report.0[0].starts_with("ReplaceValue on a container"),
784            "unexpected deviation:{report}"
785        );
786    }
787
788    /// The refusal itself, stated as behavior rather than as a count — so what
789    /// happens to anyone reaching for `Model::set_value_at` on a TOML table is
790    /// legible, and so a *change* in how fig handles the shape is caught too.
791    ///
792    /// The second assertion is the one that matters. Before fig 3.2 this op
793    /// reported success and quietly rewrote the section header; the document
794    /// being byte-identical after a refusal is the whole of the improvement, and
795    /// an error return that had still mutated the file would be worse than the
796    /// original defect rather than better.
797    #[test]
798    fn replacing_a_toml_table_header_is_refused_and_changes_nothing() {
799        let mut backend = open_fixture();
800        let before = backend.source().expect("source");
801        let result = backend.apply(EditOp::ReplaceValue {
802            path: vec![Seg::Key("nested".into())],
803            value: Value::Str("REPLACED".into()),
804        });
805        assert!(result.is_err(), "fig refuses the shape it cannot rewrite");
806        assert_eq!(
807            backend.source().expect("source"),
808            before,
809            "a refused op leaves the document byte-identical"
810        );
811    }
812
813    const COMMENTED: &str = "\
814# the document
815title = \"note\" # what it is called
816# above the tags
817# two lines of it
818tags = [\"alpha\"]
819
820# the nested table
821[nested]
822k = \"v\"
823";
824
825    fn open_commented() -> FigBackend {
826        FigBackend::open(COMMENTED.as_bytes(), Format::Toml).expect("open")
827    }
828
829    fn set_leading(b: &mut FigBackend, path: &[Seg], text: Option<&str>) {
830        b.apply(EditOp::SetLeadingComment {
831            path: path.to_vec(),
832            text: text.map(str::to_string),
833        })
834        .expect("set leading comment");
835    }
836
837    fn set_trailing(b: &mut FigBackend, path: &[Seg], text: Option<&str>) {
838        b.apply(EditOp::SetTrailingComment {
839            path: path.to_vec(),
840            text: text.map(str::to_string),
841        })
842        .expect("set trailing comment");
843    }
844
845    #[test]
846    fn comments_are_read_per_node_with_markers_stripped() {
847        let b = open_commented();
848        let at = |k: &str| vec![Seg::Key(k.into())];
849        assert_eq!(
850            b.leading_comment(&at("title")).unwrap().as_deref(),
851            Some("the document")
852        );
853        assert_eq!(
854            b.trailing_comment(&at("title")).unwrap().as_deref(),
855            Some("what it is called")
856        );
857        assert_eq!(
858            b.leading_comment(&at("tags")).unwrap().as_deref(),
859            Some("above the tags\ntwo lines of it"),
860            "a block reads as its lines joined"
861        );
862        assert_eq!(b.trailing_comment(&at("tags")).unwrap(), None);
863        assert_eq!(
864            b.leading_comment(&at("nested")).unwrap().as_deref(),
865            Some("the nested table"),
866            "a table header carries its block like any node"
867        );
868        assert_eq!(b.leading_comment(&at("k")).ok().flatten(), None);
869    }
870
871    #[test]
872    fn set_leading_comment_replaces_the_whole_block_and_none_removes_it() {
873        let mut b = open_commented();
874        let tags = vec![Seg::Key("tags".into())];
875        set_leading(&mut b, &tags, Some("one line now"));
876        let src = b.source().unwrap();
877        assert!(src.contains("# one line now\ntags = "), "{src}");
878        assert!(!src.contains("above the tags"), "old block gone:\n{src}");
879        assert!(
880            src.contains("# the document\ntitle"),
881            "other blocks untouched"
882        );
883
884        set_leading(&mut b, &tags, None);
885        let src = b.source().unwrap();
886        assert!(
887            src.contains("\"note\" # what it is called\ntags = "),
888            "{src}"
889        );
890        assert_eq!(b.leading_comment(&tags).unwrap(), None);
891    }
892
893    #[test]
894    fn set_trailing_comment_replaces_and_none_removes() {
895        let mut b = open_commented();
896        let title = vec![Seg::Key("title".into())];
897        set_trailing(&mut b, &title, Some("renamed"));
898        assert!(b.source().unwrap().contains("title = \"note\" # renamed\n"));
899        set_trailing(&mut b, &title, None);
900        assert!(b.source().unwrap().contains("title = \"note\"\n"));
901        assert_eq!(b.trailing_comment(&title).unwrap(), None);
902        // A node that had none gains one.
903        let k = vec![Seg::Key("nested".into()), Seg::Key("k".into())];
904        set_trailing(&mut b, &k, Some("added"));
905        assert!(b.source().unwrap().contains("k = \"v\" # added\n"));
906    }
907
908    #[test]
909    fn a_comment_edit_leaves_the_value_tree_alone() {
910        let mut b = open_commented();
911        let before = b.to_value().unwrap();
912        set_leading(&mut b, &[Seg::Key("title".into())], Some("changed"));
913        set_trailing(&mut b, &[Seg::Key("tags".into())], Some("changed"));
914        assert_eq!(b.to_value().unwrap(), before);
915    }
916
917    #[test]
918    fn strict_json_has_no_comments_to_read_and_refuses_to_write_one() {
919        let mut b = FigBackend::open(br#"{"a": 1}"#, Format::Json).expect("open");
920        let a = vec![Seg::Key("a".into())];
921        // A read is an answer, not an error: the page over a JSON file simply
922        // has no comments on it.
923        assert_eq!(b.leading_comment(&a).unwrap(), None);
924        assert_eq!(b.trailing_comment(&a).unwrap(), None);
925        let before = b.source().unwrap();
926        assert!(
927            b.apply(EditOp::SetLeadingComment {
928                path: a.clone(),
929                text: Some("nope".into()),
930            })
931            .is_err()
932        );
933        assert!(
934            b.apply(EditOp::SetTrailingComment {
935                path: a,
936                text: Some("nope".into()),
937            })
938            .is_err()
939        );
940        assert_eq!(b.source().unwrap(), before, "a refusal changes nothing");
941    }
942
943    #[test]
944    fn a_multiline_trailing_comment_is_refused_whole() {
945        let mut b = open_commented();
946        let before = b.source().unwrap();
947        let result = b.apply(EditOp::SetTrailingComment {
948            path: vec![Seg::Key("title".into())],
949            text: Some("two\nlines".into()),
950        });
951        assert!(result.is_err());
952        assert_eq!(b.source().unwrap(), before);
953    }
954}