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