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 {
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}