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}