1use std::collections::{BTreeMap, HashMap, HashSet};
7
8use omgbase_format::hash::sha256;
9use omgbase_format::{Block, parse_markdown};
10use omgbase_mutate::{
11 At, ErrorCode, MutBlock, MutDoc, MutationError, Op, OpResult, Parent, To, UpdateArgs,
12 cross_doc_move, op_insert, op_merge, op_move, op_remove, op_split, op_update, render,
13 resolve_op,
14};
15use omgbase_reconcile::Config;
16use rusqlite::{Connection, OptionalExtension, params};
17use serde_json::{Map, Value, json};
18
19use crate::Store;
20use crate::doc_store::DocStore;
21use crate::error::Result;
22use crate::ids::ALPHABET;
23use crate::observe::{DispositionRow, IngestPlan};
24use crate::read::blob_text;
25use crate::writers::{Origin, TreeInputBlock};
26
27#[must_use]
31pub fn is_id_ref(s: &str, prefix: &str) -> bool {
32 let Some((p, suffix)) = s.split_once('_') else {
33 return false;
34 };
35 p == prefix
36 && !p.is_empty()
37 && p.bytes().all(|b| b.is_ascii_lowercase())
38 && (1..=7).contains(&suffix.len())
39 && suffix.bytes().all(|b| ALPHABET.contains(&b))
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct DocInfo {
45 pub doc_id: String,
46 pub path: String,
47 pub current_rev: Option<String>,
48}
49
50pub fn find_doc_by_ref(conn: &Connection, repo_id: &str, r: &str) -> Result<Option<DocInfo>> {
53 let map = |row: &rusqlite::Row<'_>| {
54 Ok(DocInfo {
55 doc_id: row.get(0)?,
56 path: row.get(1)?,
57 current_rev: row.get(2)?,
58 })
59 };
60 let found = if is_id_ref(r, "d") {
63 conn.query_row(
64 "SELECT doc_id, path, current_rev FROM docs WHERE doc_id = ?1 AND deleted_commit IS NULL",
65 params![r],
66 map,
67 )
68 } else {
69 conn.query_row(
70 "SELECT doc_id, path, current_rev FROM docs WHERE path = ?1 AND repo_id = ?2 AND deleted_commit IS NULL",
71 params![r, repo_id],
72 map,
73 )
74 };
75 Ok(found.optional()?)
76}
77
78struct BlockRow {
81 block_id: String,
82 parent_block: Option<String>,
83 ordinal: i64,
84 kind: String,
85 attrs: String,
86 raw_hash: Vec<u8>,
87 trivia_hash: Option<Vec<u8>>,
88}
89
90pub fn load_mut_doc(conn: &Connection, doc_id: &str) -> Result<Option<MutDoc>> {
95 type DocRow = (String, String, String, Option<String>, Option<String>);
96 let doc: Option<DocRow> = conn
97 .query_row(
98 "SELECT path, format, leading_trivia, frontmatter_trivia, current_rev FROM docs WHERE doc_id = ?1 AND deleted_commit IS NULL",
99 params![doc_id],
100 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
101 )
102 .optional()?;
103 let Some((path, format, leading_trivia, frontmatter_trivia, current_rev)) = doc else {
104 return Ok(None);
105 };
106 let rows: Vec<BlockRow> = {
107 let mut stmt = conn.prepare(
108 "SELECT block_id, parent_block, ordinal, type, attrs, raw_hash, trivia_hash
109 FROM blocks WHERE doc_id = ?1 AND deleted_commit IS NULL
110 ORDER BY parent_block, ordinal",
111 )?;
112 let it = stmt.query_map(params![doc_id], |r| {
113 Ok(BlockRow {
114 block_id: r.get(0)?,
115 parent_block: r.get(1)?,
116 ordinal: r.get(2)?,
117 kind: r.get(3)?,
118 attrs: r.get(4)?,
119 raw_hash: r.get(5)?,
120 trivia_hash: r.get(6)?,
121 })
122 })?;
123 it.collect::<std::result::Result<Vec<_>, _>>()?
124 };
125 let ids: HashSet<&str> = rows.iter().map(|r| r.block_id.as_str()).collect();
126 let mut nodes: HashMap<String, MutBlock> = HashMap::with_capacity(rows.len());
127 for r in &rows {
128 let raw = blob_text(conn, &r.raw_hash)?;
129 let trivia = match &r.trivia_hash {
130 Some(h) => blob_text(conn, h)?,
131 None => String::new(),
132 };
133 let attrs = match serde_json::from_str::<Value>(&r.attrs) {
134 Ok(Value::Object(m)) => m,
135 _ => Map::new(),
136 };
137 nodes.insert(
138 r.block_id.clone(),
139 MutBlock {
140 id: r.block_id.clone(),
141 kind: r.kind.clone(),
142 raw,
143 trivia,
144 attrs,
145 children: Vec::new(),
146 dirty: false,
147 },
148 );
149 }
150 let mut ordered: Vec<&BlockRow> = rows.iter().collect();
152 ordered.sort_by_key(|r| r.ordinal);
153 let mut children_of: HashMap<&str, Vec<&str>> = HashMap::new();
154 let mut roots: Vec<&str> = Vec::new();
155 for r in &ordered {
156 match r.parent_block.as_deref().filter(|p| ids.contains(p)) {
157 Some(p) => children_of.entry(p).or_default().push(&r.block_id),
158 None => roots.push(&r.block_id),
159 }
160 }
161 fn build(
162 id: &str,
163 nodes: &mut HashMap<String, MutBlock>,
164 children_of: &HashMap<&str, Vec<&str>>,
165 ) -> MutBlock {
166 let mut b = nodes.remove(id).expect("every row was loaded");
167 if let Some(kids) = children_of.get(id) {
168 b.children = kids.iter().map(|k| build(k, nodes, children_of)).collect();
169 }
170 b
171 }
172 let children: Vec<MutBlock> = roots
173 .iter()
174 .map(|id| build(id, &mut nodes, &children_of))
175 .collect();
176
177 let mut frontmatter_raw = None;
178 if let Some(rev) = current_rev {
179 let fm: Option<Option<Vec<u8>>> = conn
180 .query_row(
181 "SELECT frontmatter_blob FROM revisions WHERE rev_id = ?1",
182 params![rev],
183 |r| r.get(0),
184 )
185 .optional()?;
186 if let Some(Some(hash)) = fm {
187 let exists: bool = conn
188 .query_row("SELECT 1 FROM blobs WHERE hash = ?1", params![hash], |_| {
189 Ok(())
190 })
191 .optional()?
192 .is_some();
193 if exists {
194 let mut raw = blob_text(conn, &hash)?;
195 raw.push_str(frontmatter_trivia.as_deref().unwrap_or(""));
196 frontmatter_raw = Some(raw);
197 }
198 }
199 }
200 Ok(Some(MutDoc {
201 doc_id: doc_id.to_owned(),
202 path,
203 format,
204 leading_trivia,
205 frontmatter_raw,
206 children,
207 }))
208}
209
210#[derive(Clone, Debug, PartialEq, Eq)]
214pub struct ApplyOrigin {
215 pub actor: String,
216 pub reason: Option<String>,
217}
218
219impl ApplyOrigin {
220 #[must_use]
221 pub fn new(actor: &str, reason: Option<&str>) -> Self {
222 Self {
223 actor: actor.to_owned(),
224 reason: reason.map(str::to_owned),
225 }
226 }
227}
228
229#[derive(Clone, Debug, PartialEq, Eq)]
232pub struct SetFrontmatter {
233 pub doc: String,
234 pub raw: Option<String>,
235}
236
237#[derive(Clone, Debug, PartialEq)]
239pub struct ApplyRequest {
240 pub repo_id: String,
241 pub ops: Vec<Op>,
242 pub origin: ApplyOrigin,
243 pub dry_run: bool,
244 pub set_frontmatter: Vec<SetFrontmatter>,
245}
246
247#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct Revision {
250 pub doc: String,
251 pub path: String,
252}
253
254#[derive(Clone, Debug, PartialEq, Eq)]
256pub struct Diff {
257 pub before: String,
258 pub after: String,
259}
260
261#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct ApplyResult {
264 pub results: Vec<OpResult>,
265 pub revisions: Vec<Revision>,
266 pub diffs: Option<Vec<(String, Diff)>>,
268 pub committed: bool,
269}
270
271impl ApplyResult {
272 #[must_use]
274 pub fn to_json(&self) -> Value {
275 let mut m = Map::new();
276 m.insert(
277 "results".to_owned(),
278 Value::Array(self.results.iter().map(OpResult::to_json).collect()),
279 );
280 m.insert(
281 "revisions".to_owned(),
282 Value::Array(
283 self.revisions
284 .iter()
285 .map(|r| json!({ "doc": r.doc, "path": r.path }))
286 .collect(),
287 ),
288 );
289 if let Some(diffs) = &self.diffs {
290 let mut d = Map::new();
291 for (path, diff) in diffs {
292 d.insert(
293 path.clone(),
294 json!({ "before": diff.before, "after": diff.after }),
295 );
296 }
297 m.insert("diffs".to_owned(), Value::Object(d));
298 }
299 m.insert("committed".to_owned(), json!(self.committed));
300 Value::Object(m)
301 }
302}
303
304struct LoadedDoc {
306 doc: MutDoc,
307 before: String,
309 prior_ids: Vec<String>,
311}
312
313fn merr(code: ErrorCode, msg: impl Into<String>) -> MutationError {
314 MutationError::new(code, msg)
315}
316
317fn merr_data(code: ErrorCode, msg: impl Into<String>, data: Value) -> MutationError {
318 MutationError::with_data(code, msg, data)
319}
320
321struct Applied {
323 loaded: Vec<LoadedDoc>,
324 results: Vec<OpResult>,
325}
326
327impl Store {
328 pub fn load_mut_doc(&self, doc_id: &str) -> Result<Option<MutDoc>> {
330 load_mut_doc(&self.conn, doc_id)
331 }
332
333 pub fn find_doc_by_ref(&self, repo_id: &str, r: &str) -> Result<Option<DocInfo>> {
335 find_doc_by_ref(&self.conn, repo_id, r)
336 }
337
338 fn doc_id_for_block(&self, block_id: &str) -> Result<Option<String>> {
340 Ok(self
341 .conn
342 .query_row(
343 "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
344 params![block_id],
345 |r| r.get(0),
346 )
347 .optional()?)
348 }
349
350 fn apply_ops(&mut self, req: &ApplyRequest) -> Result<Applied> {
352 let mut loaded: Vec<LoadedDoc> = Vec::new();
353 let mut results: Vec<OpResult> = Vec::new();
354
355 fn index_of(loaded: &[LoadedDoc], doc_id: &str) -> Option<usize> {
356 loaded.iter().position(|d| d.doc.doc_id == doc_id)
357 }
358 fn doc_id_for_block_loaded(loaded: &[LoadedDoc], block_id: &str) -> Option<String> {
359 loaded
360 .iter()
361 .find(|d| d.doc.contains(block_id))
362 .map(|d| d.doc.doc_id.clone())
363 }
364
365 macro_rules! ensure_doc {
366 ($doc_id:expr) => {{
367 let id: &str = $doc_id;
368 match index_of(&loaded, id) {
369 Some(i) => i,
370 None => {
371 let Some(doc) = load_mut_doc(&self.conn, id)? else {
372 return Err(merr(ErrorCode::DocMissing, format!("doc {id} not found")).into());
373 };
374 let before = render(&doc);
375 let prior_ids: Vec<String> = {
376 let mut stmt = self.conn.prepare(
377 "SELECT block_id FROM blocks WHERE doc_id = ?1 AND deleted_commit IS NULL",
378 )?;
379 let it = stmt.query_map(params![id], |r| r.get(0))?;
380 it.collect::<std::result::Result<Vec<_>, _>>()?
381 };
382 loaded.push(LoadedDoc {
383 doc,
384 before,
385 prior_ids,
386 });
387 loaded.len() - 1
388 }
389 }
390 }};
391 }
392 macro_rules! doc_for_block {
393 ($block:expr) => {{
394 let b: &str = $block;
395 match doc_id_for_block_loaded(&loaded, b) {
396 Some(id) => Some(id),
397 None => self.doc_id_for_block(b)?,
398 }
399 }};
400 }
401 macro_rules! parent_doc {
402 ($to:expr) => {{
403 let to: &To = $to;
404 let anchor: Option<&str> = match &to.parent {
405 Parent::Block(id) => Some(id.as_str()),
406 Parent::Section { heading } => Some(heading.as_str()),
407 Parent::Doc => to.at.anchor(),
408 };
409 match anchor {
410 Some(id) => match doc_for_block!(id) {
411 Some(d) => d,
412 None => {
413 return Err(merr(
414 ErrorCode::ParentMissing,
415 format!("parent {id} not found"),
416 )
417 .into());
418 }
419 },
420 None => {
421 return Err(merr(
422 ErrorCode::TargetMissing,
423 "insert at top-level start/end requires an explicit doc",
424 )
425 .into());
426 }
427 }
428 }};
429 }
430
431 for fm in &req.set_frontmatter {
433 let i = ensure_doc!(&fm.doc);
434 loaded[i].doc.frontmatter_raw = fm.raw.clone();
435 }
436
437 for (i, raw_op) in req.ops.iter().enumerate() {
438 let op = resolve_op(raw_op, &results)?;
439 match &op {
440 Op::Insert {
441 doc,
442 to,
443 markdown,
444 expect,
445 } => {
446 let doc_id = match doc {
447 Some(r) => self.resolve_doc_ref(&req.repo_id, r)?,
448 None => parent_doc!(to),
449 };
450 let d = ensure_doc!(&doc_id);
451 let mut mint = self.ids.at(&self.conn);
452 let mut minter = mint.deferred("b");
453 let result = op_insert(
454 &mut loaded[d].doc,
455 to,
456 markdown,
457 i,
458 expect.as_ref(),
459 &mut minter,
460 );
461 minter.finish()?;
462 results.push(result?);
463 }
464 Op::Update {
465 block,
466 markdown,
467 attrs,
468 expect,
469 trivia,
470 child_ids,
471 } => {
472 let Some(doc_id) = doc_for_block!(block) else {
473 return Err(merr_data(
474 ErrorCode::BlockMissing,
475 format!("block {block} not found"),
476 json!({ "op_index": i }),
477 )
478 .into());
479 };
480 let d = ensure_doc!(&doc_id);
481 let args = UpdateArgs {
482 markdown: markdown.clone(),
483 attrs: attrs.clone(),
484 expect: expect.clone(),
485 trivia: trivia.clone(),
486 child_ids: child_ids.clone(),
487 };
488 let mut mint = self.ids.at(&self.conn);
489 let mut minter = mint.deferred("b");
490 let result = op_update(&mut loaded[d].doc, block, i, &args, &mut minter);
491 minter.finish()?;
492 results.push(result?);
493 }
494 Op::Move { blocks, to, expect } => {
495 let first = blocks.first().map(String::as_str).unwrap_or("");
496 let Some(src) = doc_for_block!(first) else {
497 return Err(merr_data(
498 ErrorCode::BlockMissing,
499 format!("block {first} not found"),
500 json!({ "op_index": i }),
501 )
502 .into());
503 };
504 let top_level_same_doc =
505 to.parent == Parent::Doc && matches!(to.at, At::Start | At::End);
506 let dst = if top_level_same_doc {
507 src.clone()
508 } else {
509 parent_doc!(to)
510 };
511 if dst == src {
512 let d = ensure_doc!(&src);
513 results.push(op_move(&mut loaded[d].doc, blocks, to, i, expect.as_ref())?);
514 } else {
515 let s = ensure_doc!(&src);
516 let t = ensure_doc!(&dst);
517 let (a, b) = two_mut(&mut loaded, s, t);
518 results.push(cross_doc_move(
519 &mut a.doc,
520 &mut b.doc,
521 blocks,
522 to,
523 i,
524 expect.as_ref(),
525 )?);
526 }
527 }
528 Op::Remove { blocks, expect } => {
529 let first = blocks.first().map(String::as_str).unwrap_or("");
530 let Some(doc_id) = doc_for_block!(first) else {
531 return Err(merr_data(
532 ErrorCode::BlockMissing,
533 format!("block {first} not found"),
534 json!({ "op_index": i }),
535 )
536 .into());
537 };
538 let d = ensure_doc!(&doc_id);
539 results.push(op_remove(&mut loaded[d].doc, blocks, i, expect.as_ref())?);
540 }
541 Op::Split { block, at, expect } => {
542 let Some(doc_id) = doc_for_block!(block) else {
543 return Err(merr_data(
544 ErrorCode::BlockMissing,
545 format!("block {block} not found"),
546 json!({ "op_index": i }),
547 )
548 .into());
549 };
550 let d = ensure_doc!(&doc_id);
551 let mut mint = self.ids.at(&self.conn);
552 let mut minter = mint.deferred("b");
553 let result = op_split(
554 &mut loaded[d].doc,
555 block,
556 at,
557 i,
558 expect.as_ref(),
559 &mut minter,
560 );
561 minter.finish()?;
562 results.push(result?);
563 }
564 Op::Merge {
565 blocks,
566 separator,
567 expect,
568 } => {
569 let first = blocks.first().map(String::as_str).unwrap_or("");
570 let Some(doc_id) = doc_for_block!(first) else {
571 return Err(merr_data(
572 ErrorCode::BlockMissing,
573 format!("block {first} not found"),
574 json!({ "op_index": i }),
575 )
576 .into());
577 };
578 let d = ensure_doc!(&doc_id);
579 results.push(op_merge(
580 &mut loaded[d].doc,
581 blocks,
582 i,
583 separator.as_deref(),
584 expect.as_ref(),
585 )?);
586 }
587 }
588 }
589 Ok(Applied { loaded, results })
590 }
591
592 fn resolve_doc_ref(&self, repo_id: &str, r: &str) -> Result<String> {
596 if is_id_ref(r, "d") {
597 return Ok(r.to_owned());
598 }
599 match find_doc_by_ref(&self.conn, repo_id, r)? {
600 Some(info) => Ok(info.doc_id),
601 None => Err(merr_data(
602 ErrorCode::DocMissing,
603 format!("doc {r} not found"),
604 json!({ "doc": r }),
605 )
606 .into()),
607 }
608 }
609
610 pub fn apply(
618 &mut self,
619 req: &ApplyRequest,
620 doc_store: &mut dyn DocStore,
621 ts: &str,
622 ) -> Result<ApplyResult> {
623 let Applied { loaded, results } = self.apply_ops(req)?;
624 let revisions: Vec<Revision> = loaded
625 .iter()
626 .map(|d| Revision {
627 doc: d.doc.doc_id.clone(),
628 path: d.doc.path.clone(),
629 })
630 .collect();
631 if req.dry_run {
632 let diffs = loaded
633 .iter()
634 .map(|d| {
635 (
636 d.doc.path.clone(),
637 Diff {
638 before: d.before.clone(),
639 after: render(&d.doc),
640 },
641 )
642 })
643 .collect();
644 return Ok(ApplyResult {
645 results,
646 revisions,
647 diffs: Some(diffs),
648 committed: false,
649 });
650 }
651 let expires = crate::time::pool_expiry(ts)?;
652 for d in &loaded {
653 let rendered = render(&d.doc);
654 let path = d.doc.path.as_str();
655 let current: Option<Option<Vec<u8>>> = self
657 .conn
658 .query_row(
659 "SELECT file_hash FROM docs WHERE doc_id = ?1",
660 params![d.doc.doc_id],
661 |r| r.get(0),
662 )
663 .optional()?;
664 let on_disk = doc_store.read(path)?;
665 if let (Some(bytes), Some(Some(hash))) = (&on_disk, ¤t) {
666 if sha256(bytes.as_bytes())[..] != hash[..] {
667 self.reconciling_ingest(
668 &req.repo_id,
669 path,
670 bytes,
671 ts,
672 Origin::Observed,
673 None,
674 None,
675 &Config::default(),
676 )?;
677 return Err(merr_data(
678 ErrorCode::SyncConflict,
679 format!("file {path} changed on disk; re-ingested — retry"),
680 json!({ "retriable": true }),
681 )
682 .into());
683 }
684 }
685 doc_store.write(path, &rendered)?;
686 let tree = parse_markdown(&rendered);
688 let body: &[Block] = match tree.children.first() {
689 Some(b) if b.kind == omgbase_format::BlockKind::Frontmatter => &tree.children[1..],
690 _ => &tree.children[..],
691 };
692 let by_key = positional_ids(&d.doc.children);
693 let assigned = assign_known(body, &by_key, None, &mut self.ids.at(&self.conn))?;
694 let mut now_ids = Vec::new();
695 collect_ids(&assigned, &mut now_ids);
696 let prior: HashSet<&str> = d.prior_ids.iter().map(String::as_str).collect();
697 let now: HashSet<&str> = now_ids.iter().map(String::as_str).collect();
698 let dispositions = now_ids
699 .iter()
700 .map(|id| DispositionRow {
701 block_id: id.clone(),
702 kind: if prior.contains(id.as_str()) {
703 "edited".to_owned()
704 } else {
705 "inserted".to_owned()
706 },
707 confidence: Some(1.0),
708 reason: Some("api".to_owned()),
709 matcher_v: None,
710 detail: "{}".to_owned(),
711 })
712 .collect();
713 let deleted: Vec<String> = d
714 .prior_ids
715 .iter()
716 .filter(|id| !now.contains(id.as_str()))
717 .cloned()
718 .collect();
719 let cross_doc_ids: Vec<String> = now_ids
724 .iter()
725 .filter(|id| !prior.contains(id.as_str()))
726 .cloned()
727 .collect();
728 let plan = IngestPlan {
729 path,
730 source: &rendered,
731 tree: &tree,
732 assigned,
733 dispositions,
734 deleted,
735 consumed_pool: Vec::new(),
736 cross_doc_ids,
737 origin: Origin::Api,
738 actor: Some(req.origin.actor.as_str()),
739 reason: req.origin.reason.as_deref(),
740 };
741 self.commit_ingest(&req.repo_id, &plan, ts, &expires)?;
742 }
743 Ok(ApplyResult {
744 results,
745 revisions,
746 diffs: None,
747 committed: true,
748 })
749 }
750}
751
752fn two_mut<T>(v: &mut [T], i: usize, j: usize) -> (&mut T, &mut T) {
754 assert_ne!(i, j, "a cross-document move names two documents");
755 if i < j {
756 let (a, b) = v.split_at_mut(j);
757 (&mut a[i], &mut b[0])
758 } else {
759 let (a, b) = v.split_at_mut(i);
760 (&mut b[0], &mut a[j])
761 }
762}
763
764fn positional_ids(children: &[MutBlock]) -> BTreeMap<String, String> {
766 fn walk(list: &[MutBlock], parent_key: &str, out: &mut BTreeMap<String, String>) {
767 for (i, b) in list.iter().enumerate() {
768 let key = format!("{parent_key}/{i}");
769 out.insert(key.clone(), b.id.clone());
770 if !b.children.is_empty() {
771 walk(&b.children, &key, out);
772 }
773 }
774 }
775 let mut out = BTreeMap::new();
776 walk(children, "", &mut out);
777 out
778}
779
780fn assign_known(
783 blocks: &[Block],
784 by_key: &BTreeMap<String, String>,
785 parent_key: Option<&str>,
786 minter: &mut crate::mint::Mint<'_>,
787) -> Result<Vec<TreeInputBlock>> {
788 blocks
789 .iter()
790 .enumerate()
791 .map(|(index, b)| {
792 let key = format!("{}/{index}", parent_key.unwrap_or(""));
793 let block_id = match by_key.get(&key) {
794 Some(id) => id.clone(),
795 None => minter.mint("b")?,
796 };
797 Ok(TreeInputBlock {
798 block_id,
799 kind: b.kind.as_str().to_owned(),
800 raw: b.raw.clone(),
801 text: b.text.clone(),
802 trivia: b.trivia.clone(),
803 attrs: b.attrs.clone(),
804 span: b.span,
805 children: assign_known(&b.children, by_key, Some(&key), minter)?,
806 })
807 })
808 .collect()
809}
810
811fn collect_ids(blocks: &[TreeInputBlock], out: &mut Vec<String>) {
812 for b in blocks {
813 out.push(b.block_id.clone());
814 collect_ids(&b.children, out);
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn id_refs_accept_production_and_fixture_ids() {
824 assert!(is_id_ref("d_0", "d"));
825 assert!(is_id_ref("d_k7z2p9q", "d"));
826 assert!(!is_id_ref("d_k7z2p9qq", "d"));
827 assert!(!is_id_ref("b_0", "d"));
828 assert!(!is_id_ref("a.md", "d"));
829 assert!(!is_id_ref("d_", "d"));
830 assert!(!is_id_ref("d_i", "d"), "i is not Crockford");
831 }
832
833 #[test]
834 fn positional_keys_follow_the_flatten_convention() {
835 let mut l = MutBlock::new("l", "list", "- a", "\n");
836 l.children.push(MutBlock::new("a", "list_item", "- a", ""));
837 let keys = positional_ids(&[MutBlock::new("h", "heading", "# H", "\n\n"), l]);
838 assert_eq!(keys["/0"], "h");
839 assert_eq!(keys["/1"], "l");
840 assert_eq!(keys["/1/0"], "a");
841 }
842}