1use omgbase_format::hash::{hex, sha256};
7use omgbase_format::{Block, BlockKind, parse_markdown};
8use omgbase_mutate::{
9 ErrorCode, LowerResult, MutationError, Op, Opset, OpsetPrecondition, lower_replace,
10 lower_top_level, render, summarize,
11};
12use omgbase_reconcile::{Config, FlatSource, Options, flatten, reconcile_document};
13use rusqlite::{OptionalExtension, params};
14use serde_json::json;
15
16use crate::Store;
17use crate::doc_store::{DocStore, NullDocStore};
18use crate::error::{Error, Result};
19use crate::mutate::{
20 ApplyOrigin, ApplyRequest, ApplyResult, SetFrontmatter, find_doc_by_ref, load_mut_doc,
21};
22use crate::read::load_old_match_blocks;
23
24enum Verified {
25 Ok,
26 Diverged { actual: String },
27 Threw(String),
28}
29
30fn describe_divergence(v: &Verified, proposed: &[Block], expected: &str) -> String {
32 match v {
33 Verified::Ok => "converged".to_owned(),
34 Verified::Threw(e) => format!("simulation threw {e}"),
35 Verified::Diverged { actual } => {
36 let a = actual.as_bytes();
37 let e = expected.as_bytes();
38 let n = a.len().min(e.len());
39 let mut off = 0;
40 while off < n && a[off] == e[off] {
41 off += 1;
42 }
43 let idx = proposed
44 .iter()
45 .position(|b| off >= b.span.start && off < b.span.end + b.trivia.len());
46 let where_ = match idx {
47 Some(i) => format!(
48 "proposed block #{i} ({}, bytes {}-{})",
49 proposed[i].kind.as_str(),
50 proposed[i].span.start,
51 proposed[i].span.end
52 ),
53 None if off >= expected.len() => "past the end of the proposed content".to_owned(),
54 None => "leading trivia".to_owned(),
55 };
56 let snippet = |s: &str| -> String {
57 let mut start = off.min(s.len());
58 while !s.is_char_boundary(start) {
59 start -= 1;
60 }
61 let piece: String = s[start..].chars().take(24).collect();
62 serde_json::to_string(&piece).unwrap_or_default()
63 };
64 format!(
65 "first divergence at byte {off} in {where_}: expected {}, rendered {}",
66 snippet(expected),
67 snippet(actual)
68 )
69 }
70 }
71}
72
73impl Store {
74 pub fn plan_update(
78 &mut self,
79 repo_id: &str,
80 doc_ref: &str,
81 content: &str,
82 config: &Config,
83 ) -> Result<Opset> {
84 let info = find_doc_by_ref(&self.conn, repo_id, doc_ref)?.ok_or_else(|| {
85 Error::from(MutationError::with_data(
86 ErrorCode::DocMissing,
87 format!("doc {doc_ref} not found"),
88 json!({ "doc": doc_ref }),
89 ))
90 })?;
91 let old_doc = load_mut_doc(&self.conn, &info.doc_id)?.ok_or_else(|| {
92 Error::from(MutationError::with_data(
93 ErrorCode::DocMissing,
94 format!("doc {doc_ref} not found"),
95 json!({ "doc": doc_ref }),
96 ))
97 })?;
98 let current_content = render(&old_doc);
99 let base_content_hash = hex(&sha256(current_content.as_bytes()));
100
101 let tree = parse_markdown(content);
102 let (fm_block, rest): (Option<&Block>, &[Block]) = match tree.children.first() {
103 Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
104 _ => (None, &tree.children[..]),
105 };
106 let old_match = load_old_match_blocks(&self.conn, &info.doc_id)?;
107 let new_match = flatten(&FlatSource::from_blocks(rest, None));
108 let mut minter = self.ids.at(&self.conn);
109 let mut mint = minter.deferred("b");
110 let result = reconcile_document(
111 &old_match,
112 &new_match,
113 Options {
114 config,
115 pool: &[],
116 minter: &mut mint,
117 },
118 );
119 mint.finish()?;
120
121 let target_fm: Option<String> = fm_block.map(|b| format!("{}{}", b.raw, b.trivia));
122 let fm_changed = target_fm != old_doc.frontmatter_raw;
123 let set_frontmatter: Vec<SetFrontmatter> = if fm_changed {
124 vec![SetFrontmatter {
125 doc: info.doc_id.clone(),
126 raw: target_fm.clone(),
127 }]
128 } else {
129 Vec::new()
130 };
131
132 let mut diagnostics: Vec<String> = Vec::new();
133 let t2 = lower_top_level(&old_doc, rest, &result.assignment, &result.dispositions);
134 let v2 = self.verify(
135 repo_id,
136 &info.path,
137 &t2,
138 &set_frontmatter,
139 content,
140 ¤t_content,
141 );
142 let (chosen, converges) = if matches!(v2, Verified::Ok) {
143 (t2, true)
144 } else {
145 diagnostics.push(format!(
146 "top-level lowering did not reproduce the proposed content byte-for-byte ({}); falling back to full replace",
147 describe_divergence(&v2, &tree.children, content)
148 ));
149 let t3 = lower_replace(&old_doc, content);
150 let v3 = self.verify(
151 repo_id,
152 &info.path,
153 &t3,
154 &set_frontmatter,
155 content,
156 ¤t_content,
157 );
158 if matches!(v3, Verified::Ok) {
159 diagnostics
160 .push("full-replace plan converges (block identity not preserved)".to_owned());
161 (t3, true)
162 } else {
163 diagnostics.push(format!(
164 "no lowering reproduced the proposed content exactly (full replace: {}); plan will not apply — inspect and re-plan",
165 describe_divergence(&v3, &tree.children, content)
166 ));
167 (t2, false)
168 }
169 };
170 let summary = summarize(&chosen.ops, chosen.preserved, chosen.ambiguous);
171 Ok(Opset {
172 target_doc: info.doc_id.clone(),
173 target_path: info.path.clone(),
174 precondition: OpsetPrecondition {
175 doc: info.doc_id,
176 path: info.path,
177 base_revision: info.current_rev,
178 base_content_hash,
179 },
180 matcher_v: config.matcher_v.clone(),
181 ops: chosen.ops,
182 frontmatter: fm_changed.then_some(target_fm),
183 summary,
184 converges,
185 diagnostics,
186 })
187 }
188
189 fn verify(
192 &mut self,
193 repo_id: &str,
194 path: &str,
195 lowering: &LowerResult,
196 set_frontmatter: &[SetFrontmatter],
197 expected: &str,
198 current_content: &str,
199 ) -> Verified {
200 let req = ApplyRequest {
201 repo_id: repo_id.to_owned(),
202 ops: lowering
203 .ops
204 .iter()
205 .map(|p| p.op.clone())
206 .collect::<Vec<Op>>(),
207 origin: ApplyOrigin::new("plan:verify", None),
208 dry_run: true,
209 set_frontmatter: set_frontmatter.to_vec(),
210 };
211 let mut null = NullDocStore;
212 match self.apply(&req, &mut null, "1970-01-01T00:00:00.000Z") {
213 Ok(res) => {
214 let after = res
215 .diffs
216 .as_ref()
217 .and_then(|d| d.iter().find(|(p, _)| p == path))
218 .map_or(current_content, |(_, diff)| diff.after.as_str());
219 if after == expected {
220 Verified::Ok
221 } else {
222 Verified::Diverged {
223 actual: after.to_owned(),
224 }
225 }
226 }
227 Err(Error::Mutation(e)) => Verified::Threw(format!("{}: {}", e.code, e.message)),
228 Err(e) => Verified::Threw(e.to_string()),
229 }
230 }
231
232 pub fn apply_opset(
236 &mut self,
237 repo_id: &str,
238 opset: &Opset,
239 origin: &ApplyOrigin,
240 dry_run: bool,
241 doc_store: &mut dyn DocStore,
242 ts: &str,
243 ) -> Result<ApplyResult> {
244 if !opset.converges {
245 let why = opset
246 .diagnostics
247 .last()
248 .map_or(String::new(), |d| format!(" — {d}"));
249 return Err(MutationError::with_data(
250 ErrorCode::PlanNotConvergent,
251 format!("opset does not reproduce the proposed content; re-plan{why}"),
252 json!({ "diagnostics": opset.diagnostics }),
253 )
254 .into());
255 }
256 let row: Option<(Option<String>, Option<Vec<u8>>)> = self
257 .conn
258 .query_row(
259 "SELECT current_rev, file_hash FROM docs WHERE doc_id = ?1 AND deleted_commit IS NULL",
260 params![opset.precondition.doc],
261 |r| Ok((r.get(0)?, r.get(1)?)),
262 )
263 .optional()?;
264 let Some((current_rev, file_hash)) = row else {
265 return Err(MutationError::with_data(
266 ErrorCode::DocMissing,
267 format!("doc {} not found", opset.precondition.doc),
268 json!({ "doc": opset.precondition.doc }),
269 )
270 .into());
271 };
272 let fh = file_hash.map(|h| hex(&h));
273 let stale = (opset.precondition.base_revision.is_some()
274 && current_rev != opset.precondition.base_revision)
275 || fh
276 .as_deref()
277 .is_some_and(|h| h != opset.precondition.base_content_hash);
278 if stale {
279 return Err(MutationError::with_data(
280 ErrorCode::StalePlan,
281 "document changed since the plan was computed; re-plan",
282 json!({
283 "doc": opset.precondition.doc,
284 "expected_revision": opset.precondition.base_revision,
285 "current": { "revision": current_rev, "content_hash": fh },
286 "retriable": true,
287 }),
288 )
289 .into());
290 }
291 let req = ApplyRequest {
292 repo_id: repo_id.to_owned(),
293 ops: opset.kernel_ops(),
294 origin: origin.clone(),
295 dry_run,
296 set_frontmatter: match &opset.frontmatter {
297 Some(raw) => vec![SetFrontmatter {
298 doc: opset.precondition.doc.clone(),
299 raw: raw.clone(),
300 }],
301 None => Vec::new(),
302 },
303 };
304 self.apply(&req, doc_store, ts)
305 }
306
307 #[allow(clippy::too_many_arguments)]
309 pub fn docs_update(
310 &mut self,
311 repo_id: &str,
312 doc_ref: &str,
313 content: &str,
314 config: &Config,
315 origin: &ApplyOrigin,
316 dry_run: bool,
317 doc_store: &mut dyn DocStore,
318 ts: &str,
319 ) -> Result<(Opset, Option<ApplyResult>)> {
320 let opset = self.plan_update(repo_id, doc_ref, content, config)?;
321 if dry_run {
322 return Ok((opset, None));
323 }
324 let result = self.apply_opset(repo_id, &opset, origin, false, doc_store, ts)?;
325 Ok((opset, Some(result)))
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn divergence_descriptions() {
335 let tree = parse_markdown("# Title\n\nAlpha.\n");
336 let v = Verified::Diverged {
337 actual: "# Title\n\nAlphb.\n".to_owned(),
338 };
339 assert_eq!(
340 describe_divergence(&v, &tree.children, "# Title\n\nAlpha.\n"),
341 "first divergence at byte 13 in proposed block #1 (paragraph, bytes 9-15): expected \"a.\\n\", rendered \"b.\\n\""
342 );
343 let lead = parse_markdown("\n\n# Title\n");
344 let v = Verified::Diverged {
345 actual: "# Title\n".to_owned(),
346 };
347 assert_eq!(
348 describe_divergence(&v, &lead.children, "\n\n# Title\n"),
349 "first divergence at byte 0 in leading trivia: expected \"\\n\\n# Title\\n\", rendered \"# Title\\n\""
350 );
351 let v = Verified::Diverged {
352 actual: "# Title\n\nAlpha.\n\nmore".to_owned(),
353 };
354 assert!(
355 describe_divergence(&v, &tree.children, "# Title\n\nAlpha.\n").contains("past the end")
356 );
357 assert_eq!(
358 describe_divergence(
359 &Verified::Threw("doc_missing: doc d_0 not found".into()),
360 &[],
361 ""
362 ),
363 "simulation threw doc_missing: doc d_0 not found"
364 );
365 assert_eq!(describe_divergence(&Verified::Ok, &[], ""), "converged");
366 }
367}