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}