1use std::collections::{BTreeMap, HashMap, HashSet};
7
8use omgbase_format::Block;
9use omgbase_reconcile::json::detail_to_json;
10use omgbase_reconcile::{Disposition, DispositionKind};
11use serde_json::Value;
12
13use crate::changeset::Op;
14use crate::ops::{At, Expect, Parent, To};
15use crate::opset::{PlanDisposition, PlanOp};
16use crate::tree::{MutBlock, MutDoc, raw_hash_hex};
17
18#[derive(Clone, Debug, PartialEq)]
20pub struct LowerResult {
21 pub ops: Vec<PlanOp>,
22 pub preserved: usize,
23 pub ambiguous: usize,
24}
25
26struct TargetBlock {
27 id: String,
28 raw: String,
29 trivia: String,
30 children: Vec<TargetBlock>,
31 carried: bool,
33}
34
35fn build_target(
36 blocks: &[Block],
37 assignment: &BTreeMap<String, String>,
38 old_ids: &HashSet<String>,
39 parent_key: Option<&str>,
40) -> Vec<TargetBlock> {
41 blocks
42 .iter()
43 .enumerate()
44 .map(|(index, b)| {
45 let key = format!("{}/{index}", parent_key.unwrap_or(""));
46 let id = assignment.get(&key).cloned().unwrap_or_else(|| key.clone());
47 TargetBlock {
48 carried: old_ids.contains(&id),
49 raw: b.raw.clone(),
50 trivia: b.trivia.clone(),
51 children: build_target(&b.children, assignment, old_ids, Some(&key)),
52 id,
53 }
54 })
55 .collect()
56}
57
58fn collect_target_ids(list: &[TargetBlock], out: &mut HashSet<String>) {
59 for b in list {
60 out.insert(b.id.clone());
61 collect_target_ids(&b.children, out);
62 }
63}
64
65#[must_use]
68pub fn lcs(a: &[String], b: &[String]) -> HashSet<String> {
69 let (n, m) = (a.len(), b.len());
70 let mut dp = vec![vec![0usize; m + 1]; n + 1];
71 for i in (0..n).rev() {
72 for j in (0..m).rev() {
73 dp[i][j] = if a[i] == b[j] {
74 dp[i + 1][j + 1] + 1
75 } else {
76 dp[i + 1][j].max(dp[i][j + 1])
77 };
78 }
79 }
80 let mut keep = HashSet::new();
81 let (mut i, mut j) = (0, 0);
82 while i < n && j < m {
83 if a[i] == b[j] {
84 keep.insert(a[i].clone());
85 i += 1;
86 j += 1;
87 } else if dp[i + 1][j] >= dp[i][j + 1] {
88 i += 1;
89 } else {
90 j += 1;
91 }
92 }
93 keep
94}
95
96struct Meta {
97 confidence: Option<f64>,
98 reason: Option<String>,
99 detail: Option<Value>,
100}
101
102fn meta_of(disp: Option<&Disposition>) -> Meta {
103 match disp {
104 Some(d) => Meta {
105 confidence: d.confidence,
106 reason: d.reason.map(|r| r.as_str().to_owned()),
107 detail: Some(detail_to_json(&d.detail)),
108 },
109 None => Meta {
110 confidence: None,
111 reason: None,
112 detail: None,
113 },
114 }
115}
116
117fn carried_child_id_map(
119 container: &TargetBlock,
120 old_ids: &HashSet<String>,
121) -> BTreeMap<String, String> {
122 fn walk(
123 children: &[TargetBlock],
124 parent_key: &str,
125 old_ids: &HashSet<String>,
126 out: &mut BTreeMap<String, String>,
127 ) {
128 for (i, c) in children.iter().enumerate() {
129 let key = format!("{parent_key}/{i}");
130 if old_ids.contains(&c.id) {
131 out.insert(key.clone(), c.id.clone());
132 }
133 if !c.children.is_empty() {
134 walk(&c.children, &key, old_ids, out);
135 }
136 }
137 }
138 let mut out = BTreeMap::new();
139 walk(&container.children, "", old_ids, &mut out);
140 out
141}
142
143fn disposition_for_new(kind: Option<DispositionKind>) -> PlanDisposition {
144 match kind {
145 Some(DispositionKind::CopiedFrom) => PlanDisposition::CopiedFrom,
146 Some(DispositionKind::Resurrected) => PlanDisposition::Resurrected,
147 Some(DispositionKind::SplitFrom) => PlanDisposition::SplitFrom,
148 _ => PlanDisposition::Inserted,
149 }
150}
151
152fn top_level_to(anchor: Option<&str>) -> To {
153 To {
154 parent: Parent::Doc,
155 at: match anchor {
156 None => At::Start,
157 Some(id) => At::After(id.to_owned()),
158 },
159 }
160}
161
162#[must_use]
165pub fn lower_top_level(
166 old_doc: &MutDoc,
167 rest: &[Block],
168 assignment: &BTreeMap<String, String>,
169 dispositions: &[Disposition],
170) -> LowerResult {
171 let doc_id = &old_doc.doc_id;
172 let old_ids: HashSet<String> = old_doc.all_ids().into_iter().collect();
173 let target = build_target(rest, assignment, &old_ids, None);
174 let mut target_id_set = HashSet::new();
175 collect_target_ids(&target, &mut target_id_set);
176 let disp_by: HashMap<&str, &Disposition> = dispositions
177 .iter()
178 .map(|d| (d.block_id.as_str(), d))
179 .collect();
180 let old_by_id: HashMap<&str, &MutBlock> = old_doc.iter().map(|b| (b.id.as_str(), b)).collect();
181 let old_top_ids: Vec<String> = old_doc.children.iter().map(|b| b.id.clone()).collect();
182 let old_top_set: HashSet<&str> = old_top_ids.iter().map(String::as_str).collect();
183
184 let mut ops: Vec<PlanOp> = Vec::new();
185 let ambiguous = dispositions
186 .iter()
187 .filter(|d| {
188 matches!(
189 d.detail.get("near_misses"),
190 Some(omgbase_reconcile::DetailValue::List(_))
191 )
192 })
193 .count();
194 let meta = |id: &str| meta_of(disp_by.get(id).copied());
195
196 let target_top_ids: HashSet<&str> = target.iter().map(|t| t.id.as_str()).collect();
198 for ob in &old_doc.children {
199 if !target_top_ids.contains(ob.id.as_str()) {
200 let mut expect = BTreeMap::new();
201 expect.insert(ob.id.clone(), Expect::content(raw_hash_hex(&ob.raw)));
202 ops.push(PlanOp {
203 op: Op::Remove {
204 blocks: vec![ob.id.clone()],
205 expect: Some(expect),
206 },
207 disposition: PlanDisposition::Deleted,
208 blocks: vec![ob.id.clone()],
209 confidence: None,
210 reason: Some("tombstone".to_owned()),
211 detail: None,
212 });
213 }
214 }
215
216 for t in &target {
218 if !t.carried {
219 continue;
220 }
221 let Some(ob) = old_by_id.get(t.id.as_str()) else {
222 continue;
223 };
224 if raw_hash_hex(&ob.raw) != raw_hash_hex(&t.raw) {
225 let m = meta(&t.id);
226 let child_ids = if t.children.is_empty() {
227 BTreeMap::new()
228 } else {
229 carried_child_id_map(t, &old_ids)
230 };
231 ops.push(PlanOp {
232 op: Op::Update {
233 block: t.id.clone(),
234 markdown: Some(t.raw.clone()),
235 attrs: None,
236 expect: Some(Expect::content(raw_hash_hex(&ob.raw))),
237 trivia: None,
238 child_ids: (!child_ids.is_empty()).then_some(child_ids),
239 },
240 disposition: PlanDisposition::Edited,
241 blocks: vec![t.id.clone()],
242 confidence: m.confidence,
243 reason: m.reason,
244 detail: m.detail,
245 });
246 }
247 }
248
249 let kept_top: Vec<String> = target
251 .iter()
252 .filter(|t| t.carried && old_top_set.contains(t.id.as_str()))
253 .map(|t| t.id.clone())
254 .collect();
255 let survivors_in_old_order: Vec<String> = old_top_ids
256 .iter()
257 .filter(|id| target_id_set.contains(*id) && kept_top.contains(id))
258 .cloned()
259 .collect();
260 let stable = lcs(&survivors_in_old_order, &kept_top);
261
262 let mut prev_ref: Option<String> = None;
263 let mut ref_of: Vec<String> = Vec::with_capacity(target.len());
264 let mut placed: Vec<bool> = Vec::with_capacity(target.len());
265 for t in &target {
266 let anchor = top_level_to(prev_ref.as_deref());
267 let was_top = t.carried && old_top_set.contains(t.id.as_str());
268 placed.push(!was_top || !stable.contains(&t.id));
269 if !was_top {
270 let op_index = ops.len();
271 let d = disp_by.get(t.id.as_str()).copied();
272 ops.push(PlanOp {
273 op: Op::Insert {
274 doc: Some(doc_id.clone()),
275 to: anchor,
276 markdown: t.raw.clone(),
277 expect: None,
278 },
279 disposition: disposition_for_new(d.map(|d| d.kind)),
280 blocks: Vec::new(),
281 confidence: d.and_then(|d| d.confidence),
282 reason: d.and_then(|d| d.reason).map(|r| r.as_str().to_owned()),
283 detail: None,
284 });
285 prev_ref = Some(format!("${op_index}.ids[0]"));
286 } else {
287 if !stable.contains(&t.id) {
288 let m = meta(&t.id);
289 ops.push(PlanOp {
290 op: Op::Move {
291 blocks: vec![t.id.clone()],
292 to: anchor,
293 doc: None,
294 expect: None,
295 },
296 disposition: PlanDisposition::Moved,
297 blocks: vec![t.id.clone()],
298 confidence: m.confidence,
299 reason: m.reason,
300 detail: m.detail,
301 });
302 }
303 prev_ref = Some(t.id.clone());
304 }
305 ref_of.push(prev_ref.clone().expect("set above"));
306 }
307
308 for (i, t) in target.iter().enumerate() {
310 let ob = if t.carried {
311 old_by_id.get(t.id.as_str()).copied()
312 } else {
313 None
314 };
315 let was_top = ob.is_some() && old_top_set.contains(t.id.as_str());
316 let moved = was_top && !stable.contains(&t.id);
317 let anchors_placement = placed.get(i + 1).copied().unwrap_or(false);
318 let trivia_differs = ob.is_some_and(|o| o.trivia != t.trivia);
319 if !(!was_top || moved || anchors_placement || trivia_differs) {
320 continue;
321 }
322 ops.push(PlanOp {
323 op: Op::Update {
324 block: ref_of[i].clone(),
325 markdown: None,
326 attrs: None,
327 expect: None,
328 trivia: Some(t.trivia.clone()),
329 child_ids: None,
330 },
331 disposition: PlanDisposition::Retiled,
332 blocks: if was_top {
333 vec![t.id.clone()]
334 } else {
335 Vec::new()
336 },
337 confidence: None,
338 reason: None,
339 detail: None,
340 });
341 }
342
343 let preserved = target
344 .iter()
345 .filter(|t| {
346 t.carried
347 && old_by_id
348 .get(t.id.as_str())
349 .is_some_and(|ob| raw_hash_hex(&ob.raw) == raw_hash_hex(&t.raw))
350 && stable.contains(&t.id)
351 })
352 .count();
353
354 LowerResult {
355 ops,
356 preserved,
357 ambiguous,
358 }
359}
360
361#[must_use]
364pub fn body_of(content: &str) -> String {
365 let Some(rest) = strip_frontmatter(content) else {
366 return content.to_owned();
367 };
368 match rest.find('\n') {
371 Some(nl) if rest[..nl].chars().all(char::is_whitespace) => rest[nl + 1..].to_owned(),
372 _ => rest.to_owned(),
373 }
374}
375
376#[must_use]
379pub fn strip_frontmatter(content: &str) -> Option<&str> {
380 let after_open = content
381 .strip_prefix("---\r\n")
382 .or_else(|| content.strip_prefix("---\n"))?;
383 let mut search_from = 0;
385 loop {
386 let rel = after_open[search_from..].find("\n---")?;
387 let at = search_from + rel;
388 let after = &after_open[at + 4..];
390 let tail = if let Some(t) = after.strip_prefix("\r\n") {
391 Some(t)
392 } else if let Some(t) = after.strip_prefix('\n') {
393 Some(t)
394 } else if after.is_empty() {
395 Some(after)
396 } else {
397 Some(after)
401 };
402 if let Some(t) = tail {
403 return Some(t);
404 }
405 search_from = at + 1;
406 }
407}
408
409#[must_use]
411pub fn lower_replace(old_doc: &MutDoc, content: &str) -> LowerResult {
412 let mut ops: Vec<PlanOp> = old_doc
413 .children
414 .iter()
415 .map(|ob| PlanOp {
416 op: Op::Remove {
417 blocks: vec![ob.id.clone()],
418 expect: None,
419 },
420 disposition: PlanDisposition::Deleted,
421 blocks: vec![ob.id.clone()],
422 confidence: None,
423 reason: Some("tombstone".to_owned()),
424 detail: None,
425 })
426 .collect();
427 ops.push(PlanOp {
428 op: Op::Insert {
429 doc: Some(old_doc.doc_id.clone()),
430 to: To {
431 parent: Parent::Doc,
432 at: At::End,
433 },
434 markdown: body_of(content),
435 expect: None,
436 },
437 disposition: PlanDisposition::BulkRewrite,
438 blocks: Vec::new(),
439 confidence: None,
440 reason: None,
441 detail: None,
442 });
443 LowerResult {
444 ops,
445 preserved: 0,
446 ambiguous: 0,
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use omgbase_format::parse_markdown;
454 use omgbase_reconcile::{
455 Config, FlatSource, Options, SequentialMinter, flatten, reconcile_document,
456 };
457
458 fn ids(v: &[&str]) -> Vec<String> {
459 v.iter().map(|s| (*s).to_owned()).collect()
460 }
461
462 #[test]
463 fn lcs_keeps_the_longest_common_subsequence() {
464 let a = ids(&["a", "b", "c", "d"]);
465 let b = ids(&["b", "a", "c", "d"]);
466 let keep = lcs(&a, &b);
467 assert_eq!(keep.len(), 3);
468 assert!(keep.contains("c") && keep.contains("d"));
469 assert!(keep.contains("b"));
471 assert!(lcs(&[], &b).is_empty());
472 assert_eq!(lcs(&a, &a).len(), 4);
473 }
474
475 #[test]
476 fn frontmatter_stripping() {
477 assert_eq!(
478 strip_frontmatter("---\na: 1\n---\n\nbody\n"),
479 Some("\nbody\n")
480 );
481 assert_eq!(
482 strip_frontmatter("---\r\na: 1\r\n---\r\nbody"),
483 Some("body")
484 );
485 assert_eq!(strip_frontmatter("---\na: 1\n---"), Some(""));
486 assert_eq!(strip_frontmatter("no fm\n"), None);
487 assert_eq!(body_of("---\na: 1\n---\n\nbody\n"), "body\n");
488 assert_eq!(body_of("---\na: 1\n---\nbody\n"), "body\n");
489 assert_eq!(body_of("plain\n"), "plain\n");
490 }
491
492 fn plan_inputs(
495 old: &str,
496 new: &str,
497 ) -> (
498 MutDoc,
499 Vec<Block>,
500 BTreeMap<String, String>,
501 Vec<Disposition>,
502 ) {
503 let mut m = SequentialMinter::new("b");
504 let old_tree = parse_markdown(old);
505 fn to_mut(b: &Block, m: &mut SequentialMinter) -> MutBlock {
506 let id = omgbase_reconcile::Minter::mint(m);
507 let mut mb = MutBlock::new(&id, b.kind.as_str(), &b.raw, &b.trivia);
508 mb.children = b.children.iter().map(|c| to_mut(c, m)).collect();
509 mb
510 }
511 let children: Vec<MutBlock> = old_tree
512 .children
513 .iter()
514 .map(|b| to_mut(b, &mut m))
515 .collect();
516 let doc = MutDoc::new("d_0", "a.md", children);
517 let old_flat = flatten(&FlatSource::from_tree(
518 &old_tree,
519 Some(&mut SequentialMinter::new("b")),
520 ));
521 let new_tree = parse_markdown(new);
522 let new_flat = flatten(&FlatSource::from_tree(&new_tree, None));
523 let mut n = SequentialMinter::new("n");
524 let result = reconcile_document(
525 &old_flat,
526 &new_flat,
527 Options {
528 config: &Config::default(),
529 pool: &[],
530 minter: &mut n,
531 },
532 );
533 (
534 doc,
535 new_tree.children,
536 result.assignment,
537 result.dispositions,
538 )
539 }
540
541 #[test]
542 fn identical_content_lowers_to_nothing() {
543 let (doc, rest, assignment, disps) = plan_inputs("# T\n\nA.\n\nB.\n", "# T\n\nA.\n\nB.\n");
544 let r = lower_top_level(&doc, &rest, &assignment, &disps);
545 assert!(r.ops.is_empty());
546 assert_eq!(r.preserved, 3);
547 }
548
549 #[test]
550 fn insert_in_the_middle_anchors_after_the_previous_and_retiles() {
551 let (doc, rest, assignment, disps) =
552 plan_inputs("# T\n\nA.\n\nB.\n", "# T\n\nA.\n\nMid.\n\nB.\n");
553 let r = lower_top_level(&doc, &rest, &assignment, &disps);
554 let names: Vec<&str> = r.ops.iter().map(|p| p.op.name()).collect();
555 assert_eq!(names, ["insert", "update", "update"]);
556 match &r.ops[0].op {
557 Op::Insert {
558 doc, to, markdown, ..
559 } => {
560 assert_eq!(doc.as_deref(), Some("d_0"));
561 assert_eq!(to.at, At::After("b_1".into()));
562 assert_eq!(markdown, "Mid.");
563 }
564 other => panic!("{other:?}"),
565 }
566 assert_eq!(r.ops[0].disposition, PlanDisposition::Inserted);
567 assert!(
569 matches!(&r.ops[1].op, Op::Update { block, trivia: Some(t), .. } if block == "b_1" && t == "\n\n")
570 );
571 assert!(
572 matches!(&r.ops[2].op, Op::Update { block, trivia: Some(t), .. } if block == "$0.ids[0]" && t == "\n\n")
573 );
574 assert_eq!(r.ops[2].blocks, Vec::<String>::new());
575 assert_eq!(r.preserved, 3);
576 }
577
578 #[test]
579 fn removal_and_edit_and_reorder() {
580 let (doc, rest, assignment, disps) =
581 plan_inputs("# T\n\nA.\n\nB.\n\nC.\n", "# T\n\nA.\n\nC.\n");
582 let r = lower_top_level(&doc, &rest, &assignment, &disps);
583 assert!(
584 matches!(&r.ops[0].op, Op::Remove { blocks, expect: Some(e) } if blocks == &ids(&["b_2"]) && e["b_2"].content_hash.is_some())
585 );
586 assert_eq!(r.ops[0].reason.as_deref(), Some("tombstone"));
587 let long_a = "Alpha is a reasonably long paragraph about the first topic in this note.";
588 let long_b = "Alpha is a reasonably long paragraph about the first subject in this note.";
589 let (doc, rest, assignment, disps) =
590 plan_inputs(&format!("# T\n\n{long_a}\n"), &format!("# T\n\n{long_b}\n"));
591 let r = lower_top_level(&doc, &rest, &assignment, &disps);
592 assert!(
593 matches!(&r.ops[0].op, Op::Update { block, markdown: Some(md), expect: Some(_), child_ids: None, .. } if block == "b_1" && md == long_b)
594 );
595 assert_eq!(r.ops[0].disposition, PlanDisposition::Edited);
596 assert!(r.ops[0].confidence.is_some());
597 let (doc, rest, assignment, disps) = plan_inputs("# T\n\nA.\n\nB.\n", "# T\n\nB.\n\nA.\n");
598 let r = lower_top_level(&doc, &rest, &assignment, &disps);
599 assert!(
600 r.ops
601 .iter()
602 .any(|p| p.disposition == PlanDisposition::Moved)
603 );
604 }
605
606 #[test]
607 fn container_update_threads_carried_child_ids() {
608 let (doc, rest, assignment, disps) = plan_inputs(
609 "# T\n\n- alpha item text here\n- bravo item text here\n- gamma item text here\n",
610 "# T\n\n- alpha item text here\n- bravo item text CHANGED\n- gamma item text here\n",
611 );
612 let r = lower_top_level(&doc, &rest, &assignment, &disps);
613 let upd = r
614 .ops
615 .iter()
616 .find(|p| {
617 p.op.name() == "update"
618 && matches!(
619 &p.op,
620 Op::Update {
621 markdown: Some(_),
622 ..
623 }
624 )
625 })
626 .unwrap();
627 match &upd.op {
628 Op::Update {
629 block,
630 child_ids: Some(c),
631 ..
632 } => {
633 assert_eq!(block, "b_1");
634 assert_eq!(c.get("/0").map(String::as_str), Some("b_2"));
635 assert_eq!(c.get("/2").map(String::as_str), Some("b_4"));
636 }
637 other => panic!("{other:?}"),
638 }
639 }
640
641 #[test]
642 fn replace_lowering_removes_everything_and_inserts_the_body() {
643 let (doc, ..) = plan_inputs("# T\n\nA.\n", "x");
644 let r = lower_replace(&doc, "---\nk: v\n---\n\n# N\n");
645 assert_eq!(r.ops.len(), 3);
646 assert!(matches!(&r.ops[0].op, Op::Remove { expect: None, .. }));
647 assert!(
648 matches!(&r.ops[2].op, Op::Insert { markdown, to, .. } if markdown == "# N\n" && to.at == At::End)
649 );
650 assert_eq!(r.ops[2].disposition, PlanDisposition::BulkRewrite);
651 }
652}