Skip to main content

omgbase_store/
mutate.rs

1//! Changesets over the store (`spec/mutate/README.md` §1, §4): loading a
2//! document into the working tree, applying the ops in order across the
3//! documents they touch, and the commit protocol — file-CAS against the doc
4//! store, atomic write, and the `api` ingest with the ops' **known ids**.
5
6use 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/// `^[a-z]+_[alphabet]{1,7}$` with the given prefix: a minted id **or** a
28/// fixture-minter id (`d_0`) — the reference's `isValidId` after
29/// `spec/mutate` (paths carry an extension, so no path is lost).
30#[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/// A live document's identity (the reference's `DocInfo`).
43#[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
50/// A live doc by id **or** repo-relative path (`findDocByRef`): a `d_` id is
51/// looked up by id only; anything else is a path.
52pub 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    // The id branch binds one parameter, the path branch two (a two-value
61    // bind against the one-placeholder query is `InvalidParameterCount`).
62    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
78// ---- loading (§1) ---------------------------------------------------------------------
79
80struct 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
90/// §1: a live document as a working tree — blocks from the live rows
91/// (`ORDER BY parent_block, ordinal`; a row whose parent is not loaded is a
92/// root), raw/trivia from the blobs, the frontmatter blob + trivia. `None`
93/// for an unknown or tombstoned doc.
94pub 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    // Children by parent, in ordinal order; roots in ordinal order.
151    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// ---- requests and results (§4) ---------------------------------------------------------
211
212/// Who is writing and why (`commits.actor`, `commits.reason`).
213#[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/// A frontmatter override (§4 `set_frontmatter`): the doc id and the new
230/// frontmatter raw (fences + separator), or `None` to drop it.
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub struct SetFrontmatter {
233    pub doc: String,
234    pub raw: Option<String>,
235}
236
237/// §4 `Request`.
238#[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/// `{ doc, path }` of a touched document.
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct Revision {
250    pub doc: String,
251    pub path: String,
252}
253
254/// `{ before, after }` of a dry run.
255#[derive(Clone, Debug, PartialEq, Eq)]
256pub struct Diff {
257    pub before: String,
258    pub after: String,
259}
260
261/// §4 `Result`.
262#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct ApplyResult {
264    pub results: Vec<OpResult>,
265    pub revisions: Vec<Revision>,
266    /// Path → diff, in load order; only on a dry run.
267    pub diffs: Option<Vec<(String, Diff)>>,
268    pub committed: bool,
269}
270
271impl ApplyResult {
272    /// `{ results, revisions, diffs?, committed }`.
273    #[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
304/// A document loaded once per request (§4 step 1).
305struct LoadedDoc {
306    doc: MutDoc,
307    /// The pre-mutation render.
308    before: String,
309    /// The live ids before any op (labels the `api` dispositions).
310    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
321/// The ops applied to the loaded documents, before the commit phase.
322struct Applied {
323    loaded: Vec<LoadedDoc>,
324    results: Vec<OpResult>,
325}
326
327impl Store {
328    /// §1: [`load_mut_doc`] on this store.
329    pub fn load_mut_doc(&self, doc_id: &str) -> Result<Option<MutDoc>> {
330        load_mut_doc(&self.conn, doc_id)
331    }
332
333    /// A live doc by id or path.
334    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    /// Which live document holds `block_id` (any repo).
339    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    /// §4 steps 1–3: load on first touch, apply every op in order.
351    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        // §4 step 2: frontmatter overrides load their documents first.
432        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    /// §4 step 1: an `insert.doc` — a `d_` id passes through (the document
593    /// is loaded, and `doc_missing` raised, at first touch), a path resolves
594    /// to a live doc or `doc_missing`.
595    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    /// §4: apply a changeset at `ts` (RFC 3339 UTC, stamped on every commit
611    /// the request records). Steps 1–4 in memory; unless `dry_run`, the
612    /// commit phase per loaded document in load order — file-CAS against
613    /// `doc_store` (a mismatch ingests the on-disk bytes as `observed` and
614    /// raises `sync_conflict`), the atomic write, the `api` ingest with the
615    /// known ids. Atomic up to the commit phase; a `sync_conflict` on the
616    /// *n*-th document leaves the earlier ones committed (§10).
617    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            // File-CAS (§4 step 5).
656            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, &current) {
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            // The known-id `api` ingest.
687            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            // Ids new to this document may be live (or pooled) elsewhere — a
720            // cross-document move whose source commits later, or already did.
721            // spec/store §5.4 step 8 evicts the foreign row and the pool row;
722            // for a freshly minted id it finds nothing.
723            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
752/// Two distinct elements of a slice, mutably.
753fn 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
764/// The working tree's ids by positional key (`"/i/j"`).
765fn 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
780/// §4 step 5: the re-parsed body with the tree's ids by positional key; a
781/// position with no id mints (impossible under the round-trip law).
782fn 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}