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