1use 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#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum Parent {
20 Doc,
22 Block(String),
24 Section { heading: String },
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum At {
31 Start,
32 End,
33 Before(String),
34 After(String),
35}
36
37impl At {
38 #[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#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct To {
51 pub parent: Parent,
52 pub at: At,
53}
54
55#[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 #[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 #[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#[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 #[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#[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#[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
163pub 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#[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#[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
261fn 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
273fn 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
302fn 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
318fn 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
336pub 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
359pub 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
398pub 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
413fn 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
437fn 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
455fn 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
469pub 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 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#[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
540pub 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 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
688fn 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
696fn 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
707fn 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
720fn 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 return Ok((parent, Vec::new()));
751 }
752 Ok((parent, indices))
753}
754
755pub 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 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 let Target { parent: dst, .. } = resolve_target(doc, to, op_index)?;
796 check_children_hash(doc.siblings(&dst), expect, op_index)?;
800 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 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
827fn 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
832fn 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
847pub 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 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
897fn 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
906fn 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 if out.chars().all(is_js_whitespace) {
934 ""
935 } else {
936 out
937 }
938 })
939 .collect()
940}
941
942pub 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 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 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
1024pub 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 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
1103fn 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
1128pub 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 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 heal_top_level_seams(src);
1172 heal_top_level_seams(dst);
1173 Ok(OpResult::ids(block_ids.to_vec()))
1174}
1175
1176impl MutDoc {
1179 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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])); assert!(!is_container("markdown", &d.children[4].children[0])); let empty_quote = MutBlock::new("b_q", "blockquote", ">", "\n\n");
1893 assert!(is_container("markdown", &empty_quote));
1894 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 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 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 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 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 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 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 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 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 #[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 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 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("e_hash)),
2145 )
2146 .unwrap();
2147 assert_eq!(d.children[1].children.len(), 3);
2148 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 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}