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