Skip to main content

omgbase_surface/
history.rs

1//! History (`spec/surface/README.md` §3): `blocks_history`, the block-grain
2//! `diff`, the Myers unified `docs_diff_unified` over the two revisions'
3//! reconstructed files (§3; positional before 1.1, §9), `docs_history`. Port
4//! of `packages/core/src/graph/history.ts`; `changes_since` is the store's.
5
6use std::collections::HashMap;
7
8use omgbase_format::hash::hex;
9use omgbase_store::tree::{from_hex, parse_tree_entries};
10use omgbase_store::{Store, is_id_ref};
11use rusqlite::{Connection, OptionalExtension, params};
12use serde_json::{Map, Value as Json, json};
13
14use crate::error::Result;
15use crate::paths::{reference_path, storage_path};
16use crate::read::find_doc_by_ref;
17
18/// §3 `blocks_history`: `[{ commitId, seq, ts, origin, kind, confidence, reason }]`,
19/// newest first.
20pub fn blocks_history(store: &Store, block_id: &str, limit: Option<i64>) -> Result<Json> {
21    let limit = limit.unwrap_or(100);
22    let mut stmt = store.conn().prepare(
23        "SELECT bc.commit_id, c.seq, c.ts, c.origin, bc.kind, d.confidence, d.reason
24         FROM block_changes bc
25         JOIN commits c ON c.commit_id = bc.commit_id
26         LEFT JOIN dispositions d ON d.commit_id = bc.commit_id AND d.block_id = bc.block_id AND d.kind = bc.kind
27         WHERE bc.block_id = ?1
28         ORDER BY c.seq DESC
29         LIMIT ?2",
30    )?;
31    let rows = stmt.query_map(params![block_id, limit], |r| {
32        Ok(json!({
33            "commitId": r.get::<_, String>(0)?,
34            "seq": r.get::<_, i64>(1)?,
35            "ts": r.get::<_, String>(2)?,
36            "origin": r.get::<_, String>(3)?,
37            "kind": r.get::<_, String>(4)?,
38            "confidence": r.get::<_, Option<f64>>(5)?,
39            "reason": r.get::<_, Option<String>>(6)?,
40        }))
41    })?;
42    Ok(Json::Array(
43        rows.collect::<std::result::Result<Vec<_>, _>>()?,
44    ))
45}
46
47/// §9: an unknown revision is `target_missing` (never an empty tree or an
48/// empty file). One constructor so `diff` and `docs_diff_unified` report it with
49/// the same message and `data`.
50fn unknown_revision(doc_id: &str, rev_id: &str) -> crate::error::SurfaceError {
51    crate::error::SurfaceError::with_data(
52        "target_missing",
53        format!(
54            "no revision {} for document {doc_id}",
55            Json::String(rev_id.to_owned())
56        ),
57        json!({ "doc": doc_id, "rev": rev_id }),
58    )
59}
60
61/// The (block id → raw) map of a document at a revision, walking the Merkle
62/// tree to every depth (insertion order = tree order, roots first). The
63/// block grain of `diff`; not a rendering of the file (a container's raw
64/// already holds its children's).
65fn blocks_at_revision(
66    conn: &Connection,
67    doc_id: &str,
68    rev_id: &str,
69) -> Result<Vec<(String, String)>> {
70    match blocks_at_known_revision(conn, doc_id, rev_id)? {
71        Some(rows) => Ok(rows),
72        None => Err(unknown_revision(doc_id, rev_id)),
73    }
74}
75
76/// `None` when `rev_id` is not one of the doc's revisions (§9: an unknown
77/// revision is `target_missing`, not an empty tree).
78fn blocks_at_known_revision(
79    conn: &Connection,
80    doc_id: &str,
81    rev_id: &str,
82) -> Result<Option<Vec<(String, String)>>> {
83    let root: Option<Vec<u8>> = conn
84        .query_row(
85            "SELECT root_tree FROM revisions WHERE rev_id = ?1 AND doc_id = ?2",
86            params![rev_id, doc_id],
87            |r| r.get(0),
88        )
89        .optional()?;
90    let mut out: Vec<(String, String)> = Vec::new();
91    let Some(root) = root else {
92        return Ok(None);
93    };
94    fn walk(conn: &Connection, tree: &[u8], out: &mut Vec<(String, String)>) -> Result<()> {
95        let entries: Option<String> = conn
96            .query_row(
97                "SELECT entries FROM tree_nodes WHERE hash = ?1",
98                params![tree],
99                |r| r.get(0),
100            )
101            .optional()?;
102        let Some(text) = entries else {
103            return Ok(());
104        };
105        for e in parse_tree_entries(&text)? {
106            let raw = omgbase_store::read::blob_text(conn, &from_hex(&e.raw_hash_hex)?)?;
107            match out.iter_mut().find(|(id, _)| *id == e.block_id) {
108                Some(slot) => slot.1 = raw,
109                None => out.push((e.block_id.clone(), raw)),
110            }
111            if let Some(child) = &e.child_tree_hash_hex {
112                walk(conn, &from_hex(child)?, out)?;
113            }
114        }
115        Ok(())
116    }
117    walk(conn, &root, &mut out)?;
118    Ok(Some(out))
119}
120
121/// §3 `diff`: `removed`, `changed`, `added` entries in that order.
122pub fn diff_blocks(store: &Store, doc_id: &str, from_rev: &str, to_rev: &str) -> Result<Json> {
123    let before = blocks_at_revision(store.conn(), doc_id, from_rev)?;
124    let after = blocks_at_revision(store.conn(), doc_id, to_rev)?;
125    let after_map: HashMap<&str, &str> = after
126        .iter()
127        .map(|(k, v)| (k.as_str(), v.as_str()))
128        .collect();
129    let before_map: HashMap<&str, &str> = before
130        .iter()
131        .map(|(k, v)| (k.as_str(), v.as_str()))
132        .collect();
133    let mut entries = Vec::new();
134    for (id, raw) in &before {
135        match after_map.get(id.as_str()) {
136            None => entries.push(json!({ "kind": "removed", "id": id, "before": raw })),
137            Some(a) if *a != raw => {
138                entries.push(json!({ "kind": "changed", "id": id, "before": raw, "after": a }));
139            }
140            Some(_) => {}
141        }
142    }
143    for (id, raw) in &after {
144        if !before_map.contains_key(id.as_str()) {
145            entries.push(json!({ "kind": "added", "id": id, "after": raw }));
146        }
147    }
148    Ok(Json::Array(entries))
149}
150
151/// §3 `docs_diff_unified`: a unified diff of the two revisions' reconstructed
152/// files — what `docs_read_at` returns (`spec/store` §6.2: leading trivia,
153/// the revision's frontmatter, then each top-level block's raw and trivia,
154/// children not walked since a container's raw already holds them) — via
155/// [`unified_diff`]. Diffing the block map instead (the rendering this
156/// replaced: every live raw at every depth joined by `\n`) repeated each
157/// list item once inside its container's raw and once as its own block, so
158/// an appended bullet showed up twice, in two hunks. An unknown revision is
159/// `target_missing` (§9), exactly as `diff` reports it.
160pub fn diff_unified_text(
161    store: &Store,
162    doc_id: &str,
163    from_rev: &str,
164    to_rev: &str,
165) -> Result<String> {
166    let file_at = |rev: &str| -> Result<String> {
167        store
168            .read_at_revision(doc_id, rev)?
169            .map(|r| r.content)
170            .ok_or_else(|| unknown_revision(doc_id, rev))
171    };
172    Ok(unified_diff(&file_at(from_rev)?, &file_at(to_rev)?))
173}
174
175// ---- unified diff (spec/surface §3) ------------------------------------------
176// A line-grain unified diff with a deterministic Myers script, so both engines
177// (this port and the reference `packages/core/src/graph/history.ts`) produce
178// the same bytes for the same two texts. Pure, so the unit tests pin it.
179
180/// How a text becomes lines: `split('\n')` exactly — no trimming, no dropping
181/// of a trailing empty element (a raw ending in `\n` yields one) — with a single
182/// special case: the empty text has *no* lines (an empty file is zero lines,
183/// not one empty line), so a diff from/to nothing is `@@ -0,0 +1,n @@`.
184pub fn diff_lines(text: &str) -> Vec<&str> {
185    if text.is_empty() {
186        Vec::new()
187    } else {
188        text.split('\n').collect()
189    }
190}
191
192const DIFF_CONTEXT: usize = 3;
193
194/// One step of the edit script: `Keep` consumes a line from both sides,
195/// `Delete` one from the old text, `Insert` one from the new text.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum EditOp<'a> {
198    Keep(&'a str),
199    Delete(&'a str),
200    Insert(&'a str),
201}
202
203/// Myers' O(ND) shortest edit script (forward, with a per-`d` trace for the
204/// backtrack). `V[k]` is the furthest x on diagonal `k = x - y` reachable with
205/// `d` edits. The canonical tie rule, identical in both engines: at each step
206/// take the diagonal from `k+1` (moving down — an insertion of `b[y]`) when
207/// `k == -d || (k != d && V[k-1] < V[k+1])`, else from `k-1` (moving right —
208/// a deletion of `a[x]`). On a tie (`V[k-1] == V[k+1]`) that is the deletion.
209pub fn myers_script<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<EditOp<'a>> {
210    let n = a.len();
211    let m = b.len();
212    let max = n + m;
213    // V is indexed by k ∈ [-max-1, max+1]; `off` maps it onto a plain vector.
214    let off = max + 1;
215    let at = |k: isize| -> usize { (off as isize + k) as usize };
216    let mut v = vec![0usize; 2 * max + 3];
217    let mut trace: Vec<Vec<usize>> = Vec::new();
218    let mut found = false;
219    let mut d: isize = 0;
220    while d <= max as isize && !found {
221        trace.push(v.clone());
222        let mut k = -d;
223        while k <= d {
224            let mut x = if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) {
225                v[at(k + 1)]
226            } else {
227                v[at(k - 1)] + 1
228            };
229            let mut y = (x as isize - k) as usize;
230            while x < n && y < m && a[x] == b[y] {
231                x += 1;
232                y += 1;
233            }
234            v[at(k)] = x;
235            if x >= n && y >= m {
236                found = true;
237                break;
238            }
239            k += 2;
240        }
241        d += 1;
242    }
243    // Backtrack from (n, m) through the trace, emitting ops newest-first.
244    let mut ops: Vec<EditOp<'a>> = Vec::new();
245    let mut x = n;
246    let mut y = m;
247    for (d, vd) in trace.iter().enumerate().rev() {
248        let d = d as isize;
249        let k = x as isize - y as isize;
250        let prev_k = if k == -d || (k != d && vd[at(k - 1)] < vd[at(k + 1)]) {
251            k + 1
252        } else {
253            k - 1
254        };
255        let prev_x = vd[at(prev_k)];
256        let prev_y = prev_x as isize - prev_k;
257        while x > prev_x && y as isize > prev_y {
258            x -= 1;
259            y -= 1;
260            ops.push(EditOp::Keep(a[x]));
261        }
262        if d > 0 {
263            if x == prev_x {
264                ops.push(EditOp::Insert(b[prev_y as usize]));
265            } else {
266                ops.push(EditOp::Delete(a[prev_x]));
267            }
268        }
269        x = prev_x;
270        y = prev_y as usize;
271    }
272    ops.reverse();
273    ops
274}
275
276/// The unified diff of two texts (spec/surface §3): hunks of `DIFF_CONTEXT`
277/// (3) lines of context; a change group extends to include the next change
278/// when fewer than `2 * DIFF_CONTEXT + 1` unchanged lines separate them (the
279/// two contexts touch or overlap). Each hunk is `@@ -a,b +c,d @@` (1-based
280/// start and length; a length of 1 is written as the start alone; a length of
281/// 0 as `a,0` with `a` the line before the insertion point, `0` at the very
282/// top) followed by its lines prefixed `-`, `+` or a space with nothing after
283/// the sign; hunks joined by `\n`; no file header; identical texts → `""`.
284pub fn unified_diff(old_text: &str, new_text: &str) -> String {
285    let ops = myers_script(&diff_lines(old_text), &diff_lines(new_text));
286    // Old/new line counts consumed before each op (0-based positions).
287    let mut old_pos = Vec::with_capacity(ops.len() + 1);
288    let mut new_pos = Vec::with_capacity(ops.len() + 1);
289    let (mut o, mut nn) = (0usize, 0usize);
290    for op in &ops {
291        old_pos.push(o);
292        new_pos.push(nn);
293        if !matches!(op, EditOp::Insert(_)) {
294            o += 1;
295        }
296        if !matches!(op, EditOp::Delete(_)) {
297            nn += 1;
298        }
299    }
300    old_pos.push(o);
301    new_pos.push(nn);
302
303    let changes: Vec<usize> = (0..ops.len())
304        .filter(|&i| !matches!(ops[i], EditOp::Keep(_)))
305        .collect();
306    if changes.is_empty() {
307        return String::new();
308    }
309
310    let range = |pos: usize, len: usize| -> String {
311        let start = if len == 0 { pos } else { pos + 1 };
312        if len == 1 {
313            start.to_string()
314        } else {
315            format!("{start},{len}")
316        }
317    };
318    let mut hunks: Vec<String> = Vec::new();
319    let mut g = 0;
320    while g < changes.len() {
321        let first = changes[g];
322        let mut last = first;
323        // Merge rule: the next change joins this hunk iff the unchanged lines
324        // between them number at most 2 * DIFF_CONTEXT.
325        while g + 1 < changes.len() && changes[g + 1] - last - 1 <= 2 * DIFF_CONTEXT {
326            g += 1;
327            last = changes[g];
328        }
329        g += 1;
330        let start = first.saturating_sub(DIFF_CONTEXT);
331        let end = (last + DIFF_CONTEXT).min(ops.len() - 1);
332        let old_len = old_pos[end + 1] - old_pos[start];
333        let new_len = new_pos[end + 1] - new_pos[start];
334        let mut lines = vec![format!(
335            "@@ -{} +{} @@",
336            range(old_pos[start], old_len),
337            range(new_pos[start], new_len)
338        )];
339        for op in &ops[start..=end] {
340            lines.push(match op {
341                EditOp::Keep(l) => format!(" {l}"),
342                EditOp::Delete(l) => format!("-{l}"),
343                EditOp::Insert(l) => format!("+{l}"),
344            });
345        }
346        hunks.push(lines.join("\n"));
347    }
348    hunks.join("\n")
349}
350
351/// The two most recent revisions of a doc, newest first.
352pub fn recent_revs(conn: &Connection, doc_id: &str) -> Result<Vec<String>> {
353    let mut stmt =
354        conn.prepare("SELECT rev_id FROM revisions WHERE doc_id = ?1 ORDER BY seq DESC LIMIT 2")?;
355    let rows = stmt.query_map(params![doc_id], |r| r.get::<_, String>(0))?;
356    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
357}
358
359/// A `docs` row: `(doc_id, path, current_rev, deleted_commit)`.
360pub type DocRow = (String, String, Option<String>, Option<String>);
361
362/// The `docs` row of a ref, looking through a tombstone when `include_deleted`.
363pub fn resolve_doc_row(
364    conn: &Connection,
365    repo_id: &str,
366    r: &str,
367    include_deleted: bool,
368) -> Result<Option<DocRow>> {
369    type Row = DocRow;
370    let by_id = |id: &str| -> Result<Option<Row>> {
371        Ok(conn
372            .query_row(
373                "SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE doc_id = ?1",
374                params![id],
375                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
376            )
377            .optional()?)
378    };
379    if let Some(info) = find_doc_by_ref(conn, repo_id, r)? {
380        return by_id(&info.doc_id);
381    }
382    if !include_deleted {
383        return Ok(None);
384    }
385    if is_id_ref(r, "d") {
386        return by_id(r);
387    }
388    Ok(conn
389        .query_row(
390            "SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
391            params![repo_id, storage_path(r)],
392            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
393        )
394        .optional()?)
395}
396
397/// §3 `docs_history`: `{ docs: [{ id, path, deleted, currentRev, versions }], truncated }`.
398pub fn docs_history(
399    store: &Store,
400    repo_id: &str,
401    path_glob: Option<&str>,
402    doc: Option<&str>,
403    include_deleted: bool,
404    limit: Option<i64>,
405) -> Result<Json> {
406    let conn = store.conn();
407    let limit = usize::try_from(limit.unwrap_or(50).max(0)).unwrap_or(0);
408    type Row = (String, String, Option<String>, Option<String>);
409    let mut doc_rows: Vec<Row> = if let Some(d) = doc {
410        resolve_doc_row(conn, repo_id, d, include_deleted)?
411            .into_iter()
412            .collect()
413    } else if let Some(glob) = path_glob {
414        let deleted_clause = if include_deleted {
415            ""
416        } else {
417            "AND deleted_commit IS NULL"
418        };
419        // The glob is spelled in either path form; the column holds the storage form.
420        let glob = storage_path(glob);
421        let (path_clause, param) = if glob.contains('*') {
422            (
423                "path LIKE ?2 ESCAPE '\\'",
424                crate::context::glob_to_like(glob, false),
425            )
426        } else {
427            ("path = ?2", glob.to_owned())
428        };
429        let sql = format!(
430            "SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE repo_id = ?1 AND {path_clause} {deleted_clause} ORDER BY path LIMIT ?3"
431        );
432        let mut stmt = conn.prepare(&sql)?;
433        let rows = stmt.query_map(
434            params![
435                repo_id,
436                param,
437                i64::try_from(limit).unwrap_or(i64::MAX).saturating_add(1)
438            ],
439            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
440        )?;
441        rows.collect::<std::result::Result<Vec<_>, _>>()?
442    } else {
443        return Err(crate::error::SurfaceError::other(
444            "docHistory requires one of { doc, pathGlob }",
445        ));
446    };
447    let truncated = doc_rows.len() > limit;
448    doc_rows.truncate(limit);
449    let mut rev_stmt = conn.prepare(
450        "SELECT r.rev_id, r.seq, r.commit_id, r.rendered_hash, c.ts, c.origin, c.actor
451         FROM revisions r JOIN commits c ON c.commit_id = r.commit_id
452         WHERE r.doc_id = ?1 ORDER BY r.seq ASC",
453    )?;
454    let mut docs = Vec::new();
455    for (doc_id, path, current_rev, deleted_commit) in doc_rows {
456        let versions: Vec<Json> = rev_stmt
457            .query_map(params![doc_id], |r| {
458                let rev: String = r.get(0)?;
459                let hash: Vec<u8> = r.get(3)?;
460                Ok(json!({
461                    "rev": rev,
462                    "seq": r.get::<_, i64>(1)?,
463                    "commit": r.get::<_, String>(2)?,
464                    "updated_at": r.get::<_, String>(4)?,
465                    "origin": r.get::<_, String>(5)?,
466                    "actor": r.get::<_, Option<String>>(6)?,
467                    "hash": hex(&hash),
468                    "isCurrent": current_rev.as_deref() == Some(rev.as_str()),
469                }))
470            })?
471            .collect::<std::result::Result<_, _>>()?;
472        let mut m = Map::new();
473        m.insert("id".to_owned(), json!(doc_id));
474        m.insert("path".to_owned(), json!(reference_path(&path)));
475        m.insert("deleted".to_owned(), json!(deleted_commit.is_some()));
476        m.insert("currentRev".to_owned(), json!(current_rev));
477        m.insert("versions".to_owned(), Json::Array(versions));
478        docs.push(Json::Object(m));
479    }
480    Ok(json!({ "docs": docs, "truncated": truncated }))
481}
482
483// spec/surface §3 `docs_diff_unified`. The expected strings below are copied
484// verbatim from packages/core/src/graph/history.test.ts so the two engines pin
485// each other byte for byte.
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    const EIGHT: &str = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8";
491    const TWELVE: &str = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12";
492
493    #[test]
494    fn splits_on_newline_exactly_and_the_empty_text_has_no_lines() {
495        assert_eq!(diff_lines("a\nb"), vec!["a", "b"]);
496        assert_eq!(diff_lines("a\nb\n"), vec!["a", "b", ""]);
497        assert_eq!(diff_lines(" a \n\n b "), vec![" a ", "", " b "]);
498        assert_eq!(diff_lines(""), Vec::<&str>::new());
499    }
500
501    #[test]
502    fn identical_texts_are_empty() {
503        assert_eq!(unified_diff("a\nb\nc", "a\nb\nc"), "");
504        assert_eq!(unified_diff("", ""), "");
505    }
506
507    #[test]
508    fn insertion_in_the_middle_is_one_plus_line() {
509        assert_eq!(
510            unified_diff(EIGHT, "l1\nl2\nl3\nl4\nNEW\nl5\nl6\nl7\nl8"),
511            "@@ -2,6 +2,7 @@\n l2\n l3\n l4\n+NEW\n l5\n l6\n l7"
512        );
513    }
514
515    #[test]
516    fn deletion() {
517        assert_eq!(
518            unified_diff(EIGHT, "l1\nl2\nl3\nl4\nl6\nl7\nl8"),
519            "@@ -2,7 +2,6 @@\n l2\n l3\n l4\n-l5\n l6\n l7\n l8"
520        );
521    }
522
523    #[test]
524    fn replacement() {
525        assert_eq!(
526            unified_diff(EIGHT, "l1\nl2\nl3\nl4\nX5\nl6\nl7\nl8"),
527            "@@ -2,7 +2,7 @@\n l2\n l3\n l4\n-l5\n+X5\n l6\n l7\n l8"
528        );
529    }
530
531    #[test]
532    fn change_at_top_and_bottom_truncates_context() {
533        assert_eq!(
534            unified_diff("l1\nl2\nl3\nl4\nl5", "L1\nl2\nl3\nl4\nl5"),
535            "@@ -1,4 +1,4 @@\n-l1\n+L1\n l2\n l3\n l4"
536        );
537        assert_eq!(
538            unified_diff("l1\nl2\nl3\nl4\nl5", "l1\nl2\nl3\nl4\nL5"),
539            "@@ -2,4 +2,4 @@\n l2\n l3\n l4\n-l5\n+L5"
540        );
541    }
542
543    #[test]
544    fn far_changes_are_two_hunks_near_changes_share_one() {
545        assert_eq!(
546            unified_diff(TWELVE, "L1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nL12"),
547            "@@ -1,4 +1,4 @@\n-l1\n+L1\n l2\n l3\n l4\n@@ -9,4 +9,4 @@\n l9\n l10\n l11\n-l12\n+L12"
548        );
549        assert_eq!(
550            unified_diff(TWELVE, "L1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nL9\nl10\nl11\nl12"),
551            "@@ -1,4 +1,4 @@\n-l1\n+L1\n l2\n l3\n l4\n@@ -6,7 +6,7 @@\n l6\n l7\n l8\n-l9\n+L9\n l10\n l11\n l12"
552        );
553    }
554
555    #[test]
556    fn hunk_merge_boundary_six_between_merges_seven_splits() {
557        assert_eq!(
558            unified_diff(
559                "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10",
560                "L1\nl2\nl3\nl4\nl5\nl6\nl7\nL8\nl9\nl10"
561            ),
562            "@@ -1,10 +1,10 @@\n-l1\n+L1\n l2\n l3\n l4\n l5\n l6\n l7\n-l8\n+L8\n l9\n l10"
563        );
564        assert_eq!(
565            unified_diff(
566                "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11",
567                "L1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nL9\nl10\nl11"
568            ),
569            "@@ -1,4 +1,4 @@\n-l1\n+L1\n l2\n l3\n l4\n@@ -6,6 +6,6 @@\n l6\n l7\n l8\n-l9\n+L9\n l10\n l11"
570        );
571    }
572
573    #[test]
574    fn empty_old_and_empty_new_texts() {
575        assert_eq!(unified_diff("", "a\nb\nc"), "@@ -0,0 +1,3 @@\n+a\n+b\n+c");
576        assert_eq!(unified_diff("a\nb\nc", ""), "@@ -1,3 +0,0 @@\n-a\n-b\n-c");
577    }
578
579    #[test]
580    fn insertion_at_the_very_top() {
581        assert_eq!(
582            unified_diff("a\nb", "z\na\nb"),
583            "@@ -1,2 +1,3 @@\n+z\n a\n b"
584        );
585    }
586
587    #[test]
588    fn trailing_newline_and_empty_lines_are_lines() {
589        assert_eq!(unified_diff("a\nb\n", "a\nb"), "@@ -1,3 +1,2 @@\n a\n b\n-");
590        assert_eq!(
591            unified_diff("a\n\nb", "a\n\n\nb"),
592            "@@ -1,3 +1,4 @@\n a\n \n+\n b"
593        );
594    }
595
596    // ---- docs_diff_unified over a store --------------------------------------------
597    // The bug this pins: a list container's raw already carries every item, so
598    // diffing "every live raw at every depth" showed an appended bullet twice
599    // (at the end of the container's raw, reading as an insertion before the
600    // first item, then again after the last item block). The diff must be over
601    // the reconstructed file, exactly what `docs_read_at` returns.
602
603    use omgbase_reconcile::Config;
604    use omgbase_store::SequentialMinter;
605
606    const REV1: &str = "# Notes\n\n## Log\n\n- first\n\n- second\n";
607    const REV2: &str = "# Notes\n\n## Log\n\n- first\n\n- second\n\n- third\n- fourth\n";
608    const REV3: &str = "# Notes\n\n## Log\n\n- first\n\n- second\n\n- third\n- fourth\n- fifth\n";
609
610    /// A store with `log.md` observed three times (a heading over a loose
611    /// list, then a tight pair appended, then one more bullet); returns the
612    /// store, the doc id and the three rev ids in order.
613    fn log_doc_store() -> (Store, String, Vec<String>) {
614        let mut store =
615            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
616        let repo = store.create_repo("fixture").unwrap();
617        let mut revs = Vec::new();
618        let mut doc_id = String::new();
619        for (i, source) in [REV1, REV2, REV3].iter().enumerate() {
620            let ts = format!("2026-09-30T10:0{i}:00.000Z");
621            let out = store
622                .observe_one(&repo, "log.md", source, &ts, &Config::default())
623                .unwrap();
624            assert!(!out.echo, "revision {i} must commit");
625            doc_id = out.doc_id;
626            revs.push(out.rev.expect("a commit has a rev"));
627        }
628        (store, doc_id, revs)
629    }
630
631    #[test]
632    fn diff_unified_is_over_the_reconstructed_file_not_the_block_map() {
633        let (store, doc_id, revs) = log_doc_store();
634        // The two sides are byte for byte what `docs_read_at` returns.
635        for (rev, source) in revs.iter().zip([REV1, REV2, REV3]) {
636            let read = store.read_at_revision(&doc_id, rev).unwrap().unwrap();
637            assert_eq!(read.content, source);
638            assert!(read.rendered_hash_match);
639        }
640        let diff = diff_unified_text(&store, &doc_id, &revs[1], &revs[2]).unwrap();
641        assert_eq!(diff, unified_diff(REV2, REV3));
642        // Exactly one hunk, one `+` line, no `-` lines, the bullet once and
643        // after the previous last item.
644        assert_eq!(diff.matches("@@").count(), 2, "one hunk header: {diff}");
645        let lines: Vec<&str> = diff.lines().collect();
646        let plus: Vec<&str> = lines
647            .iter()
648            .copied()
649            .filter(|l| l.starts_with('+'))
650            .collect();
651        assert_eq!(plus, vec!["+- fifth"], "{diff}");
652        assert!(!lines.iter().any(|l| l.starts_with('-')), "{diff}");
653        assert_eq!(diff.matches("- fifth").count(), 1, "{diff}");
654        let fourth = lines.iter().position(|l| *l == " - fourth").unwrap();
655        let fifth = lines.iter().position(|l| *l == "+- fifth").unwrap();
656        assert_eq!(fifth, fourth + 1, "{diff}");
657        assert_eq!(diff, "@@ -8,4 +8,5 @@\n \n - third\n - fourth\n+- fifth\n ");
658        // The first append (a tight pair onto a loose list) reads the same way.
659        let first = diff_unified_text(&store, &doc_id, &revs[0], &revs[1]).unwrap();
660        assert_eq!(first, unified_diff(REV1, REV2));
661        assert_eq!(first.matches("- third").count(), 1, "{first}");
662        assert_eq!(first.matches("- fourth").count(), 1, "{first}");
663        assert_eq!(first.matches("@@").count(), 2, "one hunk header: {first}");
664        // Same revision on both sides: nothing.
665        assert_eq!(
666            diff_unified_text(&store, &doc_id, &revs[2], &revs[2]).unwrap(),
667            ""
668        );
669    }
670
671    #[test]
672    fn diff_unified_unknown_revision_is_target_missing_like_diff() {
673        let (store, doc_id, revs) = log_doc_store();
674        let err = diff_unified_text(&store, &doc_id, &revs[0], "rv_nope").unwrap_err();
675        assert_eq!(err.code, "target_missing");
676        assert_eq!(
677            err.message,
678            format!("no revision \"rv_nope\" for document {doc_id}")
679        );
680        assert_eq!(err.data, Some(json!({ "doc": doc_id, "rev": "rv_nope" })));
681        // Byte-identical to what the block-grain `diff` reports.
682        let block_err = diff_blocks(&store, &doc_id, &revs[0], "rv_nope").unwrap_err();
683        assert_eq!(err, block_err);
684    }
685
686    #[test]
687    fn textbook_tie_case() {
688        let script: Vec<String> = myers_script(
689            &diff_lines("a\nb\nc\na\nb\nb\na"),
690            &diff_lines("c\nb\na\nb\na\nc"),
691        )
692        .into_iter()
693        .map(|op| match op {
694            EditOp::Keep(l) => format!(" {l}"),
695            EditOp::Delete(l) => format!("-{l}"),
696            EditOp::Insert(l) => format!("+{l}"),
697        })
698        .collect();
699        assert_eq!(script.join("|"), "-a|-b| c|+b| a| b|-b| a|+c");
700        assert_eq!(
701            unified_diff("a\nb\nc\na\nb\nb\na", "c\nb\na\nb\na\nc"),
702            "@@ -1,7 +1,6 @@\n-a\n-b\n c\n+b\n a\n b\n-b\n a\n+c"
703        );
704    }
705}