Skip to main content

omgbase_store/
observe.rs

1//! Observation (`spec/store/README.md` §5): bytes at a path become a commit.
2//! Two passes — echo gate + reconcile without writes, the cross-document
3//! phase of `spec/reconcile` §7, then one transaction per member — plus the
4//! observed-deletion tombstone (§5.6). The body of a member's commit,
5//! [`ingest_in`], runs §5.4 steps 1–4 and 6–13 against a transaction and a
6//! commit id it is handed, so the `api` path ([`crate::effects`], store 13.7)
7//! can run N of them under one commit row.
8//!
9//! **The gate rule** (store 14.0, §5.7). A path with a `pending_materializations`
10//! row — an `api` commit whose file has not landed yet — is observed against
11//! that intent before the echo gate: bytes hashing to the row's
12//! `rendered_hash` are the materialized file (echo; the row goes); a member
13//! gone under a `write`/`rename` row is **not** a deletion (the materializer
14//! still owes the file; the row stays); foreign bytes win (ADR-004) — the
15//! normal reconciling ingest, the row deleted in that member's transaction.
16
17use std::collections::{BTreeMap, HashSet};
18
19use omgbase_format::hash::{hex, sha256};
20use omgbase_format::{Block, BlockKind, BlockTree, parse_markdown, render};
21use omgbase_graph::{extract_doc_edges, project_nodes};
22use omgbase_properties::{doc_properties, frontmatter_yaml, parse_frontmatter};
23use omgbase_reconcile::json::detail_to_json;
24use omgbase_reconcile::{
25    Config, DispositionKind, FlatSource, Inserted, MatchBlock, Options, PerDocUnmatched, PoolEntry,
26    ReconcileResult, apply_cross_doc_matches, cross_doc_match, flatten, reconcile_document,
27};
28use rusqlite::{Connection, OptionalExtension, params};
29
30use crate::derived::{
31    EvictRow, fts_before_evict_rows, fts_delete_doc, fts_index_doc, rebuild_sections, sweep_pool,
32};
33use crate::effects::{CommitHead, Effect, Guard, Outcome};
34use crate::error::{Error, Result};
35use crate::graph::{
36    adopt_phantoms, maintain_edges, project_section_nodes, resolve_edges, write_doc_nodes,
37};
38use crate::materialize::{PendingKind, delete_pending, pending_at};
39use crate::mint::Mint;
40use crate::order_key::key_between;
41use crate::properties::{doc_blocks, write_doc_properties};
42use crate::read::{load_old_match_blocks, load_pool, reconstruct};
43use crate::time::pool_expiry;
44use crate::tree::canonical_attrs;
45use crate::writers::{
46    NewCommit, NewRevision, Origin, TreeInputBlock, assign_fresh_ids, assign_from_map, new_commit,
47    put_blob, write_block_tree, write_revision,
48};
49use crate::{FORMAT_MARKDOWN, Store};
50
51/// One member of a batch: the bytes now at `path`, or `None` when the path
52/// is gone.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct BatchItem {
55    pub path: String,
56    pub source: Option<String>,
57}
58
59impl BatchItem {
60    /// The bytes at `path` are now `source`.
61    #[must_use]
62    pub fn observed(path: &str, source: &str) -> Self {
63        Self {
64            path: path.to_owned(),
65            source: Some(source.to_owned()),
66        }
67    }
68
69    /// `path` is gone.
70    #[must_use]
71    pub fn gone(path: &str) -> Self {
72        Self {
73            path: path.to_owned(),
74            source: None,
75        }
76    }
77}
78
79/// The outcome of observing bytes at a path (§5.4).
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ObserveOutcome {
82    pub path: String,
83    pub doc_id: String,
84    /// `None` on an echo.
85    pub rev: Option<String>,
86    /// `None` on an echo.
87    pub commit_id: Option<String>,
88    /// §5.4 step 13; `true` on an echo.
89    pub converged: bool,
90    /// The bytes already matched the stored revision: no commit.
91    pub echo: bool,
92    /// The bytes carry git conflict markers.
93    pub conflicted: bool,
94    /// Disposition kind → count over this commit (empty on an echo).
95    pub dispositions: BTreeMap<String, u64>,
96    /// The prior `file_hash` (hex), or `None` when the doc was new or gone.
97    pub old_hash_hex: Option<String>,
98    /// `sha256(source)` (hex).
99    pub new_hash_hex: String,
100}
101
102/// The outcome of observing that a path is gone (§5.6).
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct DeleteOutcome {
105    pub path: String,
106    /// The tombstoned doc, or `None` when nothing was live (a no-op).
107    pub doc_id: Option<String>,
108    pub old_hash_hex: Option<String>,
109}
110
111impl DeleteOutcome {
112    /// Whether a live doc was tombstoned.
113    #[must_use]
114    pub fn deleted(&self) -> bool {
115        self.doc_id.is_some()
116    }
117}
118
119/// The outcome of one batch member.
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub enum BatchOutcome {
122    Observed(ObserveOutcome),
123    Deleted(DeleteOutcome),
124}
125
126impl BatchOutcome {
127    #[must_use]
128    pub fn path(&self) -> &str {
129        match self {
130            BatchOutcome::Observed(o) => &o.path,
131            BatchOutcome::Deleted(d) => &d.path,
132        }
133    }
134
135    #[must_use]
136    pub fn as_observed(&self) -> Option<&ObserveOutcome> {
137        match self {
138            BatchOutcome::Observed(o) => Some(o),
139            BatchOutcome::Deleted(_) => None,
140        }
141    }
142
143    #[must_use]
144    pub fn as_deleted(&self) -> Option<&DeleteOutcome> {
145        match self {
146            BatchOutcome::Deleted(d) => Some(d),
147            BatchOutcome::Observed(_) => None,
148        }
149    }
150}
151
152/// §5.4 step 14: a line starting with `<<<<<<<` **and** a line starting with
153/// `>>>>>>>` (the reference's `/^<{7}/m && /^>{7}/m`; JavaScript's `^` in
154/// multiline mode matches after `\n`, `\r`, U+2028 and U+2029).
155#[must_use]
156pub fn has_conflict_markers(source: &str) -> bool {
157    let starts = |marker: &str| {
158        source
159            .split(['\n', '\r', '\u{2028}', '\u{2029}'])
160            .any(|line| line.starts_with(marker))
161    };
162    starts("<<<<<<<") && starts(">>>>>>>")
163}
164
165/// A member parsed and reconciled but not committed (pass 1) — what an
166/// [`Effect::IngestReconciled`](crate::effects::Effect) carries into the
167/// transaction.
168pub(crate) struct Prepared {
169    pub(crate) path: String,
170    pub(crate) source: String,
171    /// The doc row at `path`, live or tombstoned, or `None` when the path is new.
172    doc_id: Option<String>,
173    tree: BlockTree,
174    old_blocks: Vec<MatchBlock>,
175    new_blocks: Vec<MatchBlock>,
176    result: ReconcileResult,
177    /// Ids carried in by the cross-document phase.
178    cross_doc_ids: Vec<String>,
179}
180
181impl Prepared {
182    /// §5.4 step 3 for this member — assign the matcher's ids onto the body
183    /// (nothing mints: every key is assigned) — and the [`IngestPlan`] that
184    /// commits it with the given provenance.
185    pub(crate) fn plan<'a>(
186        &'a self,
187        minter: &mut Mint<'_>,
188        origin: Origin,
189        actor: Option<&'a str>,
190        reason: Option<&'a str>,
191        conflicted: Option<bool>,
192    ) -> Result<IngestPlan<'a>> {
193        let (_, rest) = split_frontmatter(&self.tree);
194        let assigned = assign_from_map(rest, &self.result.assignment, minter)?;
195        Ok(IngestPlan {
196            path: &self.path,
197            source: &self.source,
198            tree: &self.tree,
199            assigned,
200            dispositions: disposition_rows(&self.result),
201            deleted: self.result.deleted.clone(),
202            consumed_pool: self.result.consumed_pool.clone(),
203            cross_doc_ids: self.cross_doc_ids.clone(),
204            origin,
205            actor,
206            reason,
207            conflicted,
208        })
209    }
210}
211
212enum Pending {
213    Echo(ObserveOutcome),
214    Gone {
215        path: String,
216        doc_id: Option<String>,
217        old_hash_hex: Option<String>,
218        old_blocks: Vec<MatchBlock>,
219    },
220    Ingest {
221        prepared: Box<Prepared>,
222        old_hash_hex: Option<String>,
223        new_hash_hex: String,
224        /// The path had a pending row the file now supersedes: delete it in
225        /// the member's transaction (§5.7).
226        clear_pending: bool,
227    },
228}
229
230/// The frontmatter block (when the first block is one) and the body.
231fn split_frontmatter(tree: &BlockTree) -> (Option<&Block>, &[Block]) {
232    match tree.children.first() {
233        Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
234        _ => (None, &tree.children[..]),
235    }
236}
237
238/// §5.1 step 4: parse, load the old tree (regardless of tombstone), offer the
239/// pool minus `consumed` when a doc row exists, reconcile, mint `b` ids.
240#[allow(clippy::too_many_arguments)]
241fn prepare_reconcile(
242    conn: &Connection,
243    minter: &mut Mint<'_>,
244    repo_id: &str,
245    path: &str,
246    source: &str,
247    config: &Config,
248    pool: &[PoolEntry],
249    consumed: &mut HashSet<String>,
250) -> Result<Prepared> {
251    let doc_id: Option<String> = conn
252        .query_row(
253            "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
254            params![repo_id, path],
255            |r| r.get(0),
256        )
257        .optional()?;
258    let tree = parse_markdown(source);
259    let old_blocks = match &doc_id {
260        Some(id) => load_old_match_blocks(conn, id)?,
261        None => Vec::new(),
262    };
263    let new_blocks = flatten(&FlatSource::from_tree(&tree, None));
264    let offered: Vec<PoolEntry> = if doc_id.is_some() {
265        pool.iter()
266            .filter(|c| !consumed.contains(&c.id))
267            .cloned()
268            .collect()
269    } else {
270        Vec::new()
271    };
272    let mut mint = minter.deferred("b");
273    let result = reconcile_document(
274        &old_blocks,
275        &new_blocks,
276        Options {
277            config,
278            pool: &offered,
279            minter: &mut mint,
280        },
281    );
282    mint.finish()?;
283    consumed.extend(result.consumed_pool.iter().cloned());
284    Ok(Prepared {
285        path: path.to_owned(),
286        source: source.to_owned(),
287        doc_id,
288        tree,
289        old_blocks,
290        new_blocks,
291        result,
292        cross_doc_ids: Vec::new(),
293    })
294}
295
296/// §5.3: pool the leftovers, match across documents, apply.
297fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
298    let mut docs: Vec<PerDocUnmatched> = Vec::new();
299    let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
300    let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
301    for (i, p) in pending.iter_mut().enumerate() {
302        match p {
303            Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
304            Pending::Gone {
305                doc_id: Some(doc_id),
306                old_blocks,
307                ..
308            } => {
309                docs.push(PerDocUnmatched {
310                    doc_id: doc_id.clone(),
311                    deleted: old_blocks.clone(),
312                    inserted: Vec::new(),
313                });
314                results.insert(
315                    doc_id.clone(),
316                    ReconcileResult {
317                        deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
318                        ..ReconcileResult::default()
319                    },
320                );
321            }
322            Pending::Ingest { prepared, .. } => {
323                let key = prepared
324                    .doc_id
325                    .clone()
326                    .unwrap_or_else(|| format!("new:{}", prepared.path));
327                let key_of_id: BTreeMap<&str, &str> = prepared
328                    .result
329                    .assignment
330                    .iter()
331                    .map(|(k, id)| (id.as_str(), k.as_str()))
332                    .collect();
333                let deleted = prepared
334                    .result
335                    .deleted
336                    .iter()
337                    .filter_map(|id| {
338                        prepared
339                            .old_blocks
340                            .iter()
341                            .find(|b| b.id.as_deref() == Some(id))
342                    })
343                    .cloned()
344                    .collect();
345                let inserted = prepared
346                    .result
347                    .dispositions
348                    .iter()
349                    .filter(|d| d.kind == DispositionKind::Inserted)
350                    .filter_map(|d| {
351                        let k = key_of_id.get(d.block_id.as_str())?;
352                        let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
353                        Some(Inserted {
354                            block: block.clone(),
355                            minted_id: d.block_id.clone(),
356                        })
357                    })
358                    .collect();
359                docs.push(PerDocUnmatched {
360                    doc_id: key.clone(),
361                    deleted,
362                    inserted,
363                });
364                results.insert(key.clone(), std::mem::take(&mut prepared.result));
365                key_of[i] = Some(key);
366            }
367        }
368    }
369    let matches = if docs.len() < 2 {
370        Vec::new()
371    } else {
372        cross_doc_match(&docs, config)
373    };
374    if !matches.is_empty() {
375        apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
376    }
377    for (i, p) in pending.iter_mut().enumerate() {
378        if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
379            prepared.result = results
380                .remove(key)
381                .expect("every prepared member was keyed");
382            for m in &matches {
383                if &m.to_doc == key {
384                    prepared.cross_doc_ids.push(m.carried_id.clone());
385                }
386            }
387        }
388    }
389}
390
391struct BlockRow {
392    block_id: String,
393    parent_block: Option<String>,
394    order_key: String,
395    ordinal: i64,
396    depth: i64,
397    ancestor_path: String,
398    kind: String,
399    attrs: String,
400    text: String,
401    raw_hash: [u8; 32],
402    norm_hash: [u8; 32],
403    trivia_hash: Option<[u8; 32]>,
404}
405
406/// The `blocks` rows of a body tree in pre-order (§4.3, §5.4 step 9).
407fn flatten_rows(
408    blocks: &[TreeInputBlock],
409    parent: Option<&str>,
410    depth: i64,
411    ancestor_path: &str,
412    out: &mut Vec<BlockRow>,
413) {
414    let mut prev_key: Option<String> = None;
415    for (ordinal, b) in blocks.iter().enumerate() {
416        let order_key = key_between(prev_key.as_deref(), None);
417        prev_key = Some(order_key.clone());
418        out.push(BlockRow {
419            block_id: b.block_id.clone(),
420            parent_block: parent.map(str::to_owned),
421            order_key,
422            ordinal: ordinal as i64,
423            depth,
424            ancestor_path: ancestor_path.to_owned(),
425            kind: b.kind.clone(),
426            attrs: canonical_attrs(&b.attrs),
427            text: b.text.clone(),
428            raw_hash: sha256(b.raw.as_bytes()),
429            norm_hash: sha256(b.text.as_bytes()),
430            trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
431        });
432        if !b.children.is_empty() {
433            flatten_rows(
434                &b.children,
435                Some(&b.block_id),
436                depth + 1,
437                &format!("{ancestor_path}{}/", b.block_id),
438                out,
439            );
440        }
441    }
442}
443
444/// §5.4 step 8: delete another document's `blocks` row for each id (its FTS
445/// entry first when it is a live leaf, `spec/search` §1.1 — leaf-ness decided
446/// over the table before any row goes, a parent left childless re-indexed)
447/// and the id's pool row.
448fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
449    let mut rows = Vec::new();
450    for id in ids {
451        let row: Option<EvictRow> = conn
452            .query_row(
453                "SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit IS NULL
454                 FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
455                params![id, doc_id],
456                |r| {
457                    Ok(EvictRow {
458                        rowid: r.get(0)?,
459                        block_id: r.get(1)?,
460                        doc_id: r.get(2)?,
461                        parent_block: r.get(3)?,
462                        text: r.get(4)?,
463                        live: r.get(5)?,
464                    })
465                },
466            )
467            .optional()?;
468        if let Some(row) = row {
469            rows.push(row);
470        }
471    }
472    fts_before_evict_rows(conn, &rows)?;
473    for r in &rows {
474        conn.execute(
475            "DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
476            params![r.block_id, doc_id],
477        )?;
478    }
479    for id in ids {
480        conn.execute(
481            "DELETE FROM resurrection_pool WHERE block_id = ?1",
482            params![id],
483        )?;
484    }
485    Ok(())
486}
487
488/// What §5.4 steps 1–13 produce: the committed document, commit and
489/// revision, and whether the bytes converged (step 13).
490#[derive(Clone, Debug, PartialEq, Eq)]
491pub struct Committed {
492    pub doc_id: String,
493    pub commit_id: String,
494    pub rev_id: String,
495    pub converged: bool,
496}
497
498/// A disposition to persist (§5.4 step 10): the matcher's, or the `api`
499/// intent rows of `spec/mutate` §4 (`matcher_v` null, `detail` `{}`).
500#[derive(Clone, Debug, PartialEq)]
501pub(crate) struct DispositionRow {
502    pub block_id: String,
503    pub kind: String,
504    pub confidence: Option<f64>,
505    pub reason: Option<String>,
506    pub matcher_v: Option<String>,
507    /// JSON text.
508    pub detail: String,
509}
510
511/// Everything §5.4 needs to commit one document: the parsed tree and the
512/// id-assigned body, the identity decisions, and the commit row's provenance.
513pub(crate) struct IngestPlan<'a> {
514    pub path: &'a str,
515    pub source: &'a str,
516    pub tree: &'a BlockTree,
517    pub assigned: Vec<TreeInputBlock>,
518    pub dispositions: Vec<DispositionRow>,
519    /// Pooled (step 7).
520    pub deleted: Vec<String>,
521    pub consumed_pool: Vec<String>,
522    pub cross_doc_ids: Vec<String>,
523    pub origin: Origin,
524    pub actor: Option<&'a str>,
525    pub reason: Option<&'a str>,
526    /// `docs.conflicted` for this commit: `Some` on the observed path (§5.4
527    /// step 12 writes it with the pointers, in the same transaction — the
528    /// reference's `observeFile` sets it from the conflict-marker scan),
529    /// `None` leaves the column as it is (`api` ingests, the reconciling and
530    /// fresh ingests of the sync layer).
531    pub conflicted: Option<bool>,
532}
533
534/// §5.4 step 10 (14.2): which verdicts the store persists — every kind but
535/// `same`.
536#[must_use]
537pub fn persists_disposition(kind: &str) -> bool {
538    kind != "same"
539}
540
541/// §5.4 (14.2): kind → count over every verdict of a reconcile result, `same`
542/// included — what the observe outcome reports. A `BTreeMap` orders the kinds
543/// bytewise, as `ORDER BY kind` did through 14.1.
544fn count_kinds(result: &ReconcileResult) -> BTreeMap<String, u64> {
545    let mut counts = BTreeMap::new();
546    for d in &result.dispositions {
547        *counts.entry(d.kind.as_str().to_owned()).or_insert(0) += 1;
548    }
549    counts
550}
551
552fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
553    result
554        .dispositions
555        .iter()
556        .map(|d| DispositionRow {
557            block_id: d.block_id.clone(),
558            kind: d.kind.as_str().to_owned(),
559            confidence: d.confidence,
560            reason: d.reason.map(|r| r.as_str().to_owned()),
561            matcher_v: Some(d.matcher_v.clone()),
562            detail: detail_to_json(&d.detail).to_string(),
563        })
564        .collect()
565}
566
567/// §5.4 steps 1–4 and 6–13 for one document against an open transaction
568/// `tx` and the commit row `commit_id` already written in it (step 5 is the
569/// caller's: [`Store::commit_ingest`] writes one row per document, the `api`
570/// path of [`crate::effects`] one row per request — store 13.7). Mints `d`
571/// for a new path, `r`, then `x`/`e` through `mint`, which must be the
572/// store's minter over `tx`. Nothing here opens a transaction of its own.
573pub(crate) fn ingest_in(
574    tx: &Connection,
575    mint: &mut Mint<'_>,
576    repo_id: &str,
577    plan: &IngestPlan<'_>,
578    expires: &str,
579    commit_id: &str,
580) -> Result<Committed> {
581    let minter = mint;
582    let tree = plan.tree;
583    let source = plan.source;
584    let (fm_block, _) = split_frontmatter(tree);
585
586    // 2. Frontmatter blob (the reference puts it before the doc row; blobs
587    //    are content-addressed, so the order is unobservable).
588    let fm_blob_hex = fm_block.map(|b| put_blob(tx, &b.raw)).transpose()?;
589    let fm_trivia = fm_block.map(|b| b.trivia.as_str());
590
591    // 1. Doc row (the path's row regardless of tombstone; a re-created
592    //    path is revived — §5.6 "Re-creation").
593    let existing: Option<(String, Option<String>)> = tx
594        .query_row(
595            "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
596            params![repo_id, plan.path],
597            |r| Ok((r.get(0)?, r.get(1)?)),
598        )
599        .optional()?;
600    let doc_id = match existing {
601        Some((id, deleted_commit)) => {
602            tx.execute(
603                "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
604                params![FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia, id],
605            )?;
606            // spec/graph §3.5: a revived row becomes live again, so the
607            // phantom edges that accrued at its path while it was
608            // tombstoned re-point to it.
609            if deleted_commit.is_some() {
610                adopt_phantoms(tx, plan.path, &id)?;
611            }
612            id
613        }
614        None => {
615            let id = minter.mint("d")?;
616            tx.execute(
617                "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
618                params![id, repo_id, plan.path, FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia],
619            )?;
620            // spec/graph §3.5: a new row at this path adopts the open
621            // phantom edges that pointed at it (the reference runs this
622            // right after the INSERT; nothing is minted).
623            adopt_phantoms(tx, plan.path, &id)?;
624            id
625        }
626    };
627
628    // 4. Write the tree.
629    let assigned = &plan.assigned;
630    let root_tree_hex = write_block_tree(tx, assigned)?;
631
632    // 6. The revision row (the commit row, step 5, is the caller's).
633    let rendered_hash = sha256(source.as_bytes());
634    let (rev_id, _) = write_revision(
635        tx,
636        minter,
637        &NewRevision {
638            doc_id: &doc_id,
639            root_tree_hex: &root_tree_hex,
640            frontmatter_blob_hex: fm_blob_hex.as_deref(),
641            rendered_hash,
642            path: plan.path,
643            commit_id,
644        },
645    )?;
646
647    // 7. Pool the deleted.
648    {
649        let mut pool = tx.prepare(
650            "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
651             SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
652        )?;
653        for id in &plan.deleted {
654            pool.execute(params![commit_id, expires, id, doc_id])?;
655        }
656    }
657
658    // 8. Evict foreign rows.
659    let incoming: Vec<String> = plan
660        .cross_doc_ids
661        .iter()
662        .chain(plan.consumed_pool.iter())
663        .cloned()
664        .collect();
665    if !incoming.is_empty() {
666        evict_foreign_block_rows(tx, &doc_id, &incoming)?;
667    }
668
669    // 9. Refresh the blocks, sections and FTS.
670    fts_delete_doc(tx, &doc_id)?;
671    tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
672    let mut rows = Vec::new();
673    flatten_rows(assigned, None, 0, "/", &mut rows);
674    {
675        let mut insert = tx.prepare(
676            "INSERT INTO blocks
677               (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
678                ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
679             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
680        )?;
681        for r in &rows {
682            insert.execute(params![
683                r.block_id,
684                repo_id,
685                doc_id,
686                r.parent_block,
687                r.order_key,
688                r.ordinal,
689                r.depth,
690                r.ancestor_path,
691                r.kind,
692                r.attrs,
693                r.text,
694                &r.raw_hash[..],
695                &r.norm_hash[..],
696                r.trivia_hash.as_ref().map(|h| &h[..]),
697                commit_id,
698            ])?;
699        }
700    }
701    fts_index_doc(tx, &doc_id)?;
702    rebuild_sections(tx, &doc_id)?;
703
704    // 9a. Nodes (spec/graph §2): the adapter's projections over the
705    //     assigned body, then the `md:section` nodes from the sections just
706    //     rebuilt; deleted (FTS first) and reinserted.
707    let body = doc_blocks(assigned);
708    let mut nodes = project_nodes(&body);
709    nodes.extend(project_section_nodes(tx, &doc_id)?);
710    write_doc_nodes(tx, repo_id, &doc_id, &nodes)?;
711
712    // 9b. Properties (spec/properties §6): the document's rows from the
713    //     frontmatter block and the assigned body, deleted then written.
714    let property_rows = doc_properties(&doc_id, fm_block, &body);
715    write_doc_properties(tx, repo_id, &doc_id, commit_id, &property_rows)?;
716
717    // 9c. Edges (spec/graph §3): descriptors from the blocks in pre-order
718    //     then the frontmatter mapping (the same parse the properties step
719    //     uses; none when it fails), resolved in order (**mints `x`** per
720    //     new external URI), then the intervals (**mints `e`** per new
721    //     edge) and the rollup.
722    let mapping = fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)));
723    let descriptors = extract_doc_edges(&body, mapping.as_ref());
724    let resolved = resolve_edges(tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
725    maintain_edges(tx, minter, repo_id, &doc_id, commit_id, &resolved)?;
726
727    // 10. Dispositions and block_changes — every verdict but `same` (14.2).
728    //     The matcher (and the known-id resolver) still produce one per
729    //     untouched block, and the outcome counts them, but the two tables
730    //     record what CHANGED: a block's rows are its own history, so
731    //     `MAX(commits.ts)` over its `block_changes` and `blocks_history`
732    //     are the block's changes, not its siblings'. Every other kind — the
733    //     first revision's `inserted` rows the mint oracle (§2.1) depends on
734    //     included — is written as before.
735    {
736        let mut ins = tx.prepare(
737            "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
738             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
739        )?;
740        let mut bc = tx.prepare(
741            "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
742        )?;
743        for d in plan
744            .dispositions
745            .iter()
746            .filter(|d| persists_disposition(&d.kind))
747        {
748            ins.execute(params![
749                commit_id,
750                d.block_id,
751                d.kind,
752                d.confidence,
753                d.reason,
754                d.matcher_v,
755                d.detail,
756            ])?;
757            bc.execute(params![d.block_id, commit_id, d.kind])?;
758        }
759    }
760
761    // 11. Consume the pool.
762    for id in &plan.consumed_pool {
763        tx.execute(
764            "DELETE FROM resurrection_pool WHERE block_id = ?1",
765            params![id],
766        )?;
767    }
768
769    // 12. Pointers — and, on the observed path, the conflict flag, so
770    //     the row the commit leaves behind is consistent as a whole (an
771    //     `api` plan leaves `conflicted` as it is).
772    match plan.conflicted {
773        Some(conflicted) => tx.execute(
774            "UPDATE docs SET current_rev = ?1, file_hash = ?2, conflicted = ?3 WHERE doc_id = ?4",
775            params![rev_id, &rendered_hash[..], i64::from(conflicted), doc_id],
776        )?,
777        None => tx.execute(
778            "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
779            params![rev_id, &rendered_hash[..], doc_id],
780        )?,
781    };
782
783    // 13. Converged: file_hash == rendered_hash (trivially), render(tree) ==
784    //     source, reconstruct(doc) == source.
785    let converged = render(tree) == source && reconstruct(tx, &doc_id)?.as_deref() == Some(source);
786
787    Ok(Committed {
788        doc_id,
789        commit_id: commit_id.to_owned(),
790        rev_id,
791        converged,
792    })
793}
794
795/// §5.6's tombstone against an open transaction and its commit row: with
796/// `expires`, every live block is pooled first (the observed deletion);
797/// without, nothing is (`docs_delete`, `spec/mutate` §6). Then the FTS rows
798/// go, the live blocks and the doc row take `deleted_commit`.
799pub(crate) fn tombstone_in(
800    tx: &Connection,
801    doc_id: &str,
802    commit_id: &str,
803    expires: Option<&str>,
804) -> Result<()> {
805    if let Some(expires) = expires {
806        tx.execute(
807            "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
808             SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
809               FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
810            params![commit_id, expires, doc_id],
811        )?;
812    }
813    fts_delete_doc(tx, doc_id)?;
814    tx.execute(
815        "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
816        params![commit_id, doc_id],
817    )?;
818    tx.execute(
819        "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
820        params![commit_id, doc_id],
821    )?;
822    Ok(())
823}
824
825impl Store {
826    /// §5: observe a batch of members at `ts` (RFC 3339 UTC, §2.4) with the
827    /// `spec/reconcile` thresholds in `config`. Does **not** sweep the pool
828    /// (§5.5: the caller does, once per batch).
829    pub fn observe_batch(
830        &mut self,
831        repo_id: &str,
832        items: &[BatchItem],
833        ts: &str,
834        config: &Config,
835    ) -> Result<Vec<BatchOutcome>> {
836        let expires = pool_expiry(ts)?;
837
838        // ---- pass 1: echo gate + reconcile, no writes ------------------------------
839        let pool = load_pool(&self.conn, repo_id, ts)?;
840        let mut consumed: HashSet<String> = HashSet::new();
841        let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
842        for it in items {
843            let existing: Option<(String, Option<Vec<u8>>)> = self
844                .conn
845                .query_row(
846                    "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
847                    params![repo_id, it.path],
848                    |r| Ok((r.get(0)?, r.get(1)?)),
849                )
850                .optional()?;
851            let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
852            // §5.7: the intent an `api` commit still owes this path, if any.
853            let intent = pending_at(&self.conn, repo_id, &it.path)?;
854            let Some(source) = &it.source else {
855                match &intent {
856                    // The file the materializer owes is not here yet: not a
857                    // deletion; the row stays for it.
858                    Some(row) if row.kind != PendingKind::Remove => {
859                        pending.push(Pending::Gone {
860                            path: it.path.clone(),
861                            doc_id: None,
862                            old_hash_hex: None,
863                            old_blocks: Vec::new(),
864                        });
865                        continue;
866                    }
867                    // A `remove` whose file is gone: fulfilled.
868                    Some(_) => delete_pending(&self.conn, repo_id, &it.path)?,
869                    None => {}
870                }
871                let (doc_id, old_blocks) = match &existing {
872                    Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
873                    None => (None, Vec::new()),
874                };
875                pending.push(Pending::Gone {
876                    path: it.path.clone(),
877                    doc_id,
878                    old_hash_hex,
879                    old_blocks,
880                });
881                continue;
882            };
883            let hash = sha256(source.as_bytes());
884            let new_hash_hex = hex(&hash);
885            let echo = |doc_id: &str| {
886                Pending::Echo(ObserveOutcome {
887                    path: it.path.clone(),
888                    doc_id: doc_id.to_owned(),
889                    rev: None,
890                    commit_id: None,
891                    converged: true,
892                    echo: true,
893                    conflicted: false,
894                    dispositions: BTreeMap::new(),
895                    old_hash_hex: old_hash_hex.clone(),
896                    new_hash_hex: new_hash_hex.clone(),
897                })
898            };
899            if let Some(row) = &intent {
900                if row.rendered_hash[..] == hash[..] {
901                    // The bytes the commit meant: the observation confirms
902                    // the write (the row goes) — or, for a `remove`, finds
903                    // the very file the materializer will unlink (the row
904                    // stays; nothing to ingest).
905                    if row.kind != PendingKind::Remove {
906                        delete_pending(&self.conn, repo_id, &it.path)?;
907                    }
908                    let doc_id = existing
909                        .as_ref()
910                        .map_or(row.doc_id.as_str(), |(id, _)| id.as_str());
911                    pending.push(echo(doc_id));
912                    continue;
913                }
914                // Foreign bytes: the file wins; the normal ingest below
915                // clears the row in its transaction — unless the database
916                // already agrees with the file (a row naming other bytes is
917                // stale either way; the observation settles it).
918                if let Some((doc_id, Some(stored))) = &existing {
919                    if stored[..] == hash[..] {
920                        delete_pending(&self.conn, repo_id, &it.path)?;
921                        pending.push(echo(doc_id));
922                        continue;
923                    }
924                }
925            } else if let Some((doc_id, Some(stored))) = &existing {
926                if stored[..] == hash[..] {
927                    pending.push(echo(doc_id));
928                    continue;
929                }
930            }
931            let clear_pending = intent.is_some();
932            let prepared = prepare_reconcile(
933                &self.conn,
934                &mut self.ids.at(&self.conn),
935                repo_id,
936                &it.path,
937                source,
938                config,
939                &pool,
940                &mut consumed,
941            )?;
942            pending.push(Pending::Ingest {
943                prepared: Box::new(prepared),
944                old_hash_hex,
945                new_hash_hex,
946                clear_pending,
947            });
948        }
949
950        // ---- cross-document phase (spec/reconcile §7) --------------------------------
951        if pending.len() > 1 {
952            cross_doc_phase(&mut pending, config);
953        }
954
955        // ---- pass 2: commit, in batch order -------------------------------------------
956        let mut out = Vec::with_capacity(pending.len());
957        for p in pending {
958            match p {
959                Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
960                Pending::Gone {
961                    path,
962                    doc_id,
963                    old_hash_hex,
964                    ..
965                } => {
966                    if let Some(id) = &doc_id {
967                        self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
968                    }
969                    out.push(BatchOutcome::Deleted(DeleteOutcome {
970                        path,
971                        doc_id,
972                        old_hash_hex,
973                    }));
974                }
975                Pending::Ingest {
976                    prepared,
977                    old_hash_hex,
978                    new_hash_hex,
979                    clear_pending,
980                } => {
981                    // The flag rides the commit's transaction (step 12), not
982                    // an autocommit after it; so does the pending row's end.
983                    let conflicted = has_conflict_markers(&prepared.source);
984                    let c = self.commit_prepared(
985                        repo_id,
986                        &prepared,
987                        ts,
988                        &expires,
989                        conflicted,
990                        clear_pending,
991                    )?;
992                    // §5.4 (14.2): the outcome counts every verdict the
993                    // matcher produced, `same` included — it describes what
994                    // the matcher saw; the table describes what changed
995                    // (step 10 drops `same`) — so the counts come from the
996                    // in-memory result, never the rows. Kinds in bytewise
997                    // order, as the table query listed them through 14.1.
998                    let dispositions = count_kinds(&prepared.result);
999                    out.push(BatchOutcome::Observed(ObserveOutcome {
1000                        path: prepared.path,
1001                        doc_id: c.doc_id,
1002                        rev: Some(c.rev_id),
1003                        commit_id: Some(c.commit_id),
1004                        converged: c.converged,
1005                        echo: false,
1006                        conflicted,
1007                        dispositions,
1008                        old_hash_hex,
1009                        new_hash_hex,
1010                    }));
1011                }
1012            }
1013        }
1014        Ok(out)
1015    }
1016
1017    /// §5.4 steps 1–13 for a prepared (reconciled) member: an `observed`
1018    /// commit with the matcher's dispositions, `docs.conflicted` set to
1019    /// `conflicted` in the same transaction — and, with `clear_pending`,
1020    /// the path's pending row deleted in it too (§5.7: the file won).
1021    fn commit_prepared(
1022        &mut self,
1023        repo_id: &str,
1024        prepared: &Prepared,
1025        ts: &str,
1026        expires: &str,
1027        conflicted: bool,
1028        clear_pending: bool,
1029    ) -> Result<Committed> {
1030        let plan = prepared.plan(
1031            &mut self.ids.at(&self.conn),
1032            Origin::Observed,
1033            None,
1034            None,
1035            Some(conflicted),
1036        )?;
1037        self.commit_ingest(
1038            repo_id,
1039            &plan,
1040            ts,
1041            expires,
1042            clear_pending.then_some(prepared.path.as_str()),
1043        )
1044    }
1045
1046    /// §5.1 step 4 for one path outside a batch: the repo's pool at `ts`
1047    /// offered, the member parsed and reconciled, `b` ids minted — nothing
1048    /// written. What [`Store::reconciling_ingest`] commits, and what a
1049    /// document operation hands to [`Store::commit_effects`].
1050    pub(crate) fn prepare_single(
1051        &mut self,
1052        repo_id: &str,
1053        path: &str,
1054        source: &str,
1055        ts: &str,
1056        config: &Config,
1057    ) -> Result<Prepared> {
1058        let pool = load_pool(&self.conn, repo_id, ts)?;
1059        let mut consumed = HashSet::new();
1060        prepare_reconcile(
1061            &self.conn,
1062            &mut self.ids.at(&self.conn),
1063            repo_id,
1064            path,
1065            source,
1066            config,
1067            &pool,
1068            &mut consumed,
1069        )
1070    }
1071
1072    /// Parse and reconcile `source` at `path` against the stored tree (the
1073    /// pool offered when a doc row exists, §5.1 step 4) and commit it with
1074    /// the given provenance — the reference's `ingestFile` with
1075    /// `makeReconcilingResolver`: `docs_create`/`docs_set_meta` (`api`) and
1076    /// the file-CAS conflict ingest of `spec/mutate` §4 (`observed`). No echo
1077    /// gate, no `conflicted` update, no sweep. The sync layer's recovery and
1078    /// `attach_source` (`spec/sync` §4.3, §6) ingest through this too, with
1079    /// `Origin::Observed`.
1080    #[allow(clippy::too_many_arguments)]
1081    pub fn reconciling_ingest(
1082        &mut self,
1083        repo_id: &str,
1084        path: &str,
1085        source: &str,
1086        ts: &str,
1087        origin: Origin,
1088        actor: Option<&str>,
1089        reason: Option<&str>,
1090        config: &Config,
1091    ) -> Result<Committed> {
1092        let head = CommitHead {
1093            origin,
1094            actor,
1095            reason,
1096            clear_pending: false,
1097            ops: None,
1098        };
1099        self.reconciling_commit(repo_id, path, source, ts, &head, config, Guard::None)?
1100            .committed()?
1101            .pop()
1102            .ok_or_else(|| {
1103                Error::Other(format!("reconciling_ingest: nothing committed for {path}"))
1104            })
1105    }
1106
1107    /// [`Store::reconciling_ingest`] with a precondition the transaction
1108    /// re-validates (`spec/mutate` §6, 2.2): `docs_create` guards "no live
1109    /// document at the path", `docs_set_meta` the document's triple and the
1110    /// bytes it patched. Returns the commit's [`Outcome`] — a disk conflict
1111    /// is the caller's to ingest as `observed`.
1112    #[allow(clippy::too_many_arguments)]
1113    pub(crate) fn reconciling_commit(
1114        &mut self,
1115        repo_id: &str,
1116        path: &str,
1117        source: &str,
1118        ts: &str,
1119        head: &CommitHead<'_>,
1120        config: &Config,
1121        guard: Guard,
1122    ) -> Result<Outcome> {
1123        let prepared = self.prepare_single(repo_id, path, source, ts, config)?;
1124        self.commit_effects(
1125            repo_id,
1126            head,
1127            &[Effect::IngestReconciled {
1128                prepared: Box::new(prepared),
1129                guard,
1130            }],
1131            ts,
1132        )
1133    }
1134
1135    /// Parse `source` at `path` and commit it with a **fresh `b` id for every
1136    /// block** — the reference's `ingestFile` without a resolver (no
1137    /// dispositions, nothing pooled): the re-mint path `spec/sync` §6 pins for
1138    /// a `borne` source's `attach_source` (§9). No echo gate, no sweep.
1139    pub fn fresh_ingest(
1140        &mut self,
1141        repo_id: &str,
1142        path: &str,
1143        source: &str,
1144        ts: &str,
1145        origin: Origin,
1146    ) -> Result<Committed> {
1147        let expires = pool_expiry(ts)?;
1148        let tree = parse_markdown(source);
1149        let (_, rest) = split_frontmatter(&tree);
1150        let assigned = assign_fresh_ids(rest, &mut self.ids.at(&self.conn))?;
1151        let plan = IngestPlan {
1152            path,
1153            source,
1154            tree: &tree,
1155            assigned,
1156            dispositions: Vec::new(),
1157            deleted: Vec::new(),
1158            consumed_pool: Vec::new(),
1159            cross_doc_ids: Vec::new(),
1160            origin,
1161            actor: None,
1162            reason: None,
1163            conflicted: None,
1164        };
1165        self.commit_ingest(repo_id, &plan, ts, &expires, None)
1166    }
1167
1168    /// §5.4 steps 1–13 in one transaction of their own, under a commit row
1169    /// of their own (the reference's `ingestFile`): BEGIN, step 5, then
1170    /// [`ingest_in`], the pending row at `clear_pending_at` deleted (§5.7),
1171    /// COMMIT. The observed path and the fresh ingest commit through this;
1172    /// the `api` path runs N [`ingest_in`]s under one row via
1173    /// [`Store::commit_effects`](crate::effects).
1174    pub(crate) fn commit_ingest(
1175        &mut self,
1176        repo_id: &str,
1177        plan: &IngestPlan<'_>,
1178        ts: &str,
1179        expires: &str,
1180        clear_pending_at: Option<&str>,
1181    ) -> Result<Committed> {
1182        let tx = self.conn.unchecked_transaction()?;
1183        let committed = {
1184            let mut mint = self.ids.at(&tx);
1185            // 5. The commit row.
1186            let (commit_id, _) = new_commit(
1187                &tx,
1188                &mut mint,
1189                &NewCommit {
1190                    repo_id,
1191                    ts,
1192                    origin: plan.origin,
1193                    actor: plan.actor,
1194                    reason: plan.reason,
1195                    checkpoint_id: None,
1196                    ops: None,
1197                },
1198            )?;
1199            let committed = ingest_in(&tx, &mut mint, repo_id, plan, expires, &commit_id)?;
1200            if let Some(path) = clear_pending_at {
1201                delete_pending(&tx, repo_id, path)?;
1202            }
1203            committed
1204        };
1205        tx.commit()?;
1206        Ok(committed)
1207    }
1208
1209    /// §5.6: tombstone a live doc whose path is gone, in one transaction of
1210    /// its own: an `observed deletion` commit (mints `c`), then
1211    /// [`tombstone_in`] with the pool. Returns the commit id.
1212    pub(crate) fn tombstone_observed_deletion(
1213        &mut self,
1214        repo_id: &str,
1215        doc_id: &str,
1216        ts: &str,
1217        expires: &str,
1218    ) -> Result<String> {
1219        let tx = self.conn.unchecked_transaction()?;
1220        let commit_id = {
1221            let mut mint = self.ids.at(&tx);
1222            let (commit_id, _) = new_commit(
1223                &tx,
1224                &mut mint,
1225                &NewCommit {
1226                    reason: Some("observed deletion"),
1227                    ..NewCommit::observed(repo_id, ts)
1228                },
1229            )?;
1230            tombstone_in(&tx, doc_id, &commit_id, Some(expires))?;
1231            commit_id
1232        };
1233        tx.commit()?;
1234        Ok(commit_id)
1235    }
1236
1237    /// A batch of one (§5: "the same procedure without the middle"). Does not
1238    /// sweep the pool.
1239    pub fn observe_one(
1240        &mut self,
1241        repo_id: &str,
1242        path: &str,
1243        source: &str,
1244        ts: &str,
1245        config: &Config,
1246    ) -> Result<ObserveOutcome> {
1247        let mut out =
1248            self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1249        match out.pop() {
1250            Some(BatchOutcome::Observed(o)) => Ok(o),
1251            _ => Err(Error::Other(format!(
1252                "observe_one: unexpected outcome for {path}"
1253            ))),
1254        }
1255    }
1256
1257    /// Observe that `path` is gone (§5.6) and sweep the pool at `ts` (§5.5),
1258    /// as the reference's `observeDelete` does. A path with no live doc is a
1259    /// no-op (`doc_id: None`); so is a path whose file an `api` commit still
1260    /// owes (§5.7: a `write`/`rename` row — the materializer, not a
1261    /// tombstone, answers for it).
1262    pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1263        let expires = pool_expiry(ts)?;
1264        match pending_at(&self.conn, repo_id, path)? {
1265            Some(row) if row.kind != PendingKind::Remove => {
1266                return Ok(DeleteOutcome {
1267                    path: path.to_owned(),
1268                    doc_id: None,
1269                    old_hash_hex: None,
1270                });
1271            }
1272            Some(_) => delete_pending(&self.conn, repo_id, path)?,
1273            None => {}
1274        }
1275        let existing: Option<(String, Option<Vec<u8>>)> = self
1276            .conn
1277            .query_row(
1278                "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1279                params![repo_id, path],
1280                |r| Ok((r.get(0)?, r.get(1)?)),
1281            )
1282            .optional()?;
1283        let Some((doc_id, file_hash)) = existing else {
1284            return Ok(DeleteOutcome {
1285                path: path.to_owned(),
1286                doc_id: None,
1287                old_hash_hex: None,
1288            });
1289        };
1290        self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1291        sweep_pool(&self.conn, ts)?;
1292        Ok(DeleteOutcome {
1293            path: path.to_owned(),
1294            doc_id: Some(doc_id),
1295            old_hash_hex: file_hash.as_deref().map(hex),
1296        })
1297    }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303
1304    #[test]
1305    fn conflict_markers_need_both_sides_at_line_starts() {
1306        assert!(has_conflict_markers(
1307            "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1308        ));
1309        assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1310        assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1311        assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1312        assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1313        assert!(!has_conflict_markers(""));
1314    }
1315
1316    #[test]
1317    fn frontmatter_is_split_only_when_first() {
1318        let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1319        let (fm, rest) = split_frontmatter(&tree);
1320        assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1321        assert_eq!(rest.len(), 1);
1322        let tree = parse_markdown("# H\n");
1323        let (fm, rest) = split_frontmatter(&tree);
1324        assert!(fm.is_none());
1325        assert_eq!(rest.len(), 1);
1326    }
1327}