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::read::find_doc_by_ref;
16
17pub 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
46fn 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
66fn 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
111pub 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
142pub 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
160pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum EditOp<'a> {
183 Keep(&'a str),
184 Delete(&'a str),
185 Insert(&'a str),
186}
187
188pub 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 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 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
261pub fn unified_diff(old_text: &str, new_text: &str) -> String {
270 let ops = myers_script(&diff_lines(old_text), &diff_lines(new_text));
271 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 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
336pub 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
344pub type DocRow = (String, String, Option<String>, Option<String>);
346
347pub 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
382pub 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#[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}