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 {
199 seq_path,
200 from,
201 to,
202 } => self
203 .editor
204 .move_item(&tree::to_fig(&seq_path), from, to)
205 .map_err(err),
206 EditOp::ReorderKeys { map_path, keys } => self
207 .editor
208 .reorder_keys(&tree::to_fig(&map_path), &keys)
209 .map_err(err),
210 EditOp::RenameKey { path, new_key } => self
211 .editor
212 .replace_key(&tree::to_fig(&path), &new_key)
213 .map_err(err),
214 }
215 }
216
217 fn to_value(&self) -> Result<Value, BackendError> {
218 let src = self.editor.source().map_err(err)?;
219 let doc = fig::Document::parse(src.as_bytes(), self.format).map_err(err)?;
220 doc.to_value().map_err(err)
221 }
222
223 fn source(&self) -> Result<String, BackendError> {
224 self.editor.source().map(|s| s.to_string()).map_err(err)
225 }
226}
227
228/// A contract suite any [`Backend`] implementation can run against [`EditOp`]'s
229/// documented guarantees.
230///
231/// A trait with one implementation has no contract, only a behavior; the second
232/// implementation is where the two silently part ways. This is the check that
233/// catches that — a prov backend, an embed backend, or anything else built later
234/// runs [`check`] in its own test module and finds out where it drifted.
235///
236/// It asserts only what [`EditOp`] actually promises: the two cases documented as
237/// unspecified there are not probed, so a backend is free to differ on them.
238pub mod conformance {
239 use super::{Backend, EditOp};
240 use crate::tree::{self, Seg};
241 use fig::Value;
242
243 /// The document shape every check starts from. A caller's `open` closure must
244 /// hand back a fresh backend over a document equivalent to:
245 ///
246 /// ```text
247 /// title = "note"
248 /// tags = ["alpha", "beta", "gamma"]
249 /// nested = { k = "v", j = "w" }
250 /// ```
251 ///
252 /// — written in whatever format that backend reads. [`FIXTURE_TOML`] is that
253 /// document for a TOML-parsing backend; [`fixture`] is the tree it must parse
254 /// to, which [`check`] verifies first so a mistyped fixture reports as itself
255 /// rather than as nine failing ops.
256 pub const FIXTURE_TOML: &str = "\
257title = \"note\"
258tags = [\"alpha\", \"beta\", \"gamma\"]
259
260[nested]
261k = \"v\"
262j = \"w\"
263";
264
265 /// The value tree [`FIXTURE_TOML`] (or its equivalent in another format) parses
266 /// to — the starting state each check assumes.
267 pub fn fixture() -> Value {
268 fn s(v: &str) -> Value {
269 Value::Str(v.to_string())
270 }
271 Value::Map(vec![
272 (s("title"), s("note")),
273 (
274 s("tags"),
275 Value::Seq(vec![s("alpha"), s("beta"), s("gamma")]),
276 ),
277 (
278 s("nested"),
279 Value::Map(vec![(s("k"), s("v")), (s("j"), s("w"))]),
280 ),
281 ])
282 }
283
284 /// Everything that didn't hold, one entry per violated guarantee.
285 ///
286 /// `Debug` prints the same as `Display`, so `check(..).unwrap()` in a test
287 /// reports readably instead of as one escaped line.
288 pub struct Report(pub Vec<String>);
289
290 impl std::fmt::Display for Report {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 writeln!(f, "{} backend contract violation(s):", self.0.len())?;
293 for failure in &self.0 {
294 writeln!(f, " - {failure}")?;
295 }
296 Ok(())
297 }
298 }
299
300 impl std::fmt::Debug for Report {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 write!(f, "\n{self}")
303 }
304 }
305
306 fn key(k: &str) -> Seg {
307 Seg::Key(k.to_string())
308 }
309
310 /// Run the suite. `open` must return a **fresh** backend over the [`fixture`]
311 /// document on every call — each check re-opens, so one failure can't cascade
312 /// into the next.
313 ///
314 /// ```no_run
315 /// # use flower_core::backend::{FigBackend, conformance};
316 /// # use fig::Format;
317 /// conformance::check(|| {
318 /// FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).unwrap()
319 /// })
320 /// .unwrap();
321 /// ```
322 pub fn check<B: Backend>(open: impl Fn() -> B) -> Result<(), Report> {
323 let mut failures = Vec::new();
324
325 // Precondition: the caller's fixture really is the document the rest of
326 // the suite reasons about. Bail rather than report the fallout.
327 match open().to_value() {
328 Ok(v) if v == fixture() => {}
329 Ok(v) => {
330 return Err(Report(vec![format!(
331 "`open` does not yield the fixture document.\n expected: {:?}\n got: {v:?}",
332 fixture()
333 )]));
334 }
335 Err(e) => return Err(Report(vec![format!("`open().to_value()` failed: {e}")])),
336 }
337
338 // Apply `op` to a fresh document and hand the resulting tree to `assert`,
339 // which names whatever went wrong.
340 let mut case = |name: &str, op: EditOp, assert: &dyn Fn(&Value) -> Option<String>| {
341 let mut backend = open();
342 match backend.apply(op) {
343 Err(e) => failures.push(format!("{name}: apply failed: {e}")),
344 Ok(()) => match backend.to_value() {
345 Err(e) => failures.push(format!("{name}: to_value failed: {e}")),
346 Ok(tree) => {
347 if let Some(why) = assert(&tree) {
348 failures.push(format!("{name}: {why}"));
349 }
350 }
351 },
352 }
353 };
354
355 // Compare the node at `path` against `want`, and name the mismatch.
356 let at = |tree: &Value, path: &[Seg], want: Value| -> Option<String> {
357 match tree::value_at(tree, path) {
358 Some(got) if *got == want => None,
359 Some(got) => Some(format!("at {path:?}: expected {want:?}, got {got:?}")),
360 None => Some(format!("at {path:?}: path did not resolve")),
361 }
362 };
363 let str_v = |v: &str| Value::Str(v.to_string());
364 let strs = |vs: &[&str]| Value::Seq(vs.iter().map(|v| Value::Str(v.to_string())).collect());
365
366 // ── ReplaceValue: in place, siblings untouched ────────────────────────
367 case(
368 "ReplaceValue on a scalar key",
369 EditOp::ReplaceValue {
370 path: vec![key("title")],
371 value: str_v("REPLACED"),
372 },
373 &|t| {
374 at(t, &[key("title")], str_v("REPLACED"))
375 .or_else(|| at(t, &[key("tags")], strs(&["alpha", "beta", "gamma"])))
376 },
377 );
378 case(
379 "ReplaceValue on a sequence item",
380 EditOp::ReplaceValue {
381 path: vec![key("tags"), Seg::Index(1)],
382 value: str_v("REPLACED"),
383 },
384 &|t| at(t, &[key("tags")], strs(&["alpha", "REPLACED", "gamma"])),
385 );
386 // A subtree is a value like any other: replacing a mapping with a scalar
387 // must not merge into what was there.
388 case(
389 "ReplaceValue on a container",
390 EditOp::ReplaceValue {
391 path: vec![key("nested")],
392 value: str_v("REPLACED"),
393 },
394 &|t| at(t, &[key("nested")], str_v("REPLACED")),
395 );
396
397 // ── DeleteKey / RemoveItem ────────────────────────────────────────────
398 case(
399 "DeleteKey",
400 EditOp::DeleteKey {
401 path: vec![key("nested"), key("k")],
402 },
403 &|t| {
404 at(
405 t,
406 &[key("nested")],
407 Value::Map(vec![(str_v("j"), str_v("w"))]),
408 )
409 .or_else(|| at(t, &[key("title")], str_v("note")))
410 },
411 );
412 case(
413 "RemoveItem shifts later items down",
414 EditOp::RemoveItem {
415 seq_path: vec![key("tags")],
416 index: 0,
417 },
418 &|t| at(t, &[key("tags")], strs(&["beta", "gamma"])),
419 );
420
421 // ── InsertKey / AppendItem: appended, existing entries kept ───────────
422 case(
423 "InsertKey into a nested mapping",
424 EditOp::InsertKey {
425 map_path: vec![key("nested")],
426 key: "added".to_string(),
427 value: str_v("x"),
428 },
429 &|t| {
430 at(
431 t,
432 &[key("nested")],
433 Value::Map(vec![
434 (str_v("k"), str_v("v")),
435 (str_v("j"), str_v("w")),
436 (str_v("added"), str_v("x")),
437 ]),
438 )
439 },
440 );
441 case(
442 "InsertKey at the root (empty path)",
443 EditOp::InsertKey {
444 map_path: Vec::new(),
445 key: "added".to_string(),
446 value: str_v("x"),
447 },
448 &|t| {
449 at(t, &[key("added")], str_v("x")).or_else(|| at(t, &[key("title")], str_v("note")))
450 },
451 );
452 case(
453 "AppendItem lands at the end",
454 EditOp::AppendItem {
455 seq_path: vec![key("tags")],
456 value: str_v("delta"),
457 },
458 &|t| {
459 at(
460 t,
461 &[key("tags")],
462 strs(&["alpha", "beta", "gamma", "delta"]),
463 )
464 },
465 );
466
467 // ── MoveItem: remove-then-reinsert, both directions ───────────────────
468 case(
469 "MoveItem backwards",
470 EditOp::MoveItem {
471 seq_path: vec![key("tags")],
472 from: 2,
473 to: 0,
474 },
475 &|t| at(t, &[key("tags")], strs(&["gamma", "alpha", "beta"])),
476 );
477 case(
478 "MoveItem forwards",
479 EditOp::MoveItem {
480 seq_path: vec![key("tags")],
481 from: 0,
482 to: 2,
483 },
484 &|t| at(t, &[key("tags")], strs(&["beta", "gamma", "alpha"])),
485 );
486
487 // ── ReorderKeys / RenameKey ───────────────────────────────────────────
488 // Deliberately on the *nested* mapping: a root-level reorder is the same op
489 // but some formats constrain what may follow a section header, and that is
490 // the format's business rather than the backend's.
491 case(
492 "ReorderKeys",
493 EditOp::ReorderKeys {
494 map_path: vec![key("nested")],
495 keys: vec!["j".to_string(), "k".to_string()],
496 },
497 &|t| {
498 at(
499 t,
500 &[key("nested")],
501 Value::Map(vec![(str_v("j"), str_v("w")), (str_v("k"), str_v("v"))]),
502 )
503 },
504 );
505 case(
506 "RenameKey keeps the value and the position",
507 EditOp::RenameKey {
508 path: vec![key("nested"), key("k")],
509 new_key: "renamed".to_string(),
510 },
511 &|t| {
512 at(
513 t,
514 &[key("nested")],
515 Value::Map(vec![
516 (str_v("renamed"), str_v("v")),
517 (str_v("j"), str_v("w")),
518 ]),
519 )
520 },
521 );
522
523 // ── Atomicity: a rejected op leaves the document exactly as it was ────
524 // Whether an out-of-range index errors or is declined as a no-op is the
525 // backend's call; that the document survives it is not.
526 for (name, op) in [
527 (
528 "RemoveItem past the end",
529 EditOp::RemoveItem {
530 seq_path: vec![key("tags")],
531 index: 99,
532 },
533 ),
534 (
535 "MoveItem past the end",
536 EditOp::MoveItem {
537 seq_path: vec![key("tags")],
538 from: 0,
539 to: 99,
540 },
541 ),
542 (
543 "DeleteKey on an absent key",
544 EditOp::DeleteKey {
545 path: vec![key("nope")],
546 },
547 ),
548 (
549 "AppendItem onto a non-sequence",
550 EditOp::AppendItem {
551 seq_path: vec![key("title")],
552 value: str_v("x"),
553 },
554 ),
555 ] {
556 let mut backend = open();
557 let _ = backend.apply(op);
558 match backend.to_value() {
559 Ok(tree) if tree == fixture() => {}
560 Ok(tree) => failures.push(format!(
561 "{name}: document changed by a rejected op.\n expected: {:?}\n got: {tree:?}",
562 fixture()
563 )),
564 Err(e) => {
565 failures.push(format!("{name}: document unreadable after a rejected op: {e}"))
566 }
567 }
568 }
569
570 // ── source(): the edit reached the bytes, not just the tree ───────────
571 {
572 let mut backend = open();
573 let op = EditOp::ReplaceValue {
574 path: vec![key("title")],
575 value: str_v("REPLACED"),
576 };
577 match backend.apply(op).and_then(|()| backend.source()) {
578 Ok(src) if src.contains("REPLACED") => {
579 if !src.contains("gamma") {
580 failures.push(
581 "source() after an edit dropped an untouched sibling value".to_string(),
582 );
583 }
584 }
585 Ok(src) => failures.push(format!(
586 "source() does not carry the committed edit:\n{src}"
587 )),
588 Err(e) => failures.push(format!("source() after an edit failed: {e}")),
589 }
590 }
591
592 if failures.is_empty() {
593 Ok(())
594 } else {
595 Err(Report(failures))
596 }
597 }
598
599 /// `move_permutation` is the lowering backends share, so its own arithmetic is
600 /// checked here rather than in each of them.
601 #[cfg(test)]
602 mod permutation_tests {
603 use crate::backend::move_permutation;
604
605 #[test]
606 fn move_permutation_matches_remove_then_reinsert() {
607 assert_eq!(move_permutation(3, 2, 0), Some(vec![2, 0, 1]));
608 assert_eq!(move_permutation(3, 0, 2), Some(vec![1, 2, 0]));
609 assert_eq!(move_permutation(3, 1, 1), Some(vec![0, 1, 2]));
610 assert_eq!(move_permutation(3, 0, 3), None);
611 assert_eq!(move_permutation(0, 0, 0), None);
612 }
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 fn open_fixture() -> FigBackend {
621 FigBackend::open(conformance::FIXTURE_TOML.as_bytes(), Format::Toml).expect("open fixture")
622 }
623
624 /// flower's own backend is the suite's first subject.
625 ///
626 /// It passes every guarantee but one, and that one is an upstream defect
627 /// rather than a contract that was written too strictly: `ReplaceValue` at a
628 /// TOML **table-header** table (`[nested]`) rewrites the *header's key* instead
629 /// of the table's value, so replacing `nested` with `"REPLACED"` silently
630 /// renames the section to `["REPLACED"]` and reports `Ok`. It is specific to
631 /// that one shape — an inline table, an array, and every YAML/JSON container
632 /// replace correctly.
633 ///
634 /// The deviation is pinned here rather than dropped from
635 /// [`conformance::check`], so it cannot grow quietly and so this test turns red
636 /// (and the block goes away) the day fig fixes it.
637 #[test]
638 fn fig_backend_satisfies_the_edit_op_contract_but_for_a_known_fig_defect() {
639 let report = conformance::check(open_fixture)
640 .expect_err("if this now passes, delete the allowance below");
641 assert_eq!(
642 report.0.len(),
643 1,
644 "only the known deviation is allowed:{report}"
645 );
646 assert!(
647 report.0[0].starts_with("ReplaceValue on a container"),
648 "unexpected deviation:{report}"
649 );
650 }
651
652 /// The corruption itself, stated as behavior rather than as a count — so the
653 /// hazard is legible to anyone reaching for `Model::set_value_at` on a TOML
654 /// table, and so a *change* in how fig gets it wrong is caught too.
655 #[test]
656 fn replacing_a_toml_table_header_renames_the_section_upstream() {
657 let mut backend = open_fixture();
658 backend
659 .apply(EditOp::ReplaceValue {
660 path: vec![Seg::Key("nested".into())],
661 value: Value::Str("REPLACED".into()),
662 })
663 .expect("fig reports success");
664 let src = backend.source().expect("source");
665 assert!(src.contains("[\"REPLACED\"]"), "header renamed:\n{src}");
666 assert!(src.contains("k = \"v\""), "table body left behind:\n{src}");
667 }
668}