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