Skip to main content

omgbase_mutate/
changeset.rs

1//! Changesets (`spec/mutate/README.md` §4): the op shapes, their JSON wire
2//! form, and placeholder resolution (`$<n>.ids[<i>]` naming an earlier op's
3//! result). Applying a changeset needs a store (loading, commit) and lives in
4//! `omgbase-store`; the pure pieces are here.
5
6use std::collections::BTreeMap;
7
8use serde_json::{Map, Value, json};
9
10use crate::error::{ErrorCode, MutationError, Result};
11use crate::ops::{At, Expect, OpResult, Parent, To};
12
13/// One kernel op as a changeset carries it (§4 `Op`). String fields may be
14/// placeholders until [`resolve_op`] runs.
15#[derive(Clone, Debug, PartialEq)]
16pub enum Op {
17    Insert {
18        /// A doc id or path (§4 step 1); `None` infers from the placement.
19        doc: Option<String>,
20        to: To,
21        markdown: String,
22        /// §1.2: the DESTINATION parent's `parent_children_hash` (since 1.1).
23        expect: Option<Expect>,
24    },
25    Update {
26        block: String,
27        markdown: Option<String>,
28        attrs: Option<Map<String, Value>>,
29        expect: Option<Expect>,
30        trivia: Option<String>,
31        child_ids: Option<BTreeMap<String, String>>,
32    },
33    Move {
34        blocks: Vec<String>,
35        to: To,
36        /// §2.3 (3.0): the destination document of a top-level `start`/`end`
37        /// placement — a cross-document move to a document's top level, which
38        /// `to` alone would read as the SOURCE document's; `None` keeps that
39        /// reading. A `$new.<n>` names a document the request creates.
40        doc: Option<String>,
41        /// §1.2: the DESTINATION parent's `parent_children_hash` (since 1.1).
42        expect: Option<Expect>,
43    },
44    Remove {
45        blocks: Vec<String>,
46        expect: Option<BTreeMap<String, Expect>>,
47    },
48    Split {
49        block: String,
50        /// Byte offsets (§2.5).
51        at: Vec<usize>,
52        expect: Option<Expect>,
53    },
54    Merge {
55        blocks: Vec<String>,
56        separator: Option<String>,
57        expect: Option<BTreeMap<String, Expect>>,
58    },
59}
60
61impl Op {
62    /// The op's name (`insert`, …).
63    #[must_use]
64    pub const fn name(&self) -> &'static str {
65        match self {
66            Op::Insert { .. } => "insert",
67            Op::Update { .. } => "update",
68            Op::Move { .. } => "move",
69            Op::Remove { .. } => "remove",
70            Op::Split { .. } => "split",
71            Op::Merge { .. } => "merge",
72        }
73    }
74}
75
76// ---- JSON ---------------------------------------------------------------------------
77
78fn expect_to_json(e: &Expect) -> Value {
79    let mut m = Map::new();
80    if let Some(h) = &e.content_hash {
81        m.insert("content_hash".to_owned(), json!(h));
82    }
83    if let Some(h) = &e.parent_children_hash {
84        m.insert("parent_children_hash".to_owned(), json!(h));
85    }
86    Value::Object(m)
87}
88
89fn expect_from_json(v: &Value) -> std::result::Result<Expect, String> {
90    let obj = v.as_object().ok_or("expect must be an object")?;
91    let s = |k: &str| -> std::result::Result<Option<String>, String> {
92        match obj.get(k) {
93            None | Some(Value::Null) => Ok(None),
94            Some(Value::String(s)) => Ok(Some(s.clone())),
95            Some(_) => Err(format!("expect.{k} must be a string")),
96        }
97    };
98    Ok(Expect {
99        content_hash: s("content_hash")?,
100        parent_children_hash: s("parent_children_hash")?,
101    })
102}
103
104fn expect_map_to_json(m: &BTreeMap<String, Expect>) -> Value {
105    Value::Object(
106        m.iter()
107            .map(|(k, e)| (k.clone(), expect_to_json(e)))
108            .collect(),
109    )
110}
111
112fn expect_map_from_json(v: &Value) -> std::result::Result<BTreeMap<String, Expect>, String> {
113    let obj = v
114        .as_object()
115        .ok_or("expect must be an object of block → expect")?;
116    obj.iter()
117        .map(|(k, e)| Ok((k.clone(), expect_from_json(e)?)))
118        .collect()
119}
120
121/// `To` as JSON: `{ parent, at }`.
122#[must_use]
123pub fn to_to_json(to: &To) -> Value {
124    let parent = match &to.parent {
125        Parent::Doc => json!({ "doc": true }),
126        Parent::Block(id) => json!(id),
127        Parent::Section { heading } => json!({ "heading": heading, "scope": "section" }),
128    };
129    let at = match &to.at {
130        At::Start => json!("start"),
131        At::End => json!("end"),
132        At::Before(id) => json!({ "before": id }),
133        At::After(id) => json!({ "after": id }),
134    };
135    json!({ "parent": parent, "at": at })
136}
137
138/// `To` from JSON (`parent: null` is the document, as the reference accepts).
139pub fn to_from_json(v: &Value) -> std::result::Result<To, String> {
140    let obj = v.as_object().ok_or("to must be an object")?;
141    let parent = match obj.get("parent") {
142        None | Some(Value::Null) => Parent::Doc,
143        Some(Value::String(id)) => Parent::Block(id.clone()),
144        Some(Value::Object(p)) => {
145            if let Some(h) = p.get("heading").and_then(Value::as_str) {
146                Parent::Section {
147                    heading: h.to_owned(),
148                }
149            } else if p.contains_key("doc") {
150                Parent::Doc
151            } else {
152                return Err("to.parent must be an id, { doc: true } or { heading, scope }".into());
153            }
154        }
155        Some(_) => return Err("to.parent must be an id or an object".into()),
156    };
157    let at = match obj.get("at") {
158        Some(Value::String(s)) if s == "start" => At::Start,
159        Some(Value::String(s)) if s == "end" => At::End,
160        Some(Value::Object(a)) => {
161            if let Some(b) = a.get("before").and_then(Value::as_str) {
162                At::Before(b.to_owned())
163            } else if let Some(b) = a.get("after").and_then(Value::as_str) {
164                At::After(b.to_owned())
165            } else {
166                return Err("to.at must be start, end, { before } or { after }".into());
167            }
168        }
169        _ => return Err("to.at must be start, end, { before } or { after }".into()),
170    };
171    Ok(To { parent, at })
172}
173
174fn strings(v: Option<&Value>, what: &str) -> std::result::Result<Vec<String>, String> {
175    v.and_then(Value::as_array)
176        .ok_or_else(|| format!("{what} must be an array of ids"))?
177        .iter()
178        .map(|x| {
179            x.as_str()
180                .map(str::to_owned)
181                .ok_or_else(|| format!("{what} must be an array of ids"))
182        })
183        .collect()
184}
185
186fn string(v: Option<&Value>, what: &str) -> std::result::Result<String, String> {
187    v.and_then(Value::as_str)
188        .map(str::to_owned)
189        .ok_or_else(|| format!("{what} must be a string"))
190}
191
192fn opt_string(v: Option<&Value>, what: &str) -> std::result::Result<Option<String>, String> {
193    match v {
194        None | Some(Value::Null) => Ok(None),
195        Some(Value::String(s)) => Ok(Some(s.clone())),
196        Some(_) => Err(format!("{what} must be a string")),
197    }
198}
199
200impl Op {
201    /// The wire form (§4 `Op`; `child_ids` as the spec spells it).
202    #[must_use]
203    pub fn to_json(&self) -> Value {
204        let mut m = Map::new();
205        m.insert("op".to_owned(), json!(self.name()));
206        match self {
207            Op::Insert {
208                doc,
209                to,
210                markdown,
211                expect,
212            } => {
213                if let Some(d) = doc {
214                    m.insert("doc".to_owned(), json!(d));
215                }
216                m.insert("to".to_owned(), to_to_json(to));
217                m.insert("markdown".to_owned(), json!(markdown));
218                if let Some(e) = expect {
219                    m.insert("expect".to_owned(), expect_to_json(e));
220                }
221            }
222            Op::Update {
223                block,
224                markdown,
225                attrs,
226                expect,
227                trivia,
228                child_ids,
229            } => {
230                m.insert("block".to_owned(), json!(block));
231                if let Some(md) = markdown {
232                    m.insert("markdown".to_owned(), json!(md));
233                }
234                if let Some(a) = attrs {
235                    m.insert("attrs".to_owned(), Value::Object(a.clone()));
236                }
237                if let Some(e) = expect {
238                    m.insert("expect".to_owned(), expect_to_json(e));
239                }
240                if let Some(t) = trivia {
241                    m.insert("trivia".to_owned(), json!(t));
242                }
243                if let Some(c) = child_ids {
244                    m.insert("child_ids".to_owned(), json!(c));
245                }
246            }
247            Op::Move {
248                blocks,
249                to,
250                doc,
251                expect,
252            } => {
253                m.insert("blocks".to_owned(), json!(blocks));
254                m.insert("to".to_owned(), to_to_json(to));
255                if let Some(d) = doc {
256                    m.insert("doc".to_owned(), json!(d));
257                }
258                if let Some(e) = expect {
259                    m.insert("expect".to_owned(), expect_to_json(e));
260                }
261            }
262            Op::Remove { blocks, expect } => {
263                m.insert("blocks".to_owned(), json!(blocks));
264                if let Some(e) = expect {
265                    m.insert("expect".to_owned(), expect_map_to_json(e));
266                }
267            }
268            Op::Split { block, at, expect } => {
269                m.insert("block".to_owned(), json!(block));
270                m.insert("at".to_owned(), json!(at));
271                if let Some(e) = expect {
272                    m.insert("expect".to_owned(), expect_to_json(e));
273                }
274            }
275            Op::Merge {
276                blocks,
277                separator,
278                expect,
279            } => {
280                m.insert("blocks".to_owned(), json!(blocks));
281                if let Some(s) = separator {
282                    m.insert("separator".to_owned(), json!(s));
283                }
284                if let Some(e) = expect {
285                    m.insert("expect".to_owned(), expect_map_to_json(e));
286                }
287            }
288        }
289        Value::Object(m)
290    }
291
292    /// Parse the wire form (`child_ids` or the reference's `childIds`).
293    pub fn from_json(v: &Value) -> std::result::Result<Op, String> {
294        let obj = v.as_object().ok_or("an op must be an object")?;
295        let kind = obj
296            .get("op")
297            .and_then(Value::as_str)
298            .ok_or("op.op missing")?;
299        let expect_one = |k: &str| -> std::result::Result<Option<Expect>, String> {
300            match obj.get(k) {
301                None | Some(Value::Null) => Ok(None),
302                Some(e) => Ok(Some(expect_from_json(e)?)),
303            }
304        };
305        let expect_many =
306            |k: &str| -> std::result::Result<Option<BTreeMap<String, Expect>>, String> {
307                match obj.get(k) {
308                    None | Some(Value::Null) => Ok(None),
309                    Some(e) => Ok(Some(expect_map_from_json(e)?)),
310                }
311            };
312        Ok(match kind {
313            "insert" => Op::Insert {
314                doc: opt_string(obj.get("doc"), "insert.doc")?,
315                to: to_from_json(obj.get("to").ok_or("insert.to missing")?)?,
316                markdown: string(obj.get("markdown"), "insert.markdown")?,
317                expect: expect_one("expect")?,
318            },
319            "update" => {
320                let attrs = match obj.get("attrs") {
321                    None | Some(Value::Null) => None,
322                    Some(Value::Object(a)) => Some(a.clone()),
323                    Some(_) => return Err("update.attrs must be an object".into()),
324                };
325                let child_ids = match obj.get("child_ids").or_else(|| obj.get("childIds")) {
326                    None | Some(Value::Null) => None,
327                    Some(Value::Object(c)) => Some(
328                        c.iter()
329                            .map(|(k, v)| {
330                                v.as_str()
331                                    .map(|s| (k.clone(), s.to_owned()))
332                                    .ok_or_else(|| "update.child_ids values must be ids".to_owned())
333                            })
334                            .collect::<std::result::Result<BTreeMap<_, _>, _>>()?,
335                    ),
336                    Some(_) => return Err("update.child_ids must be an object".into()),
337                };
338                Op::Update {
339                    block: string(obj.get("block"), "update.block")?,
340                    markdown: opt_string(obj.get("markdown"), "update.markdown")?,
341                    attrs,
342                    expect: expect_one("expect")?,
343                    trivia: opt_string(obj.get("trivia"), "update.trivia")?,
344                    child_ids,
345                }
346            }
347            "move" => Op::Move {
348                blocks: strings(obj.get("blocks"), "move.blocks")?,
349                to: to_from_json(obj.get("to").ok_or("move.to missing")?)?,
350                doc: match obj.get("doc") {
351                    None | Some(Value::Null) => None,
352                    Some(v) => Some(string(Some(v), "move.doc")?),
353                },
354                expect: expect_one("expect")?,
355            },
356            "remove" => Op::Remove {
357                blocks: strings(obj.get("blocks"), "remove.blocks")?,
358                expect: expect_many("expect")?,
359            },
360            "split" => Op::Split {
361                block: string(obj.get("block"), "split.block")?,
362                at: obj
363                    .get("at")
364                    .and_then(Value::as_array)
365                    .ok_or("split.at must be an array of offsets")?
366                    .iter()
367                    .map(|x| {
368                        x.as_u64()
369                            .and_then(|n| usize::try_from(n).ok())
370                            .ok_or_else(|| "split.at must be non-negative integers".to_owned())
371                    })
372                    .collect::<std::result::Result<Vec<_>, _>>()?,
373                expect: expect_one("expect")?,
374            },
375            "merge" => Op::Merge {
376                blocks: strings(obj.get("blocks"), "merge.blocks")?,
377                separator: opt_string(obj.get("separator"), "merge.separator")?,
378                expect: expect_many("expect")?,
379            },
380            other => return Err(format!("unknown op {other}")),
381        })
382    }
383}
384
385// ---- placeholders (§4 step 1) --------------------------------------------------------
386
387/// `$<n>.ids[<i>]` → `(n, i)`; `None` when `value` is not a placeholder.
388#[must_use]
389pub fn parse_placeholder(value: &str) -> Option<(usize, usize)> {
390    let rest = value.strip_prefix('$')?;
391    let (n, rest) = rest.split_once(".ids[")?;
392    let i = rest.strip_suffix(']')?;
393    if n.is_empty()
394        || i.is_empty()
395        || !n.bytes().all(|b| b.is_ascii_digit())
396        || !i.bytes().all(|b| b.is_ascii_digit())
397    {
398        return None;
399    }
400    Some((n.parse().ok()?, i.parse().ok()?))
401}
402
403/// Resolve a placeholder against earlier results; a non-placeholder passes
404/// through; an index out of range is `target_missing`.
405pub fn resolve_placeholder(value: &str, results: &[OpResult]) -> Result<String> {
406    let Some((op_idx, id_idx)) = parse_placeholder(value) else {
407        return Ok(value.to_owned());
408    };
409    results
410        .get(op_idx)
411        .and_then(|r| r.ids.get(id_idx))
412        .filter(|id| !id.is_empty())
413        .cloned()
414        .ok_or_else(|| {
415            MutationError::new(
416                ErrorCode::TargetMissing,
417                format!("placeholder {value} did not resolve"),
418            )
419        })
420}
421
422/// Resolve the placeholders a `To` may carry (`parent`, `before`/`after`).
423pub fn resolve_to(to: &To, results: &[OpResult]) -> Result<To> {
424    let parent = match &to.parent {
425        Parent::Block(id) => Parent::Block(resolve_placeholder(id, results)?),
426        other => other.clone(),
427    };
428    let at = match &to.at {
429        At::Before(id) => At::Before(resolve_placeholder(id, results)?),
430        At::After(id) => At::After(resolve_placeholder(id, results)?),
431        other => other.clone(),
432    };
433    Ok(To { parent, at })
434}
435
436/// Resolve every placeholder-bearing field of an op (`block`, `blocks[]`,
437/// `to`), as `apply` does before dispatching it.
438pub fn resolve_op(op: &Op, results: &[OpResult]) -> Result<Op> {
439    let ids = |list: &[String]| -> Result<Vec<String>> {
440        list.iter()
441            .map(|b| resolve_placeholder(b, results))
442            .collect()
443    };
444    Ok(match op {
445        Op::Insert {
446            doc,
447            to,
448            markdown,
449            expect,
450        } => Op::Insert {
451            doc: doc.clone(),
452            to: resolve_to(to, results)?,
453            markdown: markdown.clone(),
454            expect: expect.clone(),
455        },
456        Op::Update {
457            block,
458            markdown,
459            attrs,
460            expect,
461            trivia,
462            child_ids,
463        } => Op::Update {
464            block: resolve_placeholder(block, results)?,
465            markdown: markdown.clone(),
466            attrs: attrs.clone(),
467            expect: expect.clone(),
468            trivia: trivia.clone(),
469            child_ids: child_ids.clone(),
470        },
471        Op::Move {
472            blocks,
473            to,
474            doc,
475            expect,
476        } => Op::Move {
477            blocks: ids(blocks)?,
478            to: resolve_to(to, results)?,
479            doc: doc.clone(),
480            expect: expect.clone(),
481        },
482        Op::Remove { blocks, expect } => Op::Remove {
483            blocks: ids(blocks)?,
484            expect: expect.clone(),
485        },
486        Op::Split { block, at, expect } => Op::Split {
487            block: resolve_placeholder(block, results)?,
488            at: at.clone(),
489            expect: expect.clone(),
490        },
491        Op::Merge {
492            blocks,
493            separator,
494            expect,
495        } => Op::Merge {
496            blocks: ids(blocks)?,
497            separator: separator.clone(),
498            expect: expect.clone(),
499        },
500    })
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn placeholders_parse_and_resolve() {
509        assert_eq!(parse_placeholder("$0.ids[1]"), Some((0, 1)));
510        assert_eq!(parse_placeholder("$12.ids[0]"), Some((12, 0)));
511        assert_eq!(parse_placeholder("b_x"), None);
512        assert_eq!(parse_placeholder("$a.ids[0]"), None);
513        assert_eq!(parse_placeholder("$0.ids[]"), None);
514        let results = vec![OpResult::ids(vec!["b_1".into(), "b_2".into()])];
515        assert_eq!(resolve_placeholder("$0.ids[1]", &results).unwrap(), "b_2");
516        assert_eq!(resolve_placeholder("b_9", &results).unwrap(), "b_9");
517        let e = resolve_placeholder("$0.ids[2]", &results).unwrap_err();
518        assert_eq!(e.code, ErrorCode::TargetMissing);
519        let e = resolve_placeholder("$1.ids[0]", &results).unwrap_err();
520        assert_eq!(e.code, ErrorCode::TargetMissing);
521        let to = To {
522            parent: Parent::Block("$0.ids[0]".into()),
523            at: At::After("$0.ids[1]".into()),
524        };
525        assert_eq!(
526            resolve_to(&to, &results).unwrap(),
527            To {
528                parent: Parent::Block("b_1".into()),
529                at: At::After("b_2".into()),
530            }
531        );
532        let op = Op::Move {
533            blocks: vec!["$0.ids[0]".into()],
534            to: To {
535                parent: Parent::Doc,
536                at: At::Start,
537            },
538            doc: None,
539            expect: None,
540        };
541        assert!(
542            matches!(resolve_op(&op, &results).unwrap(), Op::Move { blocks, .. } if blocks == ["b_1"])
543        );
544    }
545
546    #[test]
547    fn ops_round_trip_through_json() {
548        let ops = json!([
549            { "op": "insert", "doc": "a.md", "to": { "parent": { "doc": true }, "at": "end" }, "markdown": "x", "expect": { "parent_children_hash": "cd" } },
550            { "op": "insert", "to": { "parent": { "heading": "b_0", "scope": "section" }, "at": { "after": "b_1" } }, "markdown": "x" },
551            { "op": "update", "block": "b_1", "markdown": "y", "attrs": { "checked": true }, "expect": { "content_hash": "ab" }, "trivia": "\n", "child_ids": { "/0": "b_2" } },
552            { "op": "move", "blocks": ["b_1"], "to": { "parent": "b_0", "at": { "before": "b_2" } }, "expect": { "parent_children_hash": "cd" } },
553            { "op": "remove", "blocks": ["b_1"], "expect": { "b_1": { "content_hash": "ab" } } },
554            { "op": "split", "block": "b_1", "at": [3, 7], "expect": { "content_hash": "ab" } },
555            { "op": "merge", "blocks": ["b_1", "b_2"], "separator": "\n" },
556        ]);
557        for v in ops.as_array().unwrap() {
558            let op = Op::from_json(v).unwrap();
559            assert_eq!(op.to_json(), *v);
560        }
561        // The reference's `childIds` spelling and `parent: null` are accepted.
562        let op =
563            Op::from_json(&json!({ "op": "update", "block": "b", "childIds": { "/0": "b_2" } }))
564                .unwrap();
565        assert!(matches!(op, Op::Update { child_ids: Some(c), .. } if c["/0"] == "b_2"));
566        let to = to_from_json(&json!({ "parent": null, "at": "start" })).unwrap();
567        assert_eq!(to.parent, Parent::Doc);
568        assert!(Op::from_json(&json!({ "op": "nope" })).is_err());
569        assert!(Op::from_json(&json!({ "op": "split", "block": "b", "at": [-1] })).is_err());
570    }
571}