Skip to main content

omgbase_surface/
history.rs

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