Skip to main content

omgbase_mutate/
ops.rs

1//! The six operations (`spec/mutate/README.md` §1.1, §1.2, §2) over a
2//! [`MutDoc`], plus the cross-document move (§2.3). Every op mutates the
3//! tree in place; an error leaves the tree partially mutated, which is fine
4//! because a changeset aborts as a whole on the first error (§4 step 3).
5
6use std::collections::{BTreeMap, HashSet};
7
8use omgbase_format::json::attrs_to_json;
9use omgbase_format::{Block, BlockKind, parse_markdown};
10use omgbase_reconcile::Minter;
11use serde_json::{Map, Value, json};
12
13use crate::error::{ErrorCode, MutationError, Result};
14use crate::tree::{BlockPath, MutBlock, MutDoc, parent_children_hash, raw_hash_hex};
15
16/// §1.1 `To.parent`.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum Parent {
19    /// The document's top level (`{ doc: true }`).
20    Doc,
21    /// A block's children.
22    Block(String),
23    /// The top level, within a heading's section (`{ heading, scope: "section" }`).
24    Section { heading: String },
25}
26
27/// §1.1 `To.at`.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum At {
30    Start,
31    End,
32    Before(String),
33    After(String),
34}
35
36impl At {
37    /// The anchor id of a `before`/`after`.
38    #[must_use]
39    pub fn anchor(&self) -> Option<&str> {
40        match self {
41            At::Before(id) | At::After(id) => Some(id),
42            At::Start | At::End => None,
43        }
44    }
45}
46
47/// §1.1 placement.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct To {
50    pub parent: Parent,
51    pub at: At,
52}
53
54/// §1.2 expectations.
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
56pub struct Expect {
57    pub content_hash: Option<String>,
58    pub parent_children_hash: Option<String>,
59}
60
61impl Expect {
62    /// `{ content_hash }`.
63    #[must_use]
64    pub fn content(hash: impl Into<String>) -> Self {
65        Self {
66            content_hash: Some(hash.into()),
67            parent_children_hash: None,
68        }
69    }
70
71    /// `{ parent_children_hash }` — the §1.2 order CAS alone (what an
72    /// `insert`/`move` sends for its destination parent).
73    #[must_use]
74    pub fn order(hash: impl Into<String>) -> Self {
75        Self {
76            content_hash: None,
77            parent_children_hash: Some(hash.into()),
78        }
79    }
80}
81
82/// What an op returns (§2): the ids, plus `removed` (remove) or
83/// `merged_into` (merge).
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
85pub struct OpResult {
86    pub ids: Vec<String>,
87    pub removed: Option<Vec<String>>,
88    pub merged_into: Option<Vec<String>>,
89}
90
91impl OpResult {
92    #[must_use]
93    pub fn ids(ids: Vec<String>) -> Self {
94        Self {
95            ids,
96            removed: None,
97            merged_into: None,
98        }
99    }
100
101    /// `{ ids, removed?, merged_into? }`.
102    #[must_use]
103    pub fn to_json(&self) -> Value {
104        let mut m = Map::new();
105        m.insert("ids".to_owned(), json!(self.ids));
106        if let Some(r) = &self.removed {
107            m.insert("removed".to_owned(), json!(r));
108        }
109        if let Some(r) = &self.merged_into {
110            m.insert("merged_into".to_owned(), json!(r));
111        }
112        Value::Object(m)
113    }
114}
115
116/// The format's inter-block separator (`"\n\n"` for Markdown).
117#[must_use]
118pub fn default_trivia(format: &str) -> &'static str {
119    match format {
120        "json" => ",\n",
121        "yaml" => "\n",
122        _ => "\n\n",
123    }
124}
125
126/// Whether trailing trivia holds a real block boundary: a blank line for
127/// Markdown, anything non-empty otherwise.
128#[must_use]
129pub fn separates_blocks(trivia: &str, format: &str) -> bool {
130    if format == "markdown" {
131        trivia.contains("\n\n")
132    } else {
133        !trivia.is_empty()
134    }
135}
136
137fn to_mut(b: &Block, trivia_default: &str, minter: &mut dyn Minter) -> MutBlock {
138    let id = minter.mint();
139    let attrs = match attrs_to_json(&b.attrs) {
140        Value::Object(m) => m,
141        _ => Map::new(),
142    };
143    MutBlock {
144        id,
145        kind: b.kind.as_str().to_owned(),
146        raw: b.raw.clone(),
147        trivia: if b.trivia.is_empty() {
148            trivia_default.to_owned()
149        } else {
150            b.trivia.clone()
151        },
152        attrs,
153        children: b
154            .children
155            .iter()
156            .map(|c| to_mut(c, trivia_default, minter))
157            .collect(),
158        dirty: false,
159    }
160}
161
162/// §2: parse op-supplied content (a trailing `\n` ensured) into clean
163/// blocks with freshly minted ids, pre-order at parse time; a `frontmatter`
164/// block is dropped (§10 mint order).
165pub fn parse_content(content: &str, format: &str, minter: &mut dyn Minter) -> Vec<MutBlock> {
166    let normalized = if content.ends_with('\n') {
167        content.to_owned()
168    } else {
169        format!("{content}\n")
170    };
171    let tree = parse_markdown(&normalized);
172    let trivia = default_trivia(format);
173    tree.children
174        .iter()
175        .filter(|b| b.kind != BlockKind::Frontmatter)
176        .map(|b| to_mut(b, trivia, minter))
177        .collect()
178}
179
180fn err(code: ErrorCode, msg: impl Into<String>) -> MutationError {
181    MutationError::new(code, msg)
182}
183
184fn err_data(code: ErrorCode, msg: impl Into<String>, data: Value) -> MutationError {
185    MutationError::with_data(code, msg, data)
186}
187
188/// A resolved placement: the parent path and the insertion index.
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct Target {
191    pub parent: BlockPath,
192    pub index: usize,
193}
194
195fn resolve_index(siblings: &[MutBlock], at: &At) -> Result<usize> {
196    match at {
197        At::Start => Ok(0),
198        At::End => Ok(siblings.len()),
199        At::Before(id) => siblings
200            .iter()
201            .position(|b| b.id == *id)
202            .ok_or_else(|| err(ErrorCode::TargetMissing, format!("anchor {id} not found"))),
203        At::After(id) => siblings
204            .iter()
205            .position(|b| b.id == *id)
206            .map(|i| i + 1)
207            .ok_or_else(|| err(ErrorCode::TargetMissing, format!("anchor {id} not found"))),
208    }
209}
210
211fn level_of(b: &MutBlock) -> i64 {
212    match b.attrs.get("level") {
213        None | Some(Value::Null) => 1,
214        Some(v) => js_number(v),
215    }
216}
217
218/// JavaScript `Number(v)` for the attr values a level can hold.
219fn js_number(v: &Value) -> i64 {
220    match v {
221        Value::Number(n) => n
222            .as_i64()
223            .unwrap_or_else(|| n.as_f64().unwrap_or(0.0) as i64),
224        Value::Bool(b) => i64::from(*b),
225        Value::String(s) => s.trim().parse().unwrap_or(0),
226        _ => 0,
227    }
228}
229
230/// §1.1: a section runs from its heading to before the next top-level
231/// heading of level ≤ its own.
232fn resolve_section(doc: &MutDoc, heading: &str, at: &At) -> Result<Target> {
233    let tops = &doc.children;
234    let h_idx = tops.iter().position(|b| b.id == heading).ok_or_else(|| {
235        err(
236            ErrorCode::TargetMissing,
237            format!("heading {heading} not found"),
238        )
239    })?;
240    let level = level_of(&tops[h_idx]);
241    let mut end = tops.len();
242    for (i, b) in tops.iter().enumerate().skip(h_idx + 1) {
243        if b.kind == "heading" && level_of(b) <= level {
244            end = i;
245            break;
246        }
247    }
248    let index = match at {
249        At::End => end,
250        At::Start => h_idx + 1,
251        anchor => resolve_index(tops, anchor)?,
252    };
253    Ok(Target {
254        parent: Vec::new(),
255        index,
256    })
257}
258
259/// §1.1 (1.2): whether `block` can own children placed by `insert`/`move` —
260/// a kind whose children the parser produces. Markdown asks the format
261/// layer's kind table (`list`, `list_item`, `task`, `blockquote`, `table`);
262/// any other format has no kind table, so a block with children is a
263/// container and a childless one is a leaf.
264fn is_container(format: &str, block: &MutBlock) -> bool {
265    if format == "markdown" {
266        block
267            .kind
268            .parse::<BlockKind>()
269            .is_ok_and(|k| k.is_container())
270    } else {
271        !block.children.is_empty()
272    }
273}
274
275/// §1.1 (1.2): `to.parent` naming a leaf is `type_mismatch` — before 1.2 the
276/// blocks landed in the leaf's empty `children`, the dirty leaf re-rendered
277/// from them and its own text was lost (§10).
278fn require_container(doc: &MutDoc, path: &[usize], op_index: usize) -> Result<()> {
279    let block = doc.block(path);
280    if is_container(&doc.format, block) {
281        return Ok(());
282    }
283    let (id, kind) = (&block.id, &block.kind);
284    Err(err_data(
285        ErrorCode::TypeMismatch,
286        format!(
287            "{id} is a {kind}, not a container; place relative to it with at.before/at.after or append to its section"
288        ),
289        json!({ "op_index": op_index, "block": id, "type": kind }),
290    ))
291}
292
293/// §1.1: resolve a placement to a sibling list and an index. A `parent` id
294/// must name a container (1.2); `op_index` rides on that error.
295pub fn resolve_target(doc: &MutDoc, to: &To, op_index: usize) -> Result<Target> {
296    match &to.parent {
297        Parent::Doc => Ok(Target {
298            parent: Vec::new(),
299            index: resolve_index(&doc.children, &to.at)?,
300        }),
301        Parent::Section { heading } => resolve_section(doc, heading, &to.at),
302        Parent::Block(id) => {
303            let path = doc
304                .locate(id)
305                .ok_or_else(|| err(ErrorCode::ParentMissing, format!("parent {id} not found")))?;
306            require_container(doc, &path, op_index)?;
307            let index = resolve_index(&doc.block(&path).children, &to.at)?;
308            Ok(Target {
309                parent: path,
310                index,
311            })
312        }
313    }
314}
315
316/// §1.2 `check_content_hash`: a missing or mismatched `content_hash` raises
317/// `stale_expectation` carrying the current hash and bytes.
318pub fn check_content_hash(
319    block: &MutBlock,
320    expect: Option<&Expect>,
321    op_index: usize,
322) -> Result<()> {
323    let Some(expected) = expect
324        .and_then(|e| e.content_hash.as_deref())
325        .filter(|h| !h.is_empty())
326    else {
327        return Err(err_data(
328            ErrorCode::StaleExpectation,
329            "expect.content_hash required",
330            json!({
331                "op_index": op_index,
332                "block": block.id,
333                "current": { "content_hash": raw_hash_hex(&block.raw), "markdown": block.raw },
334                "retriable": true,
335            }),
336        ));
337    };
338    let current = raw_hash_hex(&block.raw);
339    if current != expected {
340        return Err(err_data(
341            ErrorCode::StaleExpectation,
342            "content hash mismatch",
343            json!({
344                "op_index": op_index,
345                "block": block.id,
346                "expected_content_hash": expected,
347                "current": { "content_hash": current, "markdown": block.raw },
348                "retriable": true,
349            }),
350        ));
351    }
352    Ok(())
353}
354
355/// §1.2: `parent_children_hash` of the block's children (the top level for
356/// `None`) must equal `expected`.
357pub fn check_parent_children_hash(
358    doc: &MutDoc,
359    parent_id: Option<&str>,
360    expected: &str,
361    op_index: usize,
362) -> Result<()> {
363    let list = match parent_id.and_then(|id| doc.locate(id)) {
364        Some(path) => &doc.block(&path).children,
365        None => &doc.children,
366    };
367    check_children_hash(list, Some(&Expect::order(expected)), op_index)
368}
369
370/// §1.2 as the ops call it: when `expect` carries a `parent_children_hash`,
371/// the LIVE ids of `list` (the sibling list the op resolved — the block's
372/// current parent for update/split/remove/merge, the destination parent for
373/// insert/move) must hash to it. Runs after the content CAS and before the
374/// tree is touched, so a stale order never lands.
375fn check_children_hash(list: &[MutBlock], expect: Option<&Expect>, op_index: usize) -> Result<()> {
376    let Some(expected) = expect.and_then(|e| e.parent_children_hash.as_deref()) else {
377        return Ok(());
378    };
379    let current = parent_children_hash(list);
380    if current != expected {
381        return Err(err_data(
382            ErrorCode::StaleExpectation,
383            "parent_children_hash mismatch",
384            json!({
385                "op_index": op_index,
386                "current": { "parent_children_hash": current },
387                "retriable": true,
388            }),
389        ));
390    }
391    Ok(())
392}
393
394/// §2.2: override the minted ids of a re-parsed subtree by positional key
395/// (`"/0"`, `"/1/2"`, relative to the block's children).
396fn assign_child_ids(
397    children: &mut [MutBlock],
398    child_ids: &BTreeMap<String, String>,
399    parent_key: &str,
400) {
401    for (i, c) in children.iter_mut().enumerate() {
402        let key = format!("{parent_key}/{i}");
403        if let Some(id) = child_ids.get(&key).filter(|id| !id.is_empty()) {
404            c.id = id.clone();
405        }
406        if !c.children.is_empty() {
407            assign_child_ids(&mut c.children, child_ids, &key);
408        }
409    }
410}
411
412/// §2.1: a bare block becomes a `list_item` with raw `"- " + raw` (fresh id,
413/// dirty).
414fn wrap_as_item(b: &MutBlock, minter: &mut dyn Minter) -> MutBlock {
415    MutBlock {
416        id: minter.mint(),
417        kind: "list_item".to_owned(),
418        raw: format!("- {}", b.raw),
419        trivia: String::new(),
420        attrs: Map::new(),
421        children: Vec::new(),
422        dirty: true,
423    }
424}
425
426// ---- the six ops --------------------------------------------------------------------
427
428/// §2.1 `insert`.
429pub fn op_insert(
430    doc: &mut MutDoc,
431    to: &To,
432    markdown: &str,
433    op_index: usize,
434    expect: Option<&Expect>,
435    minter: &mut dyn Minter,
436) -> Result<OpResult> {
437    let Target { parent, index } = resolve_target(doc, to, op_index)?;
438    // §1.2: the destination parent's order CAS — `parent` IS the resolved
439    // sibling list (a section scope or a `{ doc: true }` anchor name the top level).
440    check_children_hash(doc.siblings(&parent), expect, op_index)?;
441    let mut blocks = parse_content(markdown, &doc.format, minter);
442    let owner_is_list = doc.owner_of(&parent).is_some_and(|o| o.kind == "list");
443    if owner_is_list && doc.format == "markdown" {
444        let mut items = Vec::new();
445        for b in blocks {
446            if b.kind == "list" {
447                items.extend(b.children);
448            } else {
449                items.push(wrap_as_item(&b, minter));
450            }
451        }
452        let ids: Vec<String> = items.iter().map(|b| b.id.clone()).collect();
453        let siblings = doc.siblings_mut(&parent);
454        let tail = siblings.split_off(index);
455        siblings.extend(items);
456        siblings.extend(tail);
457        doc.block_mut(&parent).dirty = true;
458        return Ok(OpResult::ids(ids));
459    }
460    let format = doc.format.clone();
461    if parent.is_empty() {
462        let sep = default_trivia(&format);
463        if index > 0 {
464            let prev = &mut doc.children[index - 1];
465            if !separates_blocks(&prev.trivia, &format) {
466                prev.trivia = sep.to_owned();
467            }
468        }
469        if !blocks.is_empty() && index < doc.children.len() {
470            let last = blocks.last_mut().expect("non-empty");
471            if !separates_blocks(&last.trivia, &format) {
472                last.trivia = sep.to_owned();
473            }
474        }
475    }
476    let ids: Vec<String> = blocks.iter().map(|b| b.id.clone()).collect();
477    let siblings = doc.siblings_mut(&parent);
478    let tail = siblings.split_off(index);
479    siblings.extend(blocks);
480    siblings.extend(tail);
481    if !parent.is_empty() {
482        doc.mark_container_dirty(&parent);
483    }
484    Ok(OpResult::ids(ids))
485}
486
487/// The inputs of §2.2 `update` besides the block.
488#[derive(Clone, Debug, Default, PartialEq)]
489pub struct UpdateArgs {
490    pub markdown: Option<String>,
491    pub attrs: Option<Map<String, Value>>,
492    pub expect: Option<Expect>,
493    pub trivia: Option<String>,
494    pub child_ids: Option<BTreeMap<String, String>>,
495}
496
497/// §2.2 `update`.
498pub fn op_update(
499    doc: &mut MutDoc,
500    block_id: &str,
501    op_index: usize,
502    args: &UpdateArgs,
503    minter: &mut dyn Minter,
504) -> Result<OpResult> {
505    let path = doc.locate(block_id).ok_or_else(|| {
506        err_data(
507            ErrorCode::BlockMissing,
508            format!("block {block_id} not found"),
509            json!({ "op_index": op_index, "block": block_id }),
510        )
511    })?;
512    let (index, parent) = path.split_last().expect("located path is non-empty");
513    let (index, parent) = (*index, parent.to_vec());
514    if args.markdown.is_some()
515        || args
516            .expect
517            .as_ref()
518            .is_some_and(|e| e.content_hash.is_some())
519    {
520        check_content_hash(doc.block(&path), args.expect.as_ref(), op_index)?;
521    }
522    // §1.2: the block's CURRENT parent, after the content CAS.
523    check_children_hash(doc.siblings(&parent), args.expect.as_ref(), op_index)?;
524    let format = doc.format.clone();
525    let mut extra: Vec<MutBlock> = Vec::new();
526    if let Some(markdown) = &args.markdown {
527        let is_item = doc.block(&path).kind == "list_item";
528        if format == "markdown" && is_item {
529            let mut parsed = parse_content(markdown, &format, minter);
530            if parsed.len() != 1 {
531                return Err(err_data(
532                    ErrorCode::TypeMismatch,
533                    "list-item update content must be ONE list (`- a\\n- b`: first item replaces, the rest follow as siblings) or ONE bare block",
534                    json!({
535                        "op_index": op_index,
536                        "block": block_id,
537                        "hint": "to put mixed content under an item, update the item then blocks_insert the rest with `to` = the item",
538                    }),
539                ));
540            }
541            let only = parsed.remove(0);
542            let block = doc.block_mut(&path);
543            if only.kind == "list" {
544                if only.children.is_empty() {
545                    return Err(err_data(
546                        ErrorCode::TypeMismatch,
547                        "list-item update must yield at least one item",
548                        json!({ "op_index": op_index, "block": block_id }),
549                    ));
550                }
551                let mut items = only.children.into_iter();
552                let first = items.next().expect("non-empty");
553                block.raw = first.raw;
554                block.children = first.children;
555                extra.extend(items);
556            } else {
557                block.raw = format!("- {}", only.raw);
558                block.children = Vec::new();
559            }
560            block.dirty = true;
561            doc.mark_container_dirty(&parent);
562        } else if format == "markdown" {
563            let mut parsed = parse_content(markdown, &format, minter);
564            if parsed.is_empty() {
565                return Err(err_data(
566                    ErrorCode::TypeMismatch,
567                    "update content must contain at least one block",
568                    json!({ "op_index": op_index, "block": block_id }),
569                ));
570            }
571            if parsed.len() > 1 && args.child_ids.is_some() {
572                return Err(err_data(
573                    ErrorCode::TypeMismatch,
574                    "update with childIds must be a single block",
575                    json!({ "op_index": op_index, "block": block_id }),
576                ));
577            }
578            let nb = parsed.remove(0);
579            extra.extend(parsed);
580            let block = doc.block_mut(&path);
581            block.raw = nb.raw;
582            block.kind = nb.kind;
583            block.attrs = nb.attrs;
584            block.children = nb.children;
585            if let Some(child_ids) = &args.child_ids {
586                if !block.children.is_empty() {
587                    assign_child_ids(&mut block.children, child_ids, "");
588                }
589            }
590            block.mark_subtree_clean();
591            doc.mark_container_dirty(&parent);
592        } else {
593            let block = doc.block_mut(&path);
594            block.raw = markdown.clone();
595            block.children = Vec::new();
596            block.dirty = true;
597        }
598    }
599    if let Some(attrs) = &args.attrs {
600        let block = doc.block_mut(&path);
601        for (k, v) in attrs {
602            block.attrs.insert(k.clone(), v.clone());
603        }
604        if let Some(checked) = attrs.get("checked") {
605            if block.kind == "task" || block.kind == "list_item" {
606                let replacement = if truthy(checked) { "[x]" } else { "[ ]" };
607                if let Some(pos) = find_checkbox(&block.raw) {
608                    block.raw.replace_range(pos..pos + 3, replacement);
609                }
610                block.kind = "task".to_owned();
611                block.dirty = true;
612            }
613        }
614    }
615    if let Some(trivia) = &args.trivia {
616        doc.block_mut(&path).trivia = trivia.clone();
617    }
618    let mut ids = vec![block_id.to_owned()];
619    if !extra.is_empty() {
620        let tail = doc.block(&path).trivia.clone();
621        if parent.is_empty() {
622            let sep = default_trivia(&format);
623            let block = doc.block_mut(&path);
624            if !separates_blocks(&block.trivia, &format) {
625                block.trivia = sep.to_owned();
626            }
627            let n = extra.len();
628            for b in &mut extra[..n - 1] {
629                if !separates_blocks(&b.trivia, &format) {
630                    b.trivia = sep.to_owned();
631                }
632            }
633        }
634        extra.last_mut().expect("non-empty").trivia = tail;
635        ids.extend(extra.iter().map(|b| b.id.clone()));
636        let siblings = doc.siblings_mut(&parent);
637        let rest = siblings.split_off(index + 1);
638        siblings.extend(extra);
639        siblings.extend(rest);
640        doc.mark_container_dirty(&parent);
641    }
642    Ok(OpResult::ids(ids))
643}
644
645/// The first `[ ]`/`[x]`/`[X]` in `raw` (byte offset).
646fn find_checkbox(raw: &str) -> Option<usize> {
647    let bytes = raw.as_bytes();
648    (0..bytes.len().saturating_sub(2)).find(|&i| {
649        bytes[i] == b'[' && matches!(bytes[i + 1], b' ' | b'x' | b'X') && bytes[i + 2] == b']'
650    })
651}
652
653/// JavaScript truthiness of a JSON value.
654fn truthy(v: &Value) -> bool {
655    match v {
656        Value::Null => false,
657        Value::Bool(b) => *b,
658        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
659        Value::String(s) => !s.is_empty(),
660        Value::Array(_) | Value::Object(_) => true,
661    }
662}
663
664/// §2.3: every non-last top-level block whose trivia does not separate gets
665/// the separator.
666fn heal_top_level_seams(doc: &mut MutDoc) {
667    let format = doc.format.clone();
668    let sep = default_trivia(&format);
669    let n = doc.children.len();
670    for b in doc.children.iter_mut().take(n.saturating_sub(1)) {
671        if !separates_blocks(&b.trivia, &format) {
672            b.trivia = sep.to_owned();
673        }
674    }
675}
676
677/// Locate every id (`block_missing` with `{ op_index, block }`) and require
678/// one sibling list; returns the parent path and each block's index in
679/// argument order.
680fn locate_run(
681    doc: &MutDoc,
682    block_ids: &[String],
683    op_index: usize,
684) -> Result<(BlockPath, Vec<usize>)> {
685    let mut parent: Option<BlockPath> = None;
686    let mut indices = Vec::with_capacity(block_ids.len());
687    let mut same_parent = true;
688    for id in block_ids {
689        let path = doc.locate(id).ok_or_else(|| {
690            err_data(
691                ErrorCode::BlockMissing,
692                format!("block {id} not found"),
693                json!({ "op_index": op_index, "block": id }),
694            )
695        })?;
696        let (index, p) = path.split_last().expect("non-empty");
697        match &parent {
698            None => parent = Some(p.to_vec()),
699            Some(first) if first.as_slice() != p => same_parent = false,
700            Some(_) => {}
701        }
702        indices.push(*index);
703    }
704    let parent = parent.unwrap_or_default();
705    if !same_parent {
706        // Signalled by an impossible index list: callers check contiguity.
707        return Ok((parent, Vec::new()));
708    }
709    Ok((parent, indices))
710}
711
712/// §2.3 `move` within one document.
713pub fn op_move(
714    doc: &mut MutDoc,
715    block_ids: &[String],
716    to: &To,
717    op_index: usize,
718    expect: Option<&Expect>,
719) -> Result<OpResult> {
720    if block_ids.is_empty() {
721        return Err(err(ErrorCode::NotContiguous, "move requires ≥1 block"));
722    }
723    let (parent, indices) = locate_run(doc, block_ids, op_index)?;
724    let mut sorted = indices.clone();
725    sorted.sort_unstable();
726    let contiguous = !indices.is_empty() && sorted.windows(2).all(|w| w[1] == w[0] + 1);
727    if !contiguous {
728        return Err(err_data(
729            ErrorCode::NotContiguous,
730            "move blocks must be a contiguous sibling run",
731            json!({ "op_index": op_index }),
732        ));
733    }
734    // cycle_move: the target parent must not be inside the moved subtrees.
735    if let Parent::Block(target) = &to.parent {
736        let siblings = doc.siblings(&parent);
737        let moved: HashSet<String> = indices
738            .iter()
739            .flat_map(|&i| siblings[i].subtree_ids())
740            .collect();
741        if moved.contains(target) {
742            return Err(err_data(
743                ErrorCode::CycleMove,
744                "target is inside the moved subtree",
745                json!({ "op_index": op_index }),
746            ));
747        }
748    }
749    // The destination resolves on the pre-removal tree first so a leaf parent
750    // (§1.1 `type_mismatch`) or a missing one fails before the op mutates
751    // anything; the index is re-resolved after the removal below.
752    let Target { parent: dst, .. } = resolve_target(doc, to, op_index)?;
753    // §1.2: the DESTINATION parent's order CAS, checked once for the whole
754    // run. When source and destination are one list this is the "same
755    // siblings reordered under me" guard: the hash covers the moved blocks too.
756    check_children_hash(doc.siblings(&dst), expect, op_index)?;
757    // Extract in argument order.
758    let mut moving = Vec::with_capacity(block_ids.len());
759    {
760        let siblings = doc.siblings_mut(&parent);
761        for id in block_ids {
762            if let Some(i) = siblings.iter().position(|b| &b.id == id) {
763                moving.push(siblings.remove(i));
764            }
765        }
766    }
767    // The source owner's path is unaffected by removals inside its list; mark
768    // it before the destination splice can shift top-level indices.
769    doc.mark_container_dirty(&parent);
770    let Target { parent: dst, index } = resolve_target(doc, to, op_index)?;
771    {
772        let siblings = doc.siblings_mut(&dst);
773        let tail = siblings.split_off(index);
774        siblings.extend(moving);
775        siblings.extend(tail);
776    }
777    doc.mark_container_dirty(&dst);
778    if parent.is_empty() || dst.is_empty() {
779        heal_top_level_seams(doc);
780    }
781    Ok(OpResult::ids(block_ids.to_vec()))
782}
783
784/// Whether any owner up the chain from the sibling list at `parent` is in `set`.
785fn has_ancestor_in(doc: &MutDoc, parent: &[usize], set: &HashSet<&str>) -> bool {
786    (1..=parent.len()).any(|n| set.contains(doc.block(&parent[..n]).id.as_str()))
787}
788
789/// After removing from the list at `parent`: an emptied owner is removed from
790/// its own parent (recursively), otherwise the owner is marked dirty.
791fn prune_or_dirty(doc: &mut MutDoc, parent: &[usize]) {
792    if parent.is_empty() {
793        return;
794    }
795    if doc.block(parent).children.is_empty() {
796        let (index, grand) = parent.split_last().expect("non-empty");
797        doc.siblings_mut(grand).remove(*index);
798        prune_or_dirty(doc, grand);
799    } else {
800        doc.block_mut(parent).dirty = true;
801    }
802}
803
804/// §2.4 `remove`.
805pub fn op_remove(
806    doc: &mut MutDoc,
807    block_ids: &[String],
808    op_index: usize,
809    expect_per: Option<&BTreeMap<String, Expect>>,
810) -> Result<OpResult> {
811    let set: HashSet<&str> = block_ids.iter().map(String::as_str).collect();
812    let mut tops: Vec<&str> = Vec::new();
813    let mut seen: HashSet<&str> = HashSet::new();
814    for id in block_ids {
815        if !seen.insert(id) {
816            continue;
817        }
818        let path = doc.locate(id).ok_or_else(|| {
819            err_data(
820                ErrorCode::BlockMissing,
821                format!("block {id} not found in this document"),
822                json!({
823                    "op_index": op_index,
824                    "block": id,
825                    "hint": "the id is not a live block of the targeted document — it may have been removed by an earlier op in this changeset (removing a block removes its whole subtree), or never existed",
826                }),
827            )
828        })?;
829        let (_, parent) = path.split_last().expect("non-empty");
830        if let Some(e) = expect_per.and_then(|m| m.get(id)) {
831            check_content_hash(doc.block(&path), Some(e), op_index)?;
832            // §1.2: its current parent, while the block is still in place.
833            check_children_hash(doc.siblings(parent), Some(e), op_index)?;
834        }
835        if !has_ancestor_in(doc, parent, &set) {
836            tops.push(id);
837        }
838    }
839    let mut removed = Vec::new();
840    for id in tops {
841        let path = doc.locate(id).expect("validated in pass 1");
842        let (index, parent) = path.split_last().expect("non-empty");
843        let block = doc.siblings_mut(parent).remove(*index);
844        removed.extend(block.subtree_ids());
845        prune_or_dirty(doc, parent);
846    }
847    Ok(OpResult {
848        ids: block_ids.to_vec(),
849        removed: Some(removed),
850        merged_into: None,
851    })
852}
853
854/// A byte offset rounded down to a char boundary and clamped to the length.
855fn char_boundary(s: &str, at: usize) -> usize {
856    let mut i = at.min(s.len());
857    while !s.is_char_boundary(i) {
858        i -= 1;
859    }
860    i
861}
862
863/// §2.5 `split`.
864pub fn op_split(
865    doc: &mut MutDoc,
866    block_id: &str,
867    at: &[usize],
868    op_index: usize,
869    expect: Option<&Expect>,
870    minter: &mut dyn Minter,
871) -> Result<OpResult> {
872    let path = doc.locate(block_id).ok_or_else(|| {
873        err_data(
874            ErrorCode::BlockMissing,
875            format!("block {block_id} not found"),
876            json!({ "op_index": op_index, "block": block_id }),
877        )
878    })?;
879    check_content_hash(doc.block(&path), expect, op_index)?;
880    // §1.2: its current parent, after the content CAS.
881    let (_, split_parent) = path.split_last().expect("non-empty");
882    check_children_hash(doc.siblings(split_parent), expect, op_index)?;
883    let raw = doc.block(&path).raw.clone();
884    let mut cuts: Vec<usize> = vec![0];
885    cuts.extend(at.iter().map(|&a| char_boundary(&raw, a)));
886    cuts.push(raw.len());
887    cuts.sort_unstable();
888    let pieces: Vec<&str> = cuts
889        .windows(2)
890        .map(|w| &raw[w[0]..w[1]])
891        .filter(|p| !p.trim().is_empty())
892        .collect();
893    if pieces.len() < 2 {
894        return Err(err(
895            ErrorCode::TypeMismatch,
896            "split must yield ≥2 non-empty fragments",
897        ));
898    }
899    let (kind, attrs) = {
900        let block = doc.block_mut(&path);
901        block.raw = pieces[0].to_owned();
902        block.dirty = true;
903        (block.kind.clone(), block.attrs.clone())
904    };
905    let mut new_blocks: Vec<MutBlock> = pieces[1..]
906        .iter()
907        .map(|p| MutBlock {
908            id: minter.mint(),
909            kind: kind.clone(),
910            raw: (*p).to_owned(),
911            trivia: "\n\n".to_owned(),
912            attrs: attrs.clone(),
913            children: Vec::new(),
914            dirty: true,
915        })
916        .collect();
917    let mut ids = vec![block_id.to_owned()];
918    ids.extend(new_blocks.iter().map(|b| b.id.clone()));
919    let (index, parent) = path.split_last().expect("non-empty");
920    // Seams exactly as update's extra siblings: the block's trailing trivia
921    // moves to the last piece; at the top level every earlier piece separates.
922    let format = doc.format.clone();
923    let tail_trivia = doc.block(&path).trivia.clone();
924    if parent.is_empty() {
925        let sep = default_trivia(&format);
926        let block = doc.block_mut(&path);
927        if !separates_blocks(&block.trivia, &format) {
928            block.trivia = sep.to_owned();
929        }
930        let n = new_blocks.len();
931        for b in &mut new_blocks[..n - 1] {
932            if !separates_blocks(&b.trivia, &format) {
933                b.trivia = sep.to_owned();
934            }
935        }
936    }
937    new_blocks.last_mut().expect("non-empty").trivia = tail_trivia;
938    let siblings = doc.siblings_mut(parent);
939    let rest = siblings.split_off(index + 1);
940    siblings.extend(new_blocks);
941    siblings.extend(rest);
942    doc.mark_container_dirty(parent);
943    Ok(OpResult::ids(ids))
944}
945
946/// §2.6 `merge`.
947pub fn op_merge(
948    doc: &mut MutDoc,
949    block_ids: &[String],
950    op_index: usize,
951    separator: Option<&str>,
952    expect_per: Option<&BTreeMap<String, Expect>>,
953) -> Result<OpResult> {
954    if block_ids.len() < 2 {
955        return Err(err(ErrorCode::NotContiguous, "merge requires ≥2 blocks"));
956    }
957    let separator = separator.unwrap_or(" ");
958    let mut paths = Vec::with_capacity(block_ids.len());
959    for id in block_ids {
960        let path = doc.locate(id).ok_or_else(|| {
961            err_data(
962                ErrorCode::BlockMissing,
963                format!("block {id} not found"),
964                json!({ "op_index": op_index, "block": id }),
965            )
966        })?;
967        paths.push(path);
968    }
969    let (first_index, parent) = paths[0].split_last().expect("non-empty");
970    let parent = parent.to_vec();
971    let kind = doc.block(&paths[0]).kind.clone();
972    let mut indices = vec![*first_index];
973    for (i, path) in paths.iter().enumerate() {
974        let (index, p) = path.split_last().expect("non-empty");
975        if p != parent.as_slice() {
976            return Err(err_data(
977                ErrorCode::NotContiguous,
978                "merge blocks must share a parent",
979                json!({ "op_index": op_index }),
980            ));
981        }
982        if doc.block(path).kind != kind {
983            return Err(err_data(
984                ErrorCode::TypeMismatch,
985                "merge blocks must share a type",
986                json!({ "op_index": op_index }),
987            ));
988        }
989        if let Some(e) = expect_per.and_then(|m| m.get(&block_ids[i])) {
990            check_content_hash(doc.block(path), Some(e), op_index)?;
991            // §1.2: its current parent (the shared sibling list).
992            check_children_hash(doc.siblings(p), Some(e), op_index)?;
993        }
994        if i > 0 {
995            indices.push(*index);
996        }
997    }
998    indices.sort_unstable();
999    if indices.windows(2).any(|w| w[1] != w[0] + 1) {
1000        return Err(err_data(
1001            ErrorCode::NotContiguous,
1002            "merge blocks must be contiguous",
1003            json!({ "op_index": op_index }),
1004        ));
1005    }
1006    let siblings = doc.siblings_mut(&parent);
1007    let raws: Vec<String> = indices.iter().map(|&i| siblings[i].raw.clone()).collect();
1008    let first = indices[0];
1009    siblings[first].raw = raws.join(separator);
1010    siblings[first].dirty = true;
1011    let merged_into: Vec<String> = indices[1..]
1012        .iter()
1013        .map(|&i| siblings[i].id.clone())
1014        .collect();
1015    for &i in indices[1..].iter().rev() {
1016        siblings.remove(i);
1017    }
1018    Ok(OpResult {
1019        ids: vec![siblings[first].id.clone()],
1020        removed: None,
1021        merged_into: Some(merged_into),
1022    })
1023}
1024
1025// ---- cross-document move (§2.3) ------------------------------------------------------
1026
1027fn resolve_dst_target(dst: &MutDoc, to: &To, op_index: usize) -> Result<Target> {
1028    match &to.parent {
1029        Parent::Doc | Parent::Section { .. } => Ok(Target {
1030            parent: Vec::new(),
1031            index: resolve_index(&dst.children, &to.at)?,
1032        }),
1033        Parent::Block(id) => {
1034            let path = dst.locate(id).ok_or_else(|| {
1035                err(
1036                    ErrorCode::ParentMissing,
1037                    format!("parent {id} not found in dest"),
1038                )
1039            })?;
1040            require_container(dst, &path, op_index)?;
1041            let index = resolve_index(&dst.block(&path).children, &to.at)?;
1042            Ok(Target {
1043                parent: path,
1044                index,
1045            })
1046        }
1047    }
1048}
1049
1050/// §2.3 cross-document: extract the blocks from `src`, insert them at the
1051/// destination's resolved target in `dst`; both owners marked dirty, both
1052/// documents' top-level seams healed.
1053pub fn cross_doc_move(
1054    src: &mut MutDoc,
1055    dst: &mut MutDoc,
1056    block_ids: &[String],
1057    to: &To,
1058    op_index: usize,
1059    expect: Option<&Expect>,
1060) -> Result<OpResult> {
1061    for id in block_ids {
1062        if !src.contains(id) {
1063            return Err(err_data(
1064                ErrorCode::BlockMissing,
1065                format!("block {id} not found in source doc"),
1066                json!({ "op_index": op_index }),
1067            ));
1068        }
1069    }
1070    // The destination resolves before either tree moves (a leaf parent is
1071    // §1.1 `type_mismatch`), then §1.2: its order CAS.
1072    let Target { parent, .. } = resolve_dst_target(dst, to, op_index)?;
1073    check_children_hash(dst.siblings(&parent), expect, op_index)?;
1074    let mut moving = Vec::with_capacity(block_ids.len());
1075    for id in block_ids {
1076        if let Some(path) = src.locate(id) {
1077            let (index, parent) = path.split_last().expect("non-empty");
1078            moving.push(src.siblings_mut(parent).remove(*index));
1079            src.mark_container_dirty(parent);
1080        }
1081    }
1082    let Target { parent, index } = resolve_dst_target(dst, to, op_index)?;
1083    {
1084        let siblings = dst.siblings_mut(&parent);
1085        let tail = siblings.split_off(index);
1086        siblings.extend(moving);
1087        siblings.extend(tail);
1088    }
1089    dst.mark_container_dirty(&parent);
1090    // Both documents' top-level seams heal: a block that was last in its
1091    // source carries a lone "\n", and the source's new last block may now be
1092    // followed by nothing.
1093    heal_top_level_seams(src);
1094    heal_top_level_seams(dst);
1095    Ok(OpResult::ids(block_ids.to_vec()))
1096}
1097
1098/// The six ops as methods (two-phase borrows let a caller compute ids from
1099/// the document inside the argument list).
1100impl MutDoc {
1101    /// [`op_insert`] without the §1.2 order CAS (`op_index` 0).
1102    pub fn insert(&mut self, to: &To, markdown: &str, minter: &mut dyn Minter) -> Result<OpResult> {
1103        op_insert(self, to, markdown, 0, None, minter)
1104    }
1105
1106    /// [`op_update`].
1107    pub fn update(
1108        &mut self,
1109        block_id: &str,
1110        op_index: usize,
1111        args: &UpdateArgs,
1112        minter: &mut dyn Minter,
1113    ) -> Result<OpResult> {
1114        op_update(self, block_id, op_index, args, minter)
1115    }
1116
1117    /// [`op_move`] without the §1.2 order CAS.
1118    pub fn move_blocks(
1119        &mut self,
1120        block_ids: &[String],
1121        to: &To,
1122        op_index: usize,
1123    ) -> Result<OpResult> {
1124        op_move(self, block_ids, to, op_index, None)
1125    }
1126
1127    /// [`op_remove`].
1128    pub fn remove(
1129        &mut self,
1130        block_ids: &[String],
1131        op_index: usize,
1132        expect_per: Option<&BTreeMap<String, Expect>>,
1133    ) -> Result<OpResult> {
1134        op_remove(self, block_ids, op_index, expect_per)
1135    }
1136
1137    /// [`op_split`].
1138    pub fn split(
1139        &mut self,
1140        block_id: &str,
1141        at: &[usize],
1142        op_index: usize,
1143        expect: Option<&Expect>,
1144        minter: &mut dyn Minter,
1145    ) -> Result<OpResult> {
1146        op_split(self, block_id, at, op_index, expect, minter)
1147    }
1148
1149    /// [`op_merge`].
1150    pub fn merge(
1151        &mut self,
1152        block_ids: &[String],
1153        op_index: usize,
1154        separator: Option<&str>,
1155        expect_per: Option<&BTreeMap<String, Expect>>,
1156    ) -> Result<OpResult> {
1157        op_merge(self, block_ids, op_index, separator, expect_per)
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use crate::render::render;
1165    use omgbase_reconcile::SequentialMinter;
1166
1167    /// A document from Markdown, ids `b_0…` in pre-order (mirrors the
1168    /// reference's test helper).
1169    fn doc(markdown: &str) -> (MutDoc, SequentialMinter) {
1170        let mut m = SequentialMinter::new("b");
1171        let tree = parse_markdown(markdown);
1172        let children = tree
1173            .children
1174            .iter()
1175            .filter(|b| b.kind != BlockKind::Frontmatter)
1176            .map(|b| {
1177                let mut mb = to_mut(b, "", &mut m);
1178                fn keep_trivia(b: &mut MutBlock, src: &Block) {
1179                    b.trivia = src.trivia.clone();
1180                    for (c, s) in b.children.iter_mut().zip(&src.children) {
1181                        keep_trivia(c, s);
1182                    }
1183                }
1184                keep_trivia(&mut mb, b);
1185                mb
1186            })
1187            .collect();
1188        let mut d = MutDoc::new("d_1", "a.md", children);
1189        d.leading_trivia = tree.leading_trivia;
1190        (d, m)
1191    }
1192
1193    fn id_at(d: &MutDoc, i: usize) -> String {
1194        d.children[i].id.clone()
1195    }
1196
1197    fn hash_at(d: &MutDoc, i: usize) -> Expect {
1198        Expect::content(raw_hash_hex(&d.children[i].raw))
1199    }
1200
1201    fn top(at: At) -> To {
1202        To {
1203            parent: Parent::Doc,
1204            at,
1205        }
1206    }
1207
1208    #[test]
1209    fn insert_adds_blocks_and_returns_minted_ids() {
1210        let (mut d, mut m) = doc("# Title\n\nTail.\n");
1211        let r = d
1212            .insert(&top(At::After(id_at(&d, 0))), "Inserted paragraph.", &mut m)
1213            .unwrap();
1214        assert_eq!(r.ids, ["b_2"]);
1215        assert_eq!(d.children[1].id, "b_2");
1216        assert_eq!(render(&d), "# Title\n\nInserted paragraph.\n\nTail.\n");
1217    }
1218
1219    #[test]
1220    fn insert_heals_seams_at_the_top_level() {
1221        let (mut d, mut m) = doc("# H\n\nbody");
1222        d.insert(&top(At::After(id_at(&d, 1))), "new para", &mut m)
1223            .unwrap();
1224        assert_eq!(render(&d), "# H\n\nbody\n\nnew para\n");
1225        let (mut d, mut m) = doc("# H\n\nfirst\n\nsecond\n");
1226        d.insert(
1227            &top(At::After(id_at(&d, 1))),
1228            "## Mid\n\nmid body\n",
1229            &mut m,
1230        )
1231        .unwrap();
1232        assert_eq!(render(&d), "# H\n\nfirst\n\n## Mid\n\nmid body\n\nsecond\n");
1233        // An empty parse inserts nothing.
1234        let (mut d, mut m) = doc("a\n");
1235        let r = d.insert(&top(At::End), "", &mut m).unwrap();
1236        assert!(r.ids.is_empty());
1237        // The seam before the (empty) run is still healed, as the reference does.
1238        assert_eq!(render(&d), "a\n\n");
1239    }
1240
1241    #[test]
1242    fn insert_into_a_list_unwraps_items_and_wraps_bare_blocks() {
1243        let (mut d, mut m) = doc("# T\n\n- one\n- two\n");
1244        let list = id_at(&d, 1);
1245        let two = d.children[1].children[1].id.clone();
1246        let r = d
1247            .insert(
1248                &To {
1249                    parent: Parent::Block(list.clone()),
1250                    at: At::After(two),
1251                },
1252                "- three",
1253                &mut m,
1254            )
1255            .unwrap();
1256        // The list wrapper minted b_4 (unused); its item is b_5 (§10).
1257        assert_eq!(r.ids, ["b_5"]);
1258        assert_eq!(render(&d), "# T\n\n- one\n- two\n- three\n");
1259        let r = d
1260            .insert(
1261                &To {
1262                    parent: Parent::Block(list),
1263                    at: At::Start,
1264                },
1265                "zero",
1266                &mut m,
1267            )
1268            .unwrap();
1269        assert_eq!(r.ids, ["b_7"], "paragraph b_6, wrapper item b_7");
1270        assert_eq!(render(&d), "# T\n\n- zero\n- one\n- two\n- three\n");
1271    }
1272
1273    #[test]
1274    fn insert_errors() {
1275        let (mut d, mut m) = doc("a\n");
1276        let e = d
1277            .insert(
1278                &To {
1279                    parent: Parent::Block("nope".into()),
1280                    at: At::End,
1281                },
1282                "x",
1283                &mut m,
1284            )
1285            .unwrap_err();
1286        assert_eq!(e.code, ErrorCode::ParentMissing);
1287        let e = d
1288            .insert(&top(At::Before("nope".into())), "x", &mut m)
1289            .unwrap_err();
1290        assert_eq!(e.code, ErrorCode::TargetMissing);
1291        let e = d
1292            .insert(
1293                &To {
1294                    parent: Parent::Section {
1295                        heading: "nope".into(),
1296                    },
1297                    at: At::End,
1298                },
1299                "x",
1300                &mut m,
1301            )
1302            .unwrap_err();
1303        assert_eq!(e.code, ErrorCode::TargetMissing);
1304    }
1305
1306    #[test]
1307    fn section_placement() {
1308        let (mut d, mut m) = doc("# A\n\na1\n\n## B\n\nb1\n\n# C\n\nc1\n");
1309        let a = id_at(&d, 0);
1310        d.insert(
1311            &To {
1312                parent: Parent::Section { heading: a.clone() },
1313                at: At::End,
1314            },
1315            "a-end",
1316            &mut m,
1317        )
1318        .unwrap();
1319        assert_eq!(
1320            render(&d),
1321            "# A\n\na1\n\n## B\n\nb1\n\na-end\n\n# C\n\nc1\n"
1322        );
1323        d.insert(
1324            &To {
1325                parent: Parent::Section { heading: a },
1326                at: At::Start,
1327            },
1328            "a-start",
1329            &mut m,
1330        )
1331        .unwrap();
1332        assert!(render(&d).starts_with("# A\n\na-start\n\na1\n"));
1333    }
1334
1335    #[test]
1336    fn update_requires_cas_and_replaces_in_place() {
1337        let (mut d, mut m) = doc("# Title\n\nOld body.\n");
1338        let b = id_at(&d, 1);
1339        let e = d
1340            .update(
1341                &b,
1342                0,
1343                &UpdateArgs {
1344                    markdown: Some("New body.".into()),
1345                    ..Default::default()
1346                },
1347                &mut m,
1348            )
1349            .unwrap_err();
1350        assert_eq!(e.code, ErrorCode::StaleExpectation);
1351        assert_eq!(e.data["retriable"], json!(true));
1352        assert_eq!(e.data["current"]["markdown"], json!("Old body."));
1353        d.update(
1354            &b,
1355            0,
1356            &UpdateArgs {
1357                markdown: Some("New body.".into()),
1358                expect: Some(hash_at(&d, 1)),
1359                ..Default::default()
1360            },
1361            &mut m,
1362        )
1363        .unwrap();
1364        assert_eq!(d.children[1].raw, "New body.");
1365        assert_eq!(d.children[1].id, b);
1366        assert_eq!(render(&d), "# Title\n\nNew body.\n");
1367    }
1368
1369    #[test]
1370    fn update_rejects_a_stale_hash_with_current_truth() {
1371        let (mut d, mut m) = doc("para one\n");
1372        let e = d
1373            .update(
1374                &id_at(&d, 0),
1375                3,
1376                &UpdateArgs {
1377                    markdown: Some("x".into()),
1378                    expect: Some(Expect::content("deadbeef")),
1379                    ..Default::default()
1380                },
1381                &mut m,
1382            )
1383            .unwrap_err();
1384        assert_eq!(e.code, ErrorCode::StaleExpectation);
1385        assert_eq!(e.data["op_index"], json!(3));
1386        assert_eq!(e.data["expected_content_hash"], json!("deadbeef"));
1387        assert_eq!(e.data["current"]["markdown"], json!("para one"));
1388        let e = d
1389            .update("b_9", 1, &UpdateArgs::default(), &mut m)
1390            .unwrap_err();
1391        assert_eq!(e.code, ErrorCode::BlockMissing);
1392        assert_eq!(e.data["block"], json!("b_9"));
1393    }
1394
1395    #[test]
1396    fn update_with_multi_block_content_adds_siblings() {
1397        let (mut d, mut m) = doc("# Title\n\nOld body.\n\nTail.\n");
1398        let b = id_at(&d, 1);
1399        let tail = id_at(&d, 2);
1400        let r = d
1401            .update(
1402                &b,
1403                0,
1404                &UpdateArgs {
1405                    markdown: Some("New body.\n\n- one\n- two\n\n## Sub".into()),
1406                    expect: Some(hash_at(&d, 1)),
1407                    ..Default::default()
1408                },
1409                &mut m,
1410            )
1411            .unwrap();
1412        assert_eq!(r.ids.len(), 3);
1413        assert_eq!(r.ids[0], b);
1414        let ids: Vec<&str> = d.children.iter().map(|b| b.id.as_str()).collect();
1415        assert_eq!(ids, ["b_0", "b_1", "b_4", "b_7", "b_2"]);
1416        assert_eq!(ids[4], tail);
1417        let kinds: Vec<&str> = d.children.iter().map(|b| b.kind.as_str()).collect();
1418        assert_eq!(
1419            kinds,
1420            ["heading", "paragraph", "list", "heading", "paragraph"]
1421        );
1422        assert_eq!(
1423            render(&d),
1424            "# Title\n\nNew body.\n\n- one\n- two\n\n## Sub\n\nTail.\n"
1425        );
1426    }
1427
1428    #[test]
1429    fn update_of_a_list_item() {
1430        let (mut d, mut m) = doc("- a\n- c\n");
1431        let a = d.children[0].children[0].id.clone();
1432        let c = d.children[0].children[1].id.clone();
1433        let r = d
1434            .update(
1435                &a,
1436                0,
1437                &UpdateArgs {
1438                    markdown: Some("- a1\n- b".into()),
1439                    expect: Some(Expect::content(raw_hash_hex("- a"))),
1440                    ..Default::default()
1441                },
1442                &mut m,
1443            )
1444            .unwrap();
1445        assert_eq!(r.ids.len(), 2);
1446        assert_eq!(r.ids[0], a);
1447        let ids: Vec<&str> = d.children[0]
1448            .children
1449            .iter()
1450            .map(|b| b.id.as_str())
1451            .collect();
1452        assert_eq!(ids, [a.as_str(), r.ids[1].as_str(), c.as_str()]);
1453        assert_eq!(render(&d), "- a1\n- b\n- c\n");
1454        // A bare block keeps the item a bullet.
1455        let r = d
1456            .update(
1457                &c,
1458                1,
1459                &UpdateArgs {
1460                    markdown: Some("c edited".into()),
1461                    expect: Some(Expect::content(raw_hash_hex("- c"))),
1462                    ..Default::default()
1463                },
1464                &mut m,
1465            )
1466            .unwrap();
1467        assert_eq!(r.ids, std::slice::from_ref(&c));
1468        assert_eq!(render(&d), "- a1\n- b\n- c edited\n");
1469        // Two blocks are a type_mismatch for an item.
1470        let e = d
1471            .update(
1472                &c,
1473                2,
1474                &UpdateArgs {
1475                    markdown: Some("x\n\ny".into()),
1476                    expect: Some(Expect::content(raw_hash_hex("- c edited"))),
1477                    ..Default::default()
1478                },
1479                &mut m,
1480            )
1481            .unwrap_err();
1482        assert_eq!(e.code, ErrorCode::TypeMismatch);
1483        assert_eq!(e.data["op_index"], json!(2));
1484    }
1485
1486    #[test]
1487    fn update_attrs_checked_retypes_to_task() {
1488        let (mut d, mut m) = doc("- [ ] do the thing\n- plain\n");
1489        let item = d.children[0].children[0].id.clone();
1490        let mut attrs = Map::new();
1491        attrs.insert("checked".into(), json!(true));
1492        d.update(
1493            &item,
1494            0,
1495            &UpdateArgs {
1496                attrs: Some(attrs.clone()),
1497                expect: Some(Expect::content(raw_hash_hex("- [ ] do the thing"))),
1498                ..Default::default()
1499            },
1500            &mut m,
1501        )
1502        .unwrap();
1503        assert_eq!(render(&d), "- [x] do the thing\n- plain\n");
1504        let plain = d.children[0].children[1].id.clone();
1505        d.update(
1506            &plain,
1507            1,
1508            &UpdateArgs {
1509                attrs: Some(attrs),
1510                ..Default::default()
1511            },
1512            &mut m,
1513        )
1514        .unwrap();
1515        assert_eq!(d.children[0].children[1].kind, "task");
1516        assert_eq!(d.children[0].children[1].attrs["checked"], json!(true));
1517        assert_eq!(render(&d), "- [x] do the thing\n- plain\n");
1518    }
1519
1520    #[test]
1521    fn update_trivia_and_child_ids() {
1522        let (mut d, mut m) = doc("# T\n\n- one\n- two\n");
1523        let list = id_at(&d, 1);
1524        let one = d.children[1].children[0].id.clone();
1525        let mut child_ids = BTreeMap::new();
1526        child_ids.insert("/0".to_owned(), one.clone());
1527        d.update(
1528            &list,
1529            0,
1530            &UpdateArgs {
1531                markdown: Some("- one\n- two changed".into()),
1532                expect: Some(hash_at(&d, 1)),
1533                child_ids: Some(child_ids),
1534                trivia: Some("\n\n\n".into()),
1535                ..Default::default()
1536            },
1537            &mut m,
1538        )
1539        .unwrap();
1540        assert_eq!(d.children[1].children[0].id, one);
1541        assert_ne!(d.children[1].children[1].id, "b_3");
1542        assert_eq!(render(&d), "# T\n\n- one\n- two changed\n\n\n");
1543        let e = d
1544            .update(
1545                &list,
1546                1,
1547                &UpdateArgs {
1548                    markdown: Some("a\n\nb".into()),
1549                    expect: Some(Expect::content(raw_hash_hex("- one\n- two changed"))),
1550                    child_ids: Some(BTreeMap::new()),
1551                    ..Default::default()
1552                },
1553                &mut m,
1554            )
1555            .unwrap_err();
1556        assert_eq!(e.code, ErrorCode::TypeMismatch);
1557        let e = d
1558            .update(
1559                &list,
1560                1,
1561                &UpdateArgs {
1562                    markdown: Some("".into()),
1563                    expect: Some(Expect::content(raw_hash_hex("- one\n- two changed"))),
1564                    ..Default::default()
1565                },
1566                &mut m,
1567            )
1568            .unwrap_err();
1569        assert_eq!(e.code, ErrorCode::TypeMismatch);
1570    }
1571
1572    #[test]
1573    fn nested_updates_rebuild_blockquote_and_table() {
1574        let (mut d, mut m) =
1575            doc("> first para\n>\n> second para\n\n| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n");
1576        let inner = d.children[0].children[1].clone();
1577        d.update(
1578            &inner.id,
1579            0,
1580            &UpdateArgs {
1581                markdown: Some("second para edited".into()),
1582                expect: Some(Expect::content(raw_hash_hex(&inner.raw))),
1583                ..Default::default()
1584            },
1585            &mut m,
1586        )
1587        .unwrap();
1588        let row = d.children[1].children[2].clone();
1589        d.update(
1590            &row.id,
1591            1,
1592            &UpdateArgs {
1593                markdown: Some("| 3 | 40 |".into()),
1594                expect: Some(Expect::content(raw_hash_hex(&row.raw))),
1595                ..Default::default()
1596            },
1597            &mut m,
1598        )
1599        .unwrap();
1600        assert_eq!(
1601            render(&d),
1602            "> first para\n>\n> second para edited\n\n| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 40 |\n"
1603        );
1604    }
1605
1606    #[test]
1607    fn whole_blockquote_and_table_updates_render_verbatim() {
1608        let (mut d, mut m) = doc("# H\n\n> old quote\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
1609        d.update(
1610            &id_at(&d, 1),
1611            0,
1612            &UpdateArgs {
1613                markdown: Some("> new quote\n> more".into()),
1614                expect: Some(hash_at(&d, 1)),
1615                ..Default::default()
1616            },
1617            &mut m,
1618        )
1619        .unwrap();
1620        d.update(
1621            &id_at(&d, 2),
1622            1,
1623            &UpdateArgs {
1624                markdown: Some("| c | d |\n|---|---|\n| 3 | 4 |".into()),
1625                expect: Some(hash_at(&d, 2)),
1626                ..Default::default()
1627            },
1628            &mut m,
1629        )
1630        .unwrap();
1631        assert_eq!(
1632            render(&d),
1633            "# H\n\n> new quote\n> more\n\n| c | d |\n|---|---|\n| 3 | 4 |\n"
1634        );
1635    }
1636
1637    #[test]
1638    fn move_relocates_a_run_and_heals_seams() {
1639        let (mut d, _) = doc("# A\n\nfirst\n\nsecond\n\n## B\n");
1640        let first = id_at(&d, 1);
1641        d.move_blocks(std::slice::from_ref(&first), &top(At::End), 0)
1642            .unwrap();
1643        assert_eq!(d.children.last().unwrap().id, first);
1644        // Every non-last seam is healed; the moved block keeps its own trivia.
1645        assert_eq!(render(&d), "# A\n\nsecond\n\n## B\n\nfirst\n\n");
1646        let (mut d, _) = doc("a\n\nb\n\nc\n");
1647        let e = d
1648            .move_blocks(&[id_at(&d, 0), id_at(&d, 2)], &top(At::Start), 0)
1649            .unwrap_err();
1650        assert_eq!(e.code, ErrorCode::NotContiguous);
1651        let e = d.move_blocks(&[], &top(At::Start), 0).unwrap_err();
1652        assert_eq!(e.code, ErrorCode::NotContiguous);
1653        let e = d
1654            .move_blocks(&["zz".into()], &top(At::Start), 4)
1655            .unwrap_err();
1656        assert_eq!(e.code, ErrorCode::BlockMissing);
1657        assert_eq!(e.data["op_index"], json!(4));
1658    }
1659
1660    #[test]
1661    fn move_rejects_a_cycle() {
1662        let (mut d, _) = doc("- parent\n  - child\n");
1663        let list = id_at(&d, 0);
1664        let item = d.children[0].children[0].id.clone();
1665        let e = d
1666            .move_blocks(
1667                &[list],
1668                &To {
1669                    parent: Parent::Block(item),
1670                    at: At::End,
1671                },
1672                0,
1673            )
1674            .unwrap_err();
1675        assert_eq!(e.code, ErrorCode::CycleMove);
1676    }
1677
1678    #[test]
1679    fn insert_into_a_leaf_is_type_mismatch() {
1680        // §1.1 (1.2): before, the block landed in the heading's empty
1681        // children and the heading's own text was lost on re-render.
1682        let (mut d, mut m) = doc("## Risks\n\nBody.\n");
1683        let heading = id_at(&d, 0);
1684        let e = d
1685            .insert(
1686                &To {
1687                    parent: Parent::Block(heading.clone()),
1688                    at: At::End,
1689                },
1690                "Under risks.",
1691                &mut m,
1692            )
1693            .unwrap_err();
1694        assert_eq!(e.code, ErrorCode::TypeMismatch);
1695        assert_eq!(
1696            e.message,
1697            format!(
1698                "{heading} is a heading, not a container; place relative to it with at.before/at.after or append to its section"
1699            )
1700        );
1701        assert_eq!(
1702            Value::Object(e.data),
1703            json!({ "op_index": 0, "block": heading, "type": "heading" })
1704        );
1705        assert_eq!(render(&d), "## Risks\n\nBody.\n");
1706        // A container parent still takes the insert.
1707        let (mut d, mut m) = doc("> quoted\n");
1708        let quote = id_at(&d, 0);
1709        d.insert(
1710            &To {
1711                parent: Parent::Block(quote),
1712                at: At::End,
1713            },
1714            "more",
1715            &mut m,
1716        )
1717        .unwrap();
1718        assert_eq!(d.children[0].children.len(), 2);
1719    }
1720
1721    #[test]
1722    fn move_into_a_leaf_is_type_mismatch_before_the_tree_moves() {
1723        let (mut d, _) = doc("Para.\n\n- item\n");
1724        let para = id_at(&d, 0);
1725        let item = d.children[1].children[0].id.clone();
1726        let e = d
1727            .move_blocks(
1728                &[item],
1729                &To {
1730                    parent: Parent::Block(para.clone()),
1731                    at: At::Start,
1732                },
1733                3,
1734            )
1735            .unwrap_err();
1736        assert_eq!(e.code, ErrorCode::TypeMismatch);
1737        assert_eq!(
1738            Value::Object(e.data),
1739            json!({ "op_index": 3, "block": para, "type": "paragraph" })
1740        );
1741        // Nothing was extracted: the source list still has its item.
1742        assert_eq!(render(&d), "Para.\n\n- item\n");
1743    }
1744
1745    #[test]
1746    fn cross_doc_move_into_a_leaf_is_type_mismatch() {
1747        let (mut src, _) = doc("- item\n");
1748        let (mut dst, _) = doc("# Title\n\n- other\n");
1749        let item = src.children[0].children[0].id.clone();
1750        let title = id_at(&dst, 0);
1751        let e = cross_doc_move(
1752            &mut src,
1753            &mut dst,
1754            std::slice::from_ref(&item),
1755            &To {
1756                parent: Parent::Block(title.clone()),
1757                at: At::End,
1758            },
1759            1,
1760            None,
1761        )
1762        .unwrap_err();
1763        assert_eq!(e.code, ErrorCode::TypeMismatch);
1764        assert_eq!(
1765            Value::Object(e.data),
1766            json!({ "op_index": 1, "block": title, "type": "heading" })
1767        );
1768        assert_eq!(render(&src), "- item\n");
1769        assert_eq!(render(&dst), "# Title\n\n- other\n");
1770        // The list in `dst` is a container: the same move lands.
1771        let list = id_at(&dst, 1);
1772        cross_doc_move(
1773            &mut src,
1774            &mut dst,
1775            &[item],
1776            &To {
1777                parent: Parent::Block(list),
1778                at: At::End,
1779            },
1780            1,
1781            None,
1782        )
1783        .unwrap();
1784        assert_eq!(render(&dst), "# Title\n\n- other\n- item\n");
1785    }
1786
1787    #[test]
1788    fn container_kinds_by_format() {
1789        // Markdown: the format layer's kind table decides, children or not.
1790        let (d, _) = doc(
1791            "# H\n\npara\n\n- [ ] task\n\n> quote\n\n| a |\n| - |\n| 1 |\n\n```\ncode\n```\n\n---\n\n<div></div>\n",
1792        );
1793        let verdicts: Vec<(&str, bool)> = d
1794            .children
1795            .iter()
1796            .map(|b| (b.kind.as_str(), is_container("markdown", b)))
1797            .collect();
1798        assert_eq!(
1799            verdicts,
1800            [
1801                ("heading", false),
1802                ("paragraph", false),
1803                ("list", true),
1804                ("blockquote", true),
1805                ("table", true),
1806                ("code_fence", false),
1807                ("thematic_break", false),
1808                ("html_block", false),
1809            ]
1810        );
1811        let list = &d.children[2];
1812        assert!(is_container("markdown", &list.children[0])); // task
1813        assert!(!is_container("markdown", &d.children[4].children[0])); // table_row
1814        let empty_quote = MutBlock::new("b_q", "blockquote", ">", "\n\n");
1815        assert!(is_container("markdown", &empty_quote));
1816        // Other formats have no kind table: a block with children is a
1817        // container, a childless one is a leaf — whatever its kind says.
1818        let leaf = MutBlock::new("b_1", "opaque", "k: v", "\n");
1819        assert!(!is_container("yaml", &leaf));
1820        let mut node = MutBlock::new("b_2", "opaque", "k:", "\n");
1821        node.children
1822            .push(MutBlock::new("b_3", "opaque", "  a: 1", "\n"));
1823        assert!(is_container("yaml", &node));
1824        assert!(is_container("json", &node));
1825        assert!(!is_container("markdown", &node));
1826    }
1827
1828    #[test]
1829    fn move_within_a_list_renumbers() {
1830        let (mut d, _) = doc("# T\n\n1. alpha\n2. bravo\n3. gamma\n");
1831        let list = id_at(&d, 1);
1832        let alpha = d.children[1].children[0].id.clone();
1833        let gamma = d.children[1].children[2].id.clone();
1834        d.move_blocks(
1835            &[gamma],
1836            &To {
1837                parent: Parent::Block(list),
1838                at: At::Before(alpha),
1839            },
1840            0,
1841        )
1842        .unwrap();
1843        assert_eq!(render(&d), "# T\n\n1. gamma\n2. alpha\n3. bravo\n");
1844    }
1845
1846    #[test]
1847    fn remove_collapses_to_tops_and_prunes_empty_containers() {
1848        let (mut d, _) = doc("# A\n\ndoomed\n\ntail\n");
1849        let r = d.remove(&[id_at(&d, 1)], 0, None).unwrap();
1850        assert_eq!(r.removed, Some(vec!["b_1".to_owned()]));
1851        assert_eq!(render(&d), "# A\n\ntail\n");
1852        let (mut d, _) = doc("# T\n\n- only\n");
1853        let only = d.children[1].children[0].id.clone();
1854        let r = d.remove(&[only.clone(), only.clone()], 0, None).unwrap();
1855        assert_eq!(r.ids.len(), 2);
1856        assert_eq!(render(&d), "# T\n\n");
1857        let (mut d, _) = doc("- a\n  - b\n");
1858        let list = id_at(&d, 0);
1859        let item = d.children[0].children[0].id.clone();
1860        let r = d.remove(&[item, list.clone()], 0, None).unwrap();
1861        assert!(r.removed.unwrap().len() >= 4);
1862        assert!(d.children.is_empty());
1863        let (mut d, _) = doc("# T\n\n- one\n- two\n- three\n");
1864        let two = d.children[1].children[1].id.clone();
1865        d.remove(&[two], 0, None).unwrap();
1866        assert_eq!(render(&d), "# T\n\n- one\n- three\n");
1867        let e = d.remove(&["zz".into()], 2, None).unwrap_err();
1868        assert_eq!(e.code, ErrorCode::BlockMissing);
1869        assert_eq!(e.data["op_index"], json!(2));
1870        let mut per = BTreeMap::new();
1871        per.insert(id_at(&d, 0), Expect::content("bad"));
1872        let e = d.remove(&[id_at(&d, 0)], 3, Some(&per)).unwrap_err();
1873        assert_eq!(e.code, ErrorCode::StaleExpectation);
1874    }
1875
1876    #[test]
1877    fn split_cuts_at_byte_offsets() {
1878        let (mut d, mut m) = doc("first sentence. second sentence.\n");
1879        let b = id_at(&d, 0);
1880        let cut = d.children[0].raw.find("second").unwrap();
1881        let r = d
1882            .split(&b, &[cut], 0, Some(&hash_at(&d, 0)), &mut m)
1883            .unwrap();
1884        assert_eq!(r.ids, [b.clone(), "b_1".to_owned()]);
1885        assert_eq!(d.children[0].raw, "first sentence. ");
1886        assert_eq!(d.children[1].raw, "second sentence.");
1887        assert_eq!(d.children[1].trivia, "\n");
1888        // The block's trailing trivia moves to the last piece; the seam separates.
1889        assert_eq!(render(&d), "first sentence. \n\nsecond sentence.\n");
1890        let (mut d, mut m) = doc("héllo wörld — café tail\n");
1891        d.split(&id_at(&d, 0), &[24], 0, Some(&hash_at(&d, 0)), &mut m)
1892            .unwrap();
1893        assert_eq!(d.children[0].raw, "héllo wörld — café ");
1894        assert_eq!(d.children[1].raw, "tail");
1895        let (mut d, mut m) = doc("aé b\n");
1896        d.split(&id_at(&d, 0), &[2], 0, Some(&hash_at(&d, 0)), &mut m)
1897            .unwrap();
1898        let raws: Vec<&str> = d.children.iter().map(|b| b.raw.as_str()).collect();
1899        assert_eq!(raws, ["a", "é b"]);
1900        let e = d
1901            .split("b_0", &[0, 99], 1, Some(&hash_at(&d, 0)), &mut m)
1902            .unwrap_err();
1903        assert_eq!(e.code, ErrorCode::TypeMismatch);
1904        let e = d.split("b_0", &[1], 1, None, &mut m).unwrap_err();
1905        assert_eq!(e.code, ErrorCode::StaleExpectation);
1906        let e = d.split("zz", &[1], 1, None, &mut m).unwrap_err();
1907        assert_eq!(e.code, ErrorCode::BlockMissing);
1908    }
1909
1910    #[test]
1911    fn merge_joins_contiguous_same_type_siblings() {
1912        let (mut d, _) = doc("alpha\n\nbeta\n");
1913        let first = id_at(&d, 0);
1914        let r = d
1915            .merge(&[id_at(&d, 0), id_at(&d, 1)], 0, None, None)
1916            .unwrap();
1917        assert_eq!(r.ids, [first]);
1918        assert_eq!(r.merged_into, Some(vec!["b_1".to_owned()]));
1919        assert_eq!(d.children.len(), 1);
1920        assert_eq!(d.children[0].raw, "alpha beta");
1921        let (mut d, _) = doc("# h\n\np\n\nq\n");
1922        let e = d
1923            .merge(&[id_at(&d, 0), id_at(&d, 1)], 0, None, None)
1924            .unwrap_err();
1925        assert_eq!(e.code, ErrorCode::TypeMismatch);
1926        let e = d.merge(&[id_at(&d, 1)], 0, None, None).unwrap_err();
1927        assert_eq!(e.code, ErrorCode::NotContiguous);
1928        let (mut d, _) = doc("a\n\nb\n\nc\n");
1929        let e = d
1930            .merge(&[id_at(&d, 0), id_at(&d, 2)], 0, None, None)
1931            .unwrap_err();
1932        assert_eq!(e.code, ErrorCode::NotContiguous);
1933        let r = d
1934            .merge(&[id_at(&d, 2), id_at(&d, 1)], 0, Some("\n"), None)
1935            .unwrap();
1936        assert_eq!(r.ids, ["b_1"]);
1937        assert_eq!(d.children[1].raw, "b\nc");
1938    }
1939
1940    #[test]
1941    fn cross_doc_move_extracts_and_inserts() {
1942        let (mut a, _) = doc("# A\n\nmoving\n");
1943        let (mut b, _) = doc("# B\n");
1944        let moving = id_at(&a, 1);
1945        let r = cross_doc_move(
1946            &mut a,
1947            &mut b,
1948            std::slice::from_ref(&moving),
1949            &top(At::End),
1950            0,
1951            None,
1952        )
1953        .unwrap();
1954        assert_eq!(r.ids, std::slice::from_ref(&moving));
1955        assert_eq!(render(&a), "# A\n\n");
1956        // Both documents' top-level seams heal (§2.3).
1957        assert_eq!(render(&b), "# B\n\nmoving\n");
1958        let e = cross_doc_move(&mut a, &mut b, &[moving], &top(At::End), 1, None).unwrap_err();
1959        assert_eq!(e.code, ErrorCode::BlockMissing);
1960    }
1961
1962    #[test]
1963    fn parent_children_hash_check() {
1964        let (d, _) = doc("a\n\nb\n");
1965        let ok = parent_children_hash(&d.children);
1966        check_parent_children_hash(&d, None, &ok, 0).unwrap();
1967        let e = check_parent_children_hash(&d, None, "nope", 0).unwrap_err();
1968        assert_eq!(e.code, ErrorCode::StaleExpectation);
1969        assert_eq!(e.data["current"]["parent_children_hash"], json!(ok));
1970    }
1971
1972    /// §1.2 wiring: insert/move check the destination parent, the others the
1973    /// block's current parent; content CAS first; a stale order never lands.
1974    #[test]
1975    fn parent_children_hash_guards_every_op() {
1976        let (mut d, mut m) = doc("# T\n\na\n\n> q1\n>\n> q2\n");
1977        let top_hash = parent_children_hash(&d.children);
1978        let quote_hash = parent_children_hash(&d.children[2].children);
1979        let a = id_at(&d, 1);
1980        let quote = id_at(&d, 2);
1981        let stale = Expect::order("stale");
1982        // insert: destination parent = the blockquote, not the top level.
1983        let into_quote = To {
1984            parent: Parent::Block(quote.clone()),
1985            at: At::End,
1986        };
1987        let e = op_insert(
1988            &mut d,
1989            &into_quote,
1990            "x",
1991            3,
1992            Some(&Expect::order(&top_hash)),
1993            &mut m,
1994        )
1995        .unwrap_err();
1996        assert_eq!(e.code, ErrorCode::StaleExpectation);
1997        assert_eq!(e.data["op_index"], json!(3));
1998        assert_eq!(e.data["current"]["parent_children_hash"], json!(quote_hash));
1999        assert_eq!(e.data.get("block"), None);
2000        assert_eq!(d.children[2].children.len(), 2, "nothing inserted");
2001        // move: destination checked on the pre-removal tree, once for the run.
2002        let e = op_move(
2003            &mut d,
2004            std::slice::from_ref(&a),
2005            &into_quote,
2006            0,
2007            Some(&stale),
2008        )
2009        .unwrap_err();
2010        assert_eq!(e.data["current"]["parent_children_hash"], json!(quote_hash));
2011        assert_eq!(d.children.len(), 3, "nothing moved");
2012        op_move(
2013            &mut d,
2014            std::slice::from_ref(&a),
2015            &into_quote,
2016            0,
2017            Some(&Expect::order(&quote_hash)),
2018        )
2019        .unwrap();
2020        assert_eq!(d.children[1].children.len(), 3);
2021        // update/split/remove/merge: the block's CURRENT parent (now the quote),
2022        // and the content CAS comes first.
2023        let mut args = UpdateArgs {
2024            expect: Some(Expect {
2025                content_hash: Some("bad".into()),
2026                parent_children_hash: Some("stale".into()),
2027            }),
2028            ..UpdateArgs::default()
2029        };
2030        let e = op_update(&mut d, &a, 0, &args, &mut m).unwrap_err();
2031        assert_eq!(e.data["block"], json!(a), "content CAS reported first");
2032        args.expect = Some(Expect {
2033            content_hash: Some(raw_hash_hex("a")),
2034            parent_children_hash: Some(top_hash.clone()),
2035        });
2036        let e = op_update(&mut d, &a, 0, &args, &mut m).unwrap_err();
2037        let live = parent_children_hash(&d.children[1].children);
2038        assert_eq!(e.data["current"]["parent_children_hash"], json!(live));
2039        let ok = Expect {
2040            content_hash: Some(raw_hash_hex("a")),
2041            parent_children_hash: Some(live.clone()),
2042        };
2043        let e = op_split(&mut d, &a, &[1], 0, Some(&stale_with(&ok)), &mut m).unwrap_err();
2044        assert_eq!(e.code, ErrorCode::StaleExpectation);
2045        let mut per = BTreeMap::new();
2046        per.insert(a.clone(), stale_with(&ok));
2047        let e = op_remove(&mut d, std::slice::from_ref(&a), 0, Some(&per)).unwrap_err();
2048        assert_eq!(e.data["current"]["parent_children_hash"], json!(live));
2049        let q2 = d.children[1].children[1].id.clone();
2050        let mut per = BTreeMap::new();
2051        per.insert(
2052            q2.clone(),
2053            Expect {
2054                content_hash: Some(raw_hash_hex("q2")),
2055                parent_children_hash: Some("stale".into()),
2056            },
2057        );
2058        let e = op_merge(&mut d, &[q2, a.clone()], 0, None, Some(&per)).unwrap_err();
2059        assert_eq!(e.data["current"]["parent_children_hash"], json!(live));
2060        // A matching order CAS passes.
2061        per.clear();
2062        per.insert(a.clone(), ok);
2063        op_remove(&mut d, &[a], 0, Some(&per)).unwrap();
2064        assert_eq!(d.children[1].children.len(), 2);
2065    }
2066
2067    fn stale_with(ok: &Expect) -> Expect {
2068        Expect {
2069            content_hash: ok.content_hash.clone(),
2070            parent_children_hash: Some("stale".into()),
2071        }
2072    }
2073}