1use 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
18pub fn history_node(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
47fn 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
61fn 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
76fn 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
121pub 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", "blockId": id, "before": raw })),
137 Some(a) if *a != raw => {
138 entries
139 .push(json!({ "kind": "changed", "blockId": id, "before": raw, "after": a }));
140 }
141 Some(_) => {}
142 }
143 }
144 for (id, raw) in &after {
145 if !before_map.contains_key(id.as_str()) {
146 entries.push(json!({ "kind": "added", "blockId": id, "after": raw }));
147 }
148 }
149 Ok(Json::Array(entries))
150}
151
152pub fn diff_unified_text(
162 store: &Store,
163 doc_id: &str,
164 from_rev: &str,
165 to_rev: &str,
166) -> Result<String> {
167 let file_at = |rev: &str| -> Result<String> {
168 store
169 .read_at_revision(doc_id, rev)?
170 .map(|r| r.content)
171 .ok_or_else(|| unknown_revision(doc_id, rev))
172 };
173 Ok(unified_diff(&file_at(from_rev)?, &file_at(to_rev)?))
174}
175
176pub fn diff_lines(text: &str) -> Vec<&str> {
186 if text.is_empty() {
187 Vec::new()
188 } else {
189 text.split('\n').collect()
190 }
191}
192
193const DIFF_CONTEXT: usize = 3;
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum EditOp<'a> {
199 Keep(&'a str),
200 Delete(&'a str),
201 Insert(&'a str),
202}
203
204pub fn myers_script<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<EditOp<'a>> {
211 let n = a.len();
212 let m = b.len();
213 let max = n + m;
214 let off = max + 1;
216 let at = |k: isize| -> usize { (off as isize + k) as usize };
217 let mut v = vec![0usize; 2 * max + 3];
218 let mut trace: Vec<Vec<usize>> = Vec::new();
219 let mut found = false;
220 let mut d: isize = 0;
221 while d <= max as isize && !found {
222 trace.push(v.clone());
223 let mut k = -d;
224 while k <= d {
225 let mut x = if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) {
226 v[at(k + 1)]
227 } else {
228 v[at(k - 1)] + 1
229 };
230 let mut y = (x as isize - k) as usize;
231 while x < n && y < m && a[x] == b[y] {
232 x += 1;
233 y += 1;
234 }
235 v[at(k)] = x;
236 if x >= n && y >= m {
237 found = true;
238 break;
239 }
240 k += 2;
241 }
242 d += 1;
243 }
244 let mut ops: Vec<EditOp<'a>> = Vec::new();
246 let mut x = n;
247 let mut y = m;
248 for (d, vd) in trace.iter().enumerate().rev() {
249 let d = d as isize;
250 let k = x as isize - y as isize;
251 let prev_k = if k == -d || (k != d && vd[at(k - 1)] < vd[at(k + 1)]) {
252 k + 1
253 } else {
254 k - 1
255 };
256 let prev_x = vd[at(prev_k)];
257 let prev_y = prev_x as isize - prev_k;
258 while x > prev_x && y as isize > prev_y {
259 x -= 1;
260 y -= 1;
261 ops.push(EditOp::Keep(a[x]));
262 }
263 if d > 0 {
264 if x == prev_x {
265 ops.push(EditOp::Insert(b[prev_y as usize]));
266 } else {
267 ops.push(EditOp::Delete(a[prev_x]));
268 }
269 }
270 x = prev_x;
271 y = prev_y as usize;
272 }
273 ops.reverse();
274 ops
275}
276
277pub fn unified_diff(old_text: &str, new_text: &str) -> String {
286 let ops = myers_script(&diff_lines(old_text), &diff_lines(new_text));
287 let mut old_pos = Vec::with_capacity(ops.len() + 1);
289 let mut new_pos = Vec::with_capacity(ops.len() + 1);
290 let (mut o, mut nn) = (0usize, 0usize);
291 for op in &ops {
292 old_pos.push(o);
293 new_pos.push(nn);
294 if !matches!(op, EditOp::Insert(_)) {
295 o += 1;
296 }
297 if !matches!(op, EditOp::Delete(_)) {
298 nn += 1;
299 }
300 }
301 old_pos.push(o);
302 new_pos.push(nn);
303
304 let changes: Vec<usize> = (0..ops.len())
305 .filter(|&i| !matches!(ops[i], EditOp::Keep(_)))
306 .collect();
307 if changes.is_empty() {
308 return String::new();
309 }
310
311 let range = |pos: usize, len: usize| -> String {
312 let start = if len == 0 { pos } else { pos + 1 };
313 if len == 1 {
314 start.to_string()
315 } else {
316 format!("{start},{len}")
317 }
318 };
319 let mut hunks: Vec<String> = Vec::new();
320 let mut g = 0;
321 while g < changes.len() {
322 let first = changes[g];
323 let mut last = first;
324 while g + 1 < changes.len() && changes[g + 1] - last - 1 <= 2 * DIFF_CONTEXT {
327 g += 1;
328 last = changes[g];
329 }
330 g += 1;
331 let start = first.saturating_sub(DIFF_CONTEXT);
332 let end = (last + DIFF_CONTEXT).min(ops.len() - 1);
333 let old_len = old_pos[end + 1] - old_pos[start];
334 let new_len = new_pos[end + 1] - new_pos[start];
335 let mut lines = vec![format!(
336 "@@ -{} +{} @@",
337 range(old_pos[start], old_len),
338 range(new_pos[start], new_len)
339 )];
340 for op in &ops[start..=end] {
341 lines.push(match op {
342 EditOp::Keep(l) => format!(" {l}"),
343 EditOp::Delete(l) => format!("-{l}"),
344 EditOp::Insert(l) => format!("+{l}"),
345 });
346 }
347 hunks.push(lines.join("\n"));
348 }
349 hunks.join("\n")
350}
351
352pub fn recent_revs(conn: &Connection, doc_id: &str) -> Result<Vec<String>> {
354 let mut stmt =
355 conn.prepare("SELECT rev_id FROM revisions WHERE doc_id = ?1 ORDER BY seq DESC LIMIT 2")?;
356 let rows = stmt.query_map(params![doc_id], |r| r.get::<_, String>(0))?;
357 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
358}
359
360pub type DocRow = (String, String, Option<String>, Option<String>);
362
363pub fn resolve_doc_row(
365 conn: &Connection,
366 repo_id: &str,
367 r: &str,
368 include_deleted: bool,
369) -> Result<Option<DocRow>> {
370 type Row = DocRow;
371 let by_id = |id: &str| -> Result<Option<Row>> {
372 Ok(conn
373 .query_row(
374 "SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE doc_id = ?1",
375 params![id],
376 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
377 )
378 .optional()?)
379 };
380 if let Some(info) = find_doc_by_ref(conn, repo_id, r)? {
381 return by_id(&info.doc_id);
382 }
383 if !include_deleted {
384 return Ok(None);
385 }
386 if is_id_ref(r, "d") {
387 return by_id(r);
388 }
389 Ok(conn
390 .query_row(
391 "SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
392 params![repo_id, storage_path(r)],
393 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
394 )
395 .optional()?)
396}
397
398pub fn docs_history(
400 store: &Store,
401 repo_id: &str,
402 path_glob: Option<&str>,
403 doc: Option<&str>,
404 include_deleted: bool,
405 limit: Option<i64>,
406) -> Result<Json> {
407 let conn = store.conn();
408 let limit = usize::try_from(limit.unwrap_or(50).max(0)).unwrap_or(0);
409 type Row = (String, String, Option<String>, Option<String>);
410 let mut doc_rows: Vec<Row> = if let Some(d) = doc {
411 resolve_doc_row(conn, repo_id, d, include_deleted)?
412 .into_iter()
413 .collect()
414 } else if let Some(glob) = path_glob {
415 let deleted_clause = if include_deleted {
416 ""
417 } else {
418 "AND deleted_commit IS NULL"
419 };
420 let glob = storage_path(glob);
422 let (path_clause, param) = if glob.contains('*') {
423 (
424 "path LIKE ?2 ESCAPE '\\'",
425 crate::context::glob_to_like(glob, false),
426 )
427 } else {
428 ("path = ?2", glob.to_owned())
429 };
430 let sql = format!(
431 "SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE repo_id = ?1 AND {path_clause} {deleted_clause} ORDER BY path LIMIT ?3"
432 );
433 let mut stmt = conn.prepare(&sql)?;
434 let rows = stmt.query_map(
435 params![
436 repo_id,
437 param,
438 i64::try_from(limit).unwrap_or(i64::MAX).saturating_add(1)
439 ],
440 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
441 )?;
442 rows.collect::<std::result::Result<Vec<_>, _>>()?
443 } else {
444 return Err(crate::error::SurfaceError::other(
445 "docHistory requires one of { doc, pathGlob }",
446 ));
447 };
448 let truncated = doc_rows.len() > limit;
449 doc_rows.truncate(limit);
450 let mut rev_stmt = conn.prepare(
451 "SELECT r.rev_id, r.seq, r.commit_id, r.rendered_hash, c.ts, c.origin, c.actor
452 FROM revisions r JOIN commits c ON c.commit_id = r.commit_id
453 WHERE r.doc_id = ?1 ORDER BY r.seq ASC",
454 )?;
455 let mut docs = Vec::new();
456 for (doc_id, path, current_rev, deleted_commit) in doc_rows {
457 let versions: Vec<Json> = rev_stmt
458 .query_map(params![doc_id], |r| {
459 let rev: String = r.get(0)?;
460 let hash: Vec<u8> = r.get(3)?;
461 Ok(json!({
462 "rev": rev,
463 "seq": r.get::<_, i64>(1)?,
464 "commit": r.get::<_, String>(2)?,
465 "ts": r.get::<_, String>(4)?,
466 "origin": r.get::<_, String>(5)?,
467 "actor": r.get::<_, Option<String>>(6)?,
468 "contentHash": hex(&hash),
469 "isCurrent": current_rev.as_deref() == Some(rev.as_str()),
470 }))
471 })?
472 .collect::<std::result::Result<_, _>>()?;
473 let mut m = Map::new();
474 m.insert("docId".to_owned(), json!(doc_id));
475 m.insert("path".to_owned(), json!(reference_path(&path)));
476 m.insert("deleted".to_owned(), json!(deleted_commit.is_some()));
477 m.insert("currentRev".to_owned(), json!(current_rev));
478 m.insert("versions".to_owned(), Json::Array(versions));
479 docs.push(Json::Object(m));
480 }
481 Ok(json!({ "docs": docs, "truncated": truncated }))
482}
483
484#[cfg(test)]
488mod tests {
489 use super::*;
490
491 const EIGHT: &str = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8";
492 const TWELVE: &str = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12";
493
494 #[test]
495 fn splits_on_newline_exactly_and_the_empty_text_has_no_lines() {
496 assert_eq!(diff_lines("a\nb"), vec!["a", "b"]);
497 assert_eq!(diff_lines("a\nb\n"), vec!["a", "b", ""]);
498 assert_eq!(diff_lines(" a \n\n b "), vec![" a ", "", " b "]);
499 assert_eq!(diff_lines(""), Vec::<&str>::new());
500 }
501
502 #[test]
503 fn identical_texts_are_empty() {
504 assert_eq!(unified_diff("a\nb\nc", "a\nb\nc"), "");
505 assert_eq!(unified_diff("", ""), "");
506 }
507
508 #[test]
509 fn insertion_in_the_middle_is_one_plus_line() {
510 assert_eq!(
511 unified_diff(EIGHT, "l1\nl2\nl3\nl4\nNEW\nl5\nl6\nl7\nl8"),
512 "@@ -2,6 +2,7 @@\n l2\n l3\n l4\n+NEW\n l5\n l6\n l7"
513 );
514 }
515
516 #[test]
517 fn deletion() {
518 assert_eq!(
519 unified_diff(EIGHT, "l1\nl2\nl3\nl4\nl6\nl7\nl8"),
520 "@@ -2,7 +2,6 @@\n l2\n l3\n l4\n-l5\n l6\n l7\n l8"
521 );
522 }
523
524 #[test]
525 fn replacement() {
526 assert_eq!(
527 unified_diff(EIGHT, "l1\nl2\nl3\nl4\nX5\nl6\nl7\nl8"),
528 "@@ -2,7 +2,7 @@\n l2\n l3\n l4\n-l5\n+X5\n l6\n l7\n l8"
529 );
530 }
531
532 #[test]
533 fn change_at_top_and_bottom_truncates_context() {
534 assert_eq!(
535 unified_diff("l1\nl2\nl3\nl4\nl5", "L1\nl2\nl3\nl4\nl5"),
536 "@@ -1,4 +1,4 @@\n-l1\n+L1\n l2\n l3\n l4"
537 );
538 assert_eq!(
539 unified_diff("l1\nl2\nl3\nl4\nl5", "l1\nl2\nl3\nl4\nL5"),
540 "@@ -2,4 +2,4 @@\n l2\n l3\n l4\n-l5\n+L5"
541 );
542 }
543
544 #[test]
545 fn far_changes_are_two_hunks_near_changes_share_one() {
546 assert_eq!(
547 unified_diff(TWELVE, "L1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nL12"),
548 "@@ -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"
549 );
550 assert_eq!(
551 unified_diff(TWELVE, "L1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nL9\nl10\nl11\nl12"),
552 "@@ -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"
553 );
554 }
555
556 #[test]
557 fn hunk_merge_boundary_six_between_merges_seven_splits() {
558 assert_eq!(
559 unified_diff(
560 "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10",
561 "L1\nl2\nl3\nl4\nl5\nl6\nl7\nL8\nl9\nl10"
562 ),
563 "@@ -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"
564 );
565 assert_eq!(
566 unified_diff(
567 "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11",
568 "L1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nL9\nl10\nl11"
569 ),
570 "@@ -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"
571 );
572 }
573
574 #[test]
575 fn empty_old_and_empty_new_texts() {
576 assert_eq!(unified_diff("", "a\nb\nc"), "@@ -0,0 +1,3 @@\n+a\n+b\n+c");
577 assert_eq!(unified_diff("a\nb\nc", ""), "@@ -1,3 +0,0 @@\n-a\n-b\n-c");
578 }
579
580 #[test]
581 fn insertion_at_the_very_top() {
582 assert_eq!(
583 unified_diff("a\nb", "z\na\nb"),
584 "@@ -1,2 +1,3 @@\n+z\n a\n b"
585 );
586 }
587
588 #[test]
589 fn trailing_newline_and_empty_lines_are_lines() {
590 assert_eq!(unified_diff("a\nb\n", "a\nb"), "@@ -1,3 +1,2 @@\n a\n b\n-");
591 assert_eq!(
592 unified_diff("a\n\nb", "a\n\n\nb"),
593 "@@ -1,3 +1,4 @@\n a\n \n+\n b"
594 );
595 }
596
597 use omgbase_reconcile::Config;
605 use omgbase_store::SequentialMinter;
606
607 const REV1: &str = "# Notes\n\n## Log\n\n- first\n\n- second\n";
608 const REV2: &str = "# Notes\n\n## Log\n\n- first\n\n- second\n\n- third\n- fourth\n";
609 const REV3: &str = "# Notes\n\n## Log\n\n- first\n\n- second\n\n- third\n- fourth\n- fifth\n";
610
611 fn log_doc_store() -> (Store, String, Vec<String>) {
615 let mut store =
616 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
617 let repo = store.create_repo("fixture").unwrap();
618 let mut revs = Vec::new();
619 let mut doc_id = String::new();
620 for (i, source) in [REV1, REV2, REV3].iter().enumerate() {
621 let ts = format!("2026-09-30T10:0{i}:00.000Z");
622 let out = store
623 .observe_one(&repo, "log.md", source, &ts, &Config::default())
624 .unwrap();
625 assert!(!out.echo, "revision {i} must commit");
626 doc_id = out.doc_id;
627 revs.push(out.rev.expect("a commit has a rev"));
628 }
629 (store, doc_id, revs)
630 }
631
632 #[test]
633 fn diff_unified_is_over_the_reconstructed_file_not_the_block_map() {
634 let (store, doc_id, revs) = log_doc_store();
635 for (rev, source) in revs.iter().zip([REV1, REV2, REV3]) {
637 let read = store.read_at_revision(&doc_id, rev).unwrap().unwrap();
638 assert_eq!(read.content, source);
639 assert!(read.rendered_hash_match);
640 }
641 let diff = diff_unified_text(&store, &doc_id, &revs[1], &revs[2]).unwrap();
642 assert_eq!(diff, unified_diff(REV2, REV3));
643 assert_eq!(diff.matches("@@").count(), 2, "one hunk header: {diff}");
646 let lines: Vec<&str> = diff.lines().collect();
647 let plus: Vec<&str> = lines
648 .iter()
649 .copied()
650 .filter(|l| l.starts_with('+'))
651 .collect();
652 assert_eq!(plus, vec!["+- fifth"], "{diff}");
653 assert!(!lines.iter().any(|l| l.starts_with('-')), "{diff}");
654 assert_eq!(diff.matches("- fifth").count(), 1, "{diff}");
655 let fourth = lines.iter().position(|l| *l == " - fourth").unwrap();
656 let fifth = lines.iter().position(|l| *l == "+- fifth").unwrap();
657 assert_eq!(fifth, fourth + 1, "{diff}");
658 assert_eq!(diff, "@@ -8,4 +8,5 @@\n \n - third\n - fourth\n+- fifth\n ");
659 let first = diff_unified_text(&store, &doc_id, &revs[0], &revs[1]).unwrap();
661 assert_eq!(first, unified_diff(REV1, REV2));
662 assert_eq!(first.matches("- third").count(), 1, "{first}");
663 assert_eq!(first.matches("- fourth").count(), 1, "{first}");
664 assert_eq!(first.matches("@@").count(), 2, "one hunk header: {first}");
665 assert_eq!(
667 diff_unified_text(&store, &doc_id, &revs[2], &revs[2]).unwrap(),
668 ""
669 );
670 }
671
672 #[test]
673 fn diff_unified_unknown_revision_is_target_missing_like_diff() {
674 let (store, doc_id, revs) = log_doc_store();
675 let err = diff_unified_text(&store, &doc_id, &revs[0], "rv_nope").unwrap_err();
676 assert_eq!(err.code, "target_missing");
677 assert_eq!(
678 err.message,
679 format!("no revision \"rv_nope\" for document {doc_id}")
680 );
681 assert_eq!(err.data, Some(json!({ "doc": doc_id, "rev": "rv_nope" })));
682 let block_err = diff_blocks(&store, &doc_id, &revs[0], "rv_nope").unwrap_err();
684 assert_eq!(err, block_err);
685 }
686
687 #[test]
688 fn textbook_tie_case() {
689 let script: Vec<String> = myers_script(
690 &diff_lines("a\nb\nc\na\nb\nb\na"),
691 &diff_lines("c\nb\na\nb\na\nc"),
692 )
693 .into_iter()
694 .map(|op| match op {
695 EditOp::Keep(l) => format!(" {l}"),
696 EditOp::Delete(l) => format!("-{l}"),
697 EditOp::Insert(l) => format!("+{l}"),
698 })
699 .collect();
700 assert_eq!(script.join("|"), "-a|-b| c|+b| a| b|-b| a|+c");
701 assert_eq!(
702 unified_diff("a\nb\nc\na\nb\nb\na", "c\nb\na\nb\na\nc"),
703 "@@ -1,7 +1,6 @@\n-a\n-b\n c\n+b\n a\n b\n-b\n a\n+c"
704 );
705 }
706}