use omgbase_format::hash::{hex, sha256};
use omgbase_format::{Block, BlockKind, parse_markdown};
use omgbase_mutate::{
ErrorCode, LowerResult, MutationError, Opset, OpsetPrecondition, lower_replace,
lower_top_level, render, summarize,
};
use omgbase_reconcile::{Config, FlatSource, Options, flatten, reconcile_document};
use serde_json::json;
use crate::Store;
use crate::error::{Error, Result};
use crate::mutate::{DocInfo, OpsRunner, find_doc_by_ref, load_mut_doc};
use crate::read::load_old_match_blocks;
enum Verified {
Ok,
Diverged { actual: String },
Threw(String),
}
fn describe_divergence(v: &Verified, proposed: &[Block], expected: &str) -> String {
match v {
Verified::Ok => "converged".to_owned(),
Verified::Threw(e) => format!("simulation threw {e}"),
Verified::Diverged { actual } => {
let a = actual.as_bytes();
let e = expected.as_bytes();
let n = a.len().min(e.len());
let mut off = 0;
while off < n && a[off] == e[off] {
off += 1;
}
let idx = proposed
.iter()
.position(|b| off >= b.span.start && off < b.span.end + b.trivia.len());
let where_ = match idx {
Some(i) => format!(
"proposed block #{i} ({}, bytes {}-{})",
proposed[i].kind.as_str(),
proposed[i].span.start,
proposed[i].span.end
),
None if off >= expected.len() => "past the end of the proposed content".to_owned(),
None => "leading trivia".to_owned(),
};
let snippet = |s: &str| -> String {
let mut start = off.min(s.len());
while !s.is_char_boundary(start) {
start -= 1;
}
let piece: String = s[start..].chars().take(24).collect();
serde_json::to_string(&piece).unwrap_or_default()
};
format!(
"first divergence at byte {off} in {where_}: expected {}, rendered {}",
snippet(expected),
snippet(actual)
)
}
}
}
impl Store {
pub fn plan_update(
&mut self,
repo_id: &str,
doc_ref: &str,
content: &str,
config: &Config,
) -> Result<Opset> {
let info = find_doc_by_ref(&self.conn, repo_id, doc_ref)?.ok_or_else(|| {
Error::from(MutationError::with_data(
ErrorCode::DocMissing,
format!("doc {doc_ref} not found"),
json!({ "doc": doc_ref }),
))
})?;
let old_doc = load_mut_doc(&self.conn, &info.doc_id)?.ok_or_else(|| {
Error::from(MutationError::with_data(
ErrorCode::DocMissing,
format!("doc {doc_ref} not found"),
json!({ "doc": doc_ref }),
))
})?;
let current_content = render(&old_doc);
let hash = hex(&sha256(current_content.as_bytes()));
let tree = parse_markdown(content);
let (fm_block, rest): (Option<&Block>, &[Block]) = match tree.children.first() {
Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
_ => (None, &tree.children[..]),
};
let old_match = load_old_match_blocks(&self.conn, &info.doc_id)?;
let new_match = flatten(&FlatSource::from_blocks(rest, None));
let mut minter = self.ids.at(&self.conn);
let mut mint = minter.deferred("b");
let result = reconcile_document(
&old_match,
&new_match,
Options {
config,
pool: &[],
minter: &mut mint,
},
);
mint.finish()?;
let target_fm: Option<String> = fm_block.map(|b| format!("{}{}", b.raw, b.trivia));
let fm_changed = target_fm != old_doc.frontmatter_raw;
let head: Option<String> = fm_changed.then(|| {
format!(
"{}{}",
old_doc.leading_trivia,
target_fm.as_deref().unwrap_or("")
)
});
let mut diagnostics: Vec<String> = Vec::new();
let t2 = lower_top_level(&old_doc, rest, &result.assignment, &result.dispositions);
let v2 = self.verify(
repo_id,
&info,
&t2,
head.as_deref(),
content,
¤t_content,
);
let (chosen, converges) = if matches!(v2, Verified::Ok) {
(t2, true)
} else {
diagnostics.push(format!(
"top-level lowering did not reproduce the proposed content byte-for-byte ({}); falling back to full replace",
describe_divergence(&v2, &tree.children, content)
));
let t3 = lower_replace(&old_doc, content);
let v3 = self.verify(
repo_id,
&info,
&t3,
head.as_deref(),
content,
¤t_content,
);
if matches!(v3, Verified::Ok) {
diagnostics
.push("full-replace plan converges (block identity not preserved)".to_owned());
(t3, true)
} else {
diagnostics.push(format!(
"no lowering reproduced the proposed content exactly (full replace: {}); plan will not apply — inspect and re-plan",
describe_divergence(&v3, &tree.children, content)
));
(t2, false)
}
};
let summary = summarize(&chosen.ops, chosen.preserved, chosen.ambiguous);
Ok(Opset {
target_doc: info.doc_id.clone(),
target_path: info.path.clone(),
precondition: OpsetPrecondition {
doc: info.doc_id,
path: info.path,
base_revision: info.current_rev,
hash,
},
matcher_v: config.matcher_v.clone(),
ops: chosen.ops,
frontmatter: fm_changed.then_some(target_fm),
summary,
converges,
diagnostics,
})
}
fn verify(
&mut self,
repo_id: &str,
info: &DocInfo,
lowering: &LowerResult,
head: Option<&str>,
expected: &str,
current_content: &str,
) -> Verified {
let (doc_id, path) = (info.doc_id.as_str(), info.path.as_str());
let simulate = |store: &mut Store| -> Result<Option<String>> {
let mut runner = OpsRunner::new(repo_id);
if let Some(h) = head {
runner.set_head(store, doc_id, h)?;
}
for planned in &lowering.ops {
runner.push(store, &planned.op)?;
}
let applied = runner.finish();
Ok(applied
.loaded
.iter()
.find(|d| d.doc.path == path)
.map(|d| render(&d.doc)))
};
match simulate(self) {
Ok(after) => {
let after = after.as_deref().unwrap_or(current_content);
if after == expected {
Verified::Ok
} else {
Verified::Diverged {
actual: after.to_owned(),
}
}
}
Err(Error::Mutation(e)) => Verified::Threw(format!("{}: {}", e.code, e.message)),
Err(e) => Verified::Threw(e.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn divergence_descriptions() {
let tree = parse_markdown("# Title\n\nAlpha.\n");
let v = Verified::Diverged {
actual: "# Title\n\nAlphb.\n".to_owned(),
};
assert_eq!(
describe_divergence(&v, &tree.children, "# Title\n\nAlpha.\n"),
"first divergence at byte 13 in proposed block #1 (paragraph, bytes 9-15): expected \"a.\\n\", rendered \"b.\\n\""
);
let lead = parse_markdown("\n\n# Title\n");
let v = Verified::Diverged {
actual: "# Title\n".to_owned(),
};
assert_eq!(
describe_divergence(&v, &lead.children, "\n\n# Title\n"),
"first divergence at byte 0 in leading trivia: expected \"\\n\\n# Title\\n\", rendered \"# Title\\n\""
);
let v = Verified::Diverged {
actual: "# Title\n\nAlpha.\n\nmore".to_owned(),
};
assert!(
describe_divergence(&v, &tree.children, "# Title\n\nAlpha.\n").contains("past the end")
);
assert_eq!(
describe_divergence(
&Verified::Threw("doc_missing: doc d_0 not found".into()),
&[],
""
),
"simulation threw doc_missing: doc d_0 not found"
);
assert_eq!(describe_divergence(&Verified::Ok, &[], ""), "converged");
}
}