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}
100
101/// Lower a [`EditOp::MoveItem`] into the index permutation that a
102/// `reorder_items`-style primitive takes, for a backend whose editor has no native
103/// move. `None` when either index is out of range for `len` — nothing to do.
104///
105/// The arithmetic is one line and wrong in two ways if you rederive it (whether
106/// `to` counts the moved item, and which direction the middle shifts), and it is
107/// generic to *any* backend over such an editor — so it lives here rather than in
108/// each one.
109pub fn move_permutation(len: usize, from: usize, to: usize) -> Option<Vec<usize>> {
110    if from >= len || to >= len {
111        return None;
112    }
113    let mut order: Vec<usize> = (0..len).collect();
114    let moved = order.remove(from);
115    order.insert(to, moved);
116    Some(order)
117}
118
119/// A backend failure, carrying the underlying message. An error means the edit
120/// did not apply; the document is unchanged.
121#[derive(Debug)]
122pub struct BackendError(pub String);
123
124impl std::fmt::Display for BackendError {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130impl std::error::Error for BackendError {}
131
132fn err(e: impl std::fmt::Display) -> BackendError {
133    BackendError(e.to_string())
134}
135
136/// The editing surface `Model` drives. Implementors own the document's bytes and
137/// apply edits losslessly.
138pub trait Backend {
139    /// Apply one edit. Atomic: on `Err`, the document is unchanged.
140    fn apply(&mut self, op: EditOp) -> Result<(), BackendError>;
141
142    /// The current value tree to render (for an embed backend, the metadata
143    /// region — *not* the whole host file).
144    fn to_value(&self) -> Result<Value, BackendError>;
145
146    /// The canonical serialized form the embedder persists on save (for an embed
147    /// backend, the full rendered host file).
148    fn source(&self) -> Result<String, BackendError>;
149
150    /// The schema governing this document, if the backend knows one. The backend
151    /// is exactly the component that knows *where the document came from*, so it is
152    /// the right place to know what governs it — a prov backend returns the schema
153    /// resolved from the workspace config; a standalone config file has none.
154    /// Defaulted to `None` so existing backends are unaffected.
155    fn schema(&self) -> Option<crate::schema::Schema> {
156        None
157    }
158}
159
160/// A [`Backend`] over a standalone config file, backed by [`fig::Editor`].
161pub struct FigBackend {
162    editor: fig::Editor,
163    format: Format,
164}
165
166impl FigBackend {
167    /// Open an editor over a copy of `source` parsed as `format`.
168    pub fn open(source: &[u8], format: Format) -> Result<Self, BackendError> {
169        let editor = fig::Editor::open(source, format).map_err(err)?;
170        Ok(Self { editor, format })
171    }
172}
173
174impl Backend for FigBackend {
175    fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
176        match op {
177            EditOp::ReplaceValue { path, value } => self
178                .editor
179                .replace_value(&tree::to_fig(&path), value)
180                .map_err(err),
181            EditOp::DeleteKey { path } => self.editor.delete(&tree::to_fig(&path)).map_err(err),
182            EditOp::RemoveItem { seq_path, index } => self
183                .editor
184                .remove_item(&tree::to_fig(&seq_path), index)
185                .map_err(err),
186            EditOp::InsertKey {
187                map_path,
188                key,
189                value,
190            } => self
191                .editor
192                .insert_value(&tree::to_fig(&map_path), &key, value)
193                .map_err(err),
194            EditOp::AppendItem { seq_path, value } => self
195                .editor
196                .append_value(&tree::to_fig(&seq_path), value)
197                .map_err(err),
198            EditOp::MoveItem { seq_path, from, to } => self
199                .editor
200                .move_item(&tree::to_fig(&seq_path), from, to)
201                .map_err(err),
202            EditOp::ReorderKeys { map_path, keys } => self
203                .editor
204                .reorder_keys(&tree::to_fig(&map_path), &keys)
205                .map_err(err),
206            EditOp::RenameKey { path, new_key } => self
207                .editor
208                .replace_key(&tree::to_fig(&path), &new_key)
209                .map_err(err),
210        }
211    }
212
213    fn to_value(&self) -> Result<Value, BackendError> {
214        let src = self.editor.source().map_err(err)?;
215        let doc = fig::Document::parse(src.as_bytes(), self.format).map_err(err)?;
216        doc.to_value().map_err(err)
217    }
218
219    fn source(&self) -> Result<String, BackendError> {
220        self.editor.source().map(|s| s.to_string()).map_err(err)
221    }
222}
223
224/// A contract suite any [`Backend`] implementation can run against [`EditOp`]'s
225/// documented guarantees.
226///
227/// A trait with one implementation has no contract, only a behavior; the second
228/// implementation is where the two silently part ways. This is the check that
229/// catches that — a prov backend, an embed backend, or anything else built later
230/// runs [`check`] in its own test module and finds out where it drifted.
231///
232/// It asserts only what [`EditOp`] actually promises: the two cases documented as
233/// unspecified there are not probed, so a backend is free to differ on them.
234pub mod conformance {
235    use super::{Backend, EditOp};
236    use crate::tree::{self, Seg};
237    use fig::Value;
238
239    /// The document shape every check starts from. A caller's `open` closure must
240    /// hand back a fresh backend over a document equivalent to:
241    ///
242    /// ```text
243    /// title = "note"
244    /// tags  = ["alpha", "beta", "gamma"]
245    /// nested = { k = "v", j = "w" }
246    /// ```
247    ///
248    /// — written in whatever format that backend reads. [`FIXTURE_TOML`] is that
249    /// document for a TOML-parsing backend; [`fixture`] is the tree it must parse
250    /// to, which [`check`] verifies first so a mistyped fixture reports as itself
251    /// rather than as nine failing ops.
252    pub const FIXTURE_TOML: &str = "\
253title = \"note\"
254tags = [\"alpha\", \"beta\", \"gamma\"]
255
256[nested]
257k = \"v\"
258j = \"w\"
259";
260
261    /// The value tree [`FIXTURE_TOML`] (or its equivalent in another format) parses
262    /// to — the starting state each check assumes.
263    pub fn fixture() -> Value {
264        fn s(v: &str) -> Value {
265            Value::Str(v.to_string())
266        }
267        Value::Map(vec![
268            (s("title"), s("note")),
269            (
270                s("tags"),
271                Value::Seq(vec![s("alpha"), s("beta"), s("gamma")]),
272            ),
273            (
274                s("nested"),
275                Value::Map(vec![(s("k"), s("v")), (s("j"), s("w"))]),
276            ),
277        ])
278    }
279
280    /// Everything that didn't hold, one entry per violated guarantee.
281    ///
282    /// `Debug` prints the same as `Display`, so `check(..).unwrap()` in a test
283    /// reports readably instead of as one escaped line.
284    pub struct Report(pub Vec<String>);
285
286    impl std::fmt::Display for Report {
287        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288            writeln!(f, "{} backend contract violation(s):", self.0.len())?;
289            for failure in &self.0 {
290                writeln!(f, "  - {failure}")?;
291            }
292            Ok(())
293        }
294    }
295
296    impl std::fmt::Debug for Report {
297        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298            write!(f, "\n{self}")
299        }
300    }
301
302    fn key(k: &str) -> Seg {
303        Seg::Key(k.to_string())
304    }
305
306    /// Run the suite. `open` must return a **fresh** backend over the [`fixture`]
307    /// document on every call — each check re-opens, so one failure can't cascade
308    /// into the next.
309    ///
310    /// ```no_run
311    /// # use flower_core::backend::{FigBackend, conformance};
312    /// # use fig::Format;
313    /// conformance::check(|| {
314    ///     FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).unwrap()
315    /// })
316    /// .unwrap();
317    /// ```
318    pub fn check<B: Backend>(open: impl Fn() -> B) -> Result<(), Report> {
319        let mut failures = Vec::new();
320
321        // Precondition: the caller's fixture really is the document the rest of
322        // the suite reasons about. Bail rather than report the fallout.
323        match open().to_value() {
324            Ok(v) if v == fixture() => {}
325            Ok(v) => {
326                return Err(Report(vec![format!(
327                    "`open` does not yield the fixture document.\n      expected: {:?}\n      got:      {v:?}",
328                    fixture()
329                )]));
330            }
331            Err(e) => return Err(Report(vec![format!("`open().to_value()` failed: {e}")])),
332        }
333
334        // Apply `op` to a fresh document and hand the resulting tree to `assert`,
335        // which names whatever went wrong.
336        let mut case = |name: &str, op: EditOp, assert: &dyn Fn(&Value) -> Option<String>| {
337            let mut backend = open();
338            match backend.apply(op) {
339                Err(e) => failures.push(format!("{name}: apply failed: {e}")),
340                Ok(()) => match backend.to_value() {
341                    Err(e) => failures.push(format!("{name}: to_value failed: {e}")),
342                    Ok(tree) => {
343                        if let Some(why) = assert(&tree) {
344                            failures.push(format!("{name}: {why}"));
345                        }
346                    }
347                },
348            }
349        };
350
351        // Compare the node at `path` against `want`, and name the mismatch.
352        let at = |tree: &Value, path: &[Seg], want: Value| -> Option<String> {
353            match tree::value_at(tree, path) {
354                Some(got) if *got == want => None,
355                Some(got) => Some(format!("at {path:?}: expected {want:?}, got {got:?}")),
356                None => Some(format!("at {path:?}: path did not resolve")),
357            }
358        };
359        let str_v = |v: &str| Value::Str(v.to_string());
360        let strs = |vs: &[&str]| Value::Seq(vs.iter().map(|v| Value::Str(v.to_string())).collect());
361
362        // ── ReplaceValue: in place, siblings untouched ────────────────────────
363        case(
364            "ReplaceValue on a scalar key",
365            EditOp::ReplaceValue {
366                path: vec![key("title")],
367                value: str_v("REPLACED"),
368            },
369            &|t| {
370                at(t, &[key("title")], str_v("REPLACED"))
371                    .or_else(|| at(t, &[key("tags")], strs(&["alpha", "beta", "gamma"])))
372            },
373        );
374        case(
375            "ReplaceValue on a sequence item",
376            EditOp::ReplaceValue {
377                path: vec![key("tags"), Seg::Index(1)],
378                value: str_v("REPLACED"),
379            },
380            &|t| at(t, &[key("tags")], strs(&["alpha", "REPLACED", "gamma"])),
381        );
382        // A subtree is a value like any other: replacing a mapping with a scalar
383        // must not merge into what was there.
384        case(
385            "ReplaceValue on a container",
386            EditOp::ReplaceValue {
387                path: vec![key("nested")],
388                value: str_v("REPLACED"),
389            },
390            &|t| at(t, &[key("nested")], str_v("REPLACED")),
391        );
392
393        // ── DeleteKey / RemoveItem ────────────────────────────────────────────
394        case(
395            "DeleteKey",
396            EditOp::DeleteKey {
397                path: vec![key("nested"), key("k")],
398            },
399            &|t| {
400                at(
401                    t,
402                    &[key("nested")],
403                    Value::Map(vec![(str_v("j"), str_v("w"))]),
404                )
405                .or_else(|| at(t, &[key("title")], str_v("note")))
406            },
407        );
408        case(
409            "RemoveItem shifts later items down",
410            EditOp::RemoveItem {
411                seq_path: vec![key("tags")],
412                index: 0,
413            },
414            &|t| at(t, &[key("tags")], strs(&["beta", "gamma"])),
415        );
416
417        // ── InsertKey / AppendItem: appended, existing entries kept ───────────
418        case(
419            "InsertKey into a nested mapping",
420            EditOp::InsertKey {
421                map_path: vec![key("nested")],
422                key: "added".to_string(),
423                value: str_v("x"),
424            },
425            &|t| {
426                at(
427                    t,
428                    &[key("nested")],
429                    Value::Map(vec![
430                        (str_v("k"), str_v("v")),
431                        (str_v("j"), str_v("w")),
432                        (str_v("added"), str_v("x")),
433                    ]),
434                )
435            },
436        );
437        case(
438            "InsertKey at the root (empty path)",
439            EditOp::InsertKey {
440                map_path: Vec::new(),
441                key: "added".to_string(),
442                value: str_v("x"),
443            },
444            &|t| {
445                at(t, &[key("added")], str_v("x")).or_else(|| at(t, &[key("title")], str_v("note")))
446            },
447        );
448        case(
449            "AppendItem lands at the end",
450            EditOp::AppendItem {
451                seq_path: vec![key("tags")],
452                value: str_v("delta"),
453            },
454            &|t| {
455                at(
456                    t,
457                    &[key("tags")],
458                    strs(&["alpha", "beta", "gamma", "delta"]),
459                )
460            },
461        );
462
463        // ── MoveItem: remove-then-reinsert, both directions ───────────────────
464        case(
465            "MoveItem backwards",
466            EditOp::MoveItem {
467                seq_path: vec![key("tags")],
468                from: 2,
469                to: 0,
470            },
471            &|t| at(t, &[key("tags")], strs(&["gamma", "alpha", "beta"])),
472        );
473        case(
474            "MoveItem forwards",
475            EditOp::MoveItem {
476                seq_path: vec![key("tags")],
477                from: 0,
478                to: 2,
479            },
480            &|t| at(t, &[key("tags")], strs(&["beta", "gamma", "alpha"])),
481        );
482
483        // ── ReorderKeys / RenameKey ───────────────────────────────────────────
484        // Deliberately on the *nested* mapping: a root-level reorder is the same op
485        // but some formats constrain what may follow a section header, and that is
486        // the format's business rather than the backend's.
487        case(
488            "ReorderKeys",
489            EditOp::ReorderKeys {
490                map_path: vec![key("nested")],
491                keys: vec!["j".to_string(), "k".to_string()],
492            },
493            &|t| {
494                at(
495                    t,
496                    &[key("nested")],
497                    Value::Map(vec![(str_v("j"), str_v("w")), (str_v("k"), str_v("v"))]),
498                )
499            },
500        );
501        case(
502            "RenameKey keeps the value and the position",
503            EditOp::RenameKey {
504                path: vec![key("nested"), key("k")],
505                new_key: "renamed".to_string(),
506            },
507            &|t| {
508                at(
509                    t,
510                    &[key("nested")],
511                    Value::Map(vec![
512                        (str_v("renamed"), str_v("v")),
513                        (str_v("j"), str_v("w")),
514                    ]),
515                )
516            },
517        );
518
519        // ── Atomicity: a rejected op leaves the document exactly as it was ────
520        // Whether an out-of-range index errors or is declined as a no-op is the
521        // backend's call; that the document survives it is not.
522        for (name, op) in [
523            (
524                "RemoveItem past the end",
525                EditOp::RemoveItem {
526                    seq_path: vec![key("tags")],
527                    index: 99,
528                },
529            ),
530            (
531                "MoveItem past the end",
532                EditOp::MoveItem {
533                    seq_path: vec![key("tags")],
534                    from: 0,
535                    to: 99,
536                },
537            ),
538            (
539                "DeleteKey on an absent key",
540                EditOp::DeleteKey {
541                    path: vec![key("nope")],
542                },
543            ),
544            (
545                "AppendItem onto a non-sequence",
546                EditOp::AppendItem {
547                    seq_path: vec![key("title")],
548                    value: str_v("x"),
549                },
550            ),
551        ] {
552            let mut backend = open();
553            let _ = backend.apply(op);
554            match backend.to_value() {
555                Ok(tree) if tree == fixture() => {}
556                Ok(tree) => failures.push(format!(
557                    "{name}: document changed by a rejected op.\n      expected: {:?}\n      got:      {tree:?}",
558                    fixture()
559                )),
560                Err(e) => {
561                    failures.push(format!("{name}: document unreadable after a rejected op: {e}"))
562                }
563            }
564        }
565
566        // ── source(): the edit reached the bytes, not just the tree ───────────
567        {
568            let mut backend = open();
569            let op = EditOp::ReplaceValue {
570                path: vec![key("title")],
571                value: str_v("REPLACED"),
572            };
573            match backend.apply(op).and_then(|()| backend.source()) {
574                Ok(src) if src.contains("REPLACED") => {
575                    if !src.contains("gamma") {
576                        failures.push(
577                            "source() after an edit dropped an untouched sibling value".to_string(),
578                        );
579                    }
580                }
581                Ok(src) => failures.push(format!(
582                    "source() does not carry the committed edit:\n{src}"
583                )),
584                Err(e) => failures.push(format!("source() after an edit failed: {e}")),
585            }
586        }
587
588        if failures.is_empty() {
589            Ok(())
590        } else {
591            Err(Report(failures))
592        }
593    }
594
595    /// `move_permutation` is the lowering backends share, so its own arithmetic is
596    /// checked here rather than in each of them.
597    #[cfg(test)]
598    mod permutation_tests {
599        use crate::backend::move_permutation;
600
601        #[test]
602        fn move_permutation_matches_remove_then_reinsert() {
603            assert_eq!(move_permutation(3, 2, 0), Some(vec![2, 0, 1]));
604            assert_eq!(move_permutation(3, 0, 2), Some(vec![1, 2, 0]));
605            assert_eq!(move_permutation(3, 1, 1), Some(vec![0, 1, 2]));
606            assert_eq!(move_permutation(3, 0, 3), None);
607            assert_eq!(move_permutation(0, 0, 0), None);
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    fn open_fixture() -> FigBackend {
617        FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).expect("open fixture")
618    }
619
620    /// flower's own backend is the suite's first subject.
621    ///
622    /// It passes every guarantee but one, and that one is an upstream limit
623    /// rather than a contract that was written too strictly: `ReplaceValue` at a
624    /// TOML **table-header** table (`[nested]`) is refused. It is specific to that
625    /// one shape — an inline table, an array, and every YAML/JSON container
626    /// replace correctly.
627    ///
628    /// Until fig 3.2 this was a far worse deviation: the op reported `Ok` and
629    /// rewrote the *header's key*, silently renaming the section to
630    /// `["REPLACED"]` and stranding the table's body under it. fig 3.2 refuses
631    /// instead and leaves the document alone, which is why the count below did
632    /// not change while its meaning did — an unsupported op is a deviation from
633    /// the contract, but it is no longer a corruption.
634    ///
635    /// The deviation stays pinned here rather than dropped from
636    /// [`conformance::check`], so it cannot grow quietly and so this test turns
637    /// red (and the block goes away) the day fig supports the shape.
638    #[test]
639    fn fig_backend_satisfies_the_edit_op_contract_but_for_a_known_fig_defect() {
640        let report = conformance::check(open_fixture)
641            .expect_err("if this now passes, delete the allowance below");
642        assert_eq!(
643            report.0.len(),
644            1,
645            "only the known deviation is allowed:{report}"
646        );
647        assert!(
648            report.0[0].starts_with("ReplaceValue on a container"),
649            "unexpected deviation:{report}"
650        );
651    }
652
653    /// The refusal itself, stated as behavior rather than as a count — so what
654    /// happens to anyone reaching for `Model::set_value_at` on a TOML table is
655    /// legible, and so a *change* in how fig handles the shape is caught too.
656    ///
657    /// The second assertion is the one that matters. Before fig 3.2 this op
658    /// reported success and quietly rewrote the section header; the document
659    /// being byte-identical after a refusal is the whole of the improvement, and
660    /// an error return that had still mutated the file would be worse than the
661    /// original defect rather than better.
662    #[test]
663    fn replacing_a_toml_table_header_is_refused_and_changes_nothing() {
664        let mut backend = open_fixture();
665        let before = backend.source().expect("source");
666        let result = backend.apply(EditOp::ReplaceValue {
667            path: vec![Seg::Key("nested".into())],
668            value: Value::Str("REPLACED".into()),
669        });
670        assert!(result.is_err(), "fig refuses the shape it cannot rewrite");
671        assert_eq!(
672            backend.source().expect("source"),
673            before,
674            "a refused op leaves the document byte-identical"
675        );
676    }
677}