1use std::collections::BTreeMap;
9
10use prikk_error::{PrikkError, Result};
11use prikk_object::{
12 CanonicalEncode, ChangePerm, CreateFile, DeleteNode, DeleteNodePreimage, NodeId, NodeKind,
13 ObjectEnvelope, ObjectId, ObjectType, Operation, OperationKind, PatchPayload, PatchPurpose,
14 ReplaceBinary,
15};
16
17use crate::layout::RepositoryLayout;
18use crate::object_store::{ObjectReadSnapshot, ObjectReader};
19use crate::patch_replay::decode::{
20 DecodedDeletePreimage, DecodedOperationKind, DecodedPatchOperation, decode_patch_operations,
21};
22use crate::text_span;
23
24mod read;
25
26use read::{
27 current_target_block, load_snapshot_files, read_blob_bytes_with_kind, read_block, read_patch,
28 single_parent_chain,
29};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PatchInversePlan {
34 pub ref_name: String,
36 pub target_block_id: ObjectId,
38 pub block_count: usize,
40 pub patch_count: usize,
42 pub original_operation_count: usize,
44 pub inverse_operation_count: usize,
46 pub inverse_patch_id_hint: ObjectId,
51 pub inverse_payload: PatchPayload,
53 pub operations: Vec<PatchInverseOperationSummary>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PatchInverseOperationSummary {
60 pub op_seq: u32,
62 pub path: String,
64 pub kind: PatchInverseOperationKind,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum PatchInverseOperationKind {
71 CreateFile,
73 DeleteFile,
75 ReplaceBinary,
77 EditText,
79 ChangePerm,
81}
82
83impl PatchInverseOperationKind {
84 #[must_use]
86 pub const fn as_str(self) -> &'static str {
87 match self {
88 Self::CreateFile => "create-file",
89 Self::DeleteFile => "delete-file",
90 Self::ReplaceBinary => "replace-binary",
91 Self::EditText => "edit-text",
92 Self::ChangePerm => "change-perm",
93 }
94 }
95}
96
97pub fn prepare_patch_inverse_plan(
99 layout: &RepositoryLayout,
100 ref_name: &str,
101) -> Result<PatchInversePlan> {
102 let object_store = ObjectReadSnapshot::open(layout)?;
103 let target_block_id = current_target_block(layout, &object_store, ref_name)?;
104 let block_ids = single_parent_chain(&object_store, target_block_id)?;
105 let mut files = BTreeMap::new();
106 let mut live_nodes = BTreeMap::new();
107 let mut inverse_operations = Vec::new();
108 let mut patch_count = 0_usize;
109 let mut original_operation_count = 0_usize;
110
111 for block_id in &block_ids {
112 let block = read_block(&object_store, *block_id)?;
113 if let Some(snapshot_blob_ref) = block.snapshot_blob_ref {
114 files = load_snapshot_files(&object_store, snapshot_blob_ref)?;
115 live_nodes.clear();
116 inverse_operations.clear();
117 patch_count = 0;
118 original_operation_count = 0;
119 }
120 for patch_id in block.patch_ids {
121 let patch = read_patch(&object_store, patch_id)?;
122 let operations = decode_patch_operations(&patch.canonical_payload)?;
123 for operation in operations {
124 let inverse = derive_inverse_operation(
125 &object_store,
126 &mut files,
127 &mut live_nodes,
128 operation,
129 )?;
130 inverse_operations.push(inverse);
131 original_operation_count += 1;
132 }
133 patch_count += 1;
134 }
135 }
136
137 inverse_operations.reverse();
138 renumber_operations(&mut inverse_operations)?;
139 let summaries = summarize_operations(&inverse_operations);
140 let inverse_payload = PatchPayload {
141 operations: inverse_operations,
142 parent_patch_ids: Vec::new(),
143 intent: None,
144 preconditions: Vec::new(),
145 purpose: PatchPurpose::Normal,
146 };
147 let inverse_payload_bytes = inverse_payload.to_canonical_bytes()?;
148 let inverse_patch_id_hint =
149 ObjectEnvelope::unsigned(ObjectType::Patch, 1, inverse_payload_bytes).object_id();
150
151 Ok(PatchInversePlan {
152 ref_name: ref_name.to_string(),
153 target_block_id,
154 block_count: block_ids.len(),
155 patch_count,
156 original_operation_count,
157 inverse_operation_count: inverse_payload.operations.len(),
158 inverse_patch_id_hint,
159 inverse_payload,
160 operations: summaries,
161 })
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165struct InverseLiveNode {
166 path: String,
167 kind: NodeKind,
168}
169
170fn derive_inverse_operation(
171 object_store: &impl ObjectReader,
172 files: &mut BTreeMap<String, Vec<u8>>,
173 live_nodes: &mut BTreeMap<NodeId, InverseLiveNode>,
174 operation: DecodedPatchOperation,
175) -> Result<Operation> {
176 match operation.kind {
177 DecodedOperationKind::CreateFile {
178 path,
179 node_id,
180 blob_id,
181 mode,
182 } => {
183 if files.contains_key(&path) {
184 return Err(PrikkError::Integrity(format!(
185 "CreateFile would overwrite existing path {path}"
186 )));
187 }
188 if live_nodes.contains_key(&node_id) {
189 return Err(PrikkError::Integrity(
190 "CreateFile would introduce an already-live node_id".to_string(),
191 ));
192 }
193 let (old_node_kind, bytes) = read_blob_bytes_with_kind(object_store, blob_id)?;
194 files.insert(path.clone(), bytes);
195 live_nodes.insert(
196 node_id,
197 InverseLiveNode {
198 path: path.clone(),
199 kind: old_node_kind,
200 },
201 );
202 Ok(Operation {
203 op_seq: 0,
204 op_id: Some(format!("inverse-delete-{path}")),
205 preconditions: Vec::new(),
206 kind: OperationKind::DeleteNode(DeleteNode {
207 path,
208 node_id,
209 old_node_kind,
210 preimage: DeleteNodePreimage::File {
211 old_blob_id: blob_id,
212 old_mode: mode,
213 },
214 }),
215 })
216 }
217 DecodedOperationKind::DeleteNode {
218 path,
219 node_id,
220 preimage:
221 DecodedDeletePreimage::File {
222 old_node_kind,
223 old_blob_id,
224 old_mode,
225 },
226 } => {
227 let old_bytes = files.get(&path).ok_or_else(|| {
228 PrikkError::Integrity(format!("DeleteNode path is absent: {path}"))
229 })?;
230 crate::blob_access::ensure_blob_matches_node_kind(
231 old_bytes,
232 old_blob_id,
233 old_node_kind,
234 )?;
235 files.remove(&path);
236 if let Some(live) = live_nodes.remove(&node_id) {
237 if live.path != path {
238 return Err(PrikkError::Integrity(format!(
239 "DeleteNode path {path} does not match live node path {}",
240 live.path
241 )));
242 }
243 if live.kind != old_node_kind {
244 return Err(PrikkError::Integrity(
245 "DeleteNode old_node_kind does not match live node kind".to_string(),
246 ));
247 }
248 }
249 Ok(Operation {
250 op_seq: 0,
251 op_id: Some(format!("inverse-create-{path}")),
252 preconditions: Vec::new(),
253 kind: OperationKind::CreateFile(CreateFile {
254 path,
255 node_id,
256 blob_id: old_blob_id,
257 mode: old_mode,
258 }),
259 })
260 }
261 DecodedOperationKind::EditText {
262 node_id,
263 span_id,
264 old_span_hash,
265 left_anchor_hash,
266 right_anchor_hash,
267 replacement_text,
268 old_span_text,
269 } => {
270 let live = live_nodes.get(&node_id).ok_or_else(|| {
271 PrikkError::Integrity("EditText inverse target node is not live".to_string())
272 })?;
273 if live.kind != NodeKind::TextFile {
274 return Err(PrikkError::Integrity(
275 "EditText inverse target node is not TextFile".to_string(),
276 ));
277 }
278 let pre_text = files.get(&live.path).ok_or_else(|| {
279 PrikkError::Integrity(format!(
280 "EditText inverse target path {} is absent",
281 live.path
282 ))
283 })?;
284 let (inverse, post_text) = text_span::derive_inverse_edit_text(
285 pre_text,
286 node_id,
287 &span_id,
288 &old_span_hash,
289 &left_anchor_hash,
290 &right_anchor_hash,
291 &replacement_text,
292 &old_span_text,
293 )?;
294 files.insert(live.path.clone(), post_text);
295 Ok(Operation {
296 op_seq: 0,
297 op_id: Some(format!("inverse-edit-text-{}", live.path)),
298 preconditions: Vec::new(),
299 kind: OperationKind::EditText(inverse),
300 })
301 }
302 DecodedOperationKind::DeleteNode {
303 preimage: DecodedDeletePreimage::Symlink { .. },
304 ..
305 } => Err(PrikkError::UnsupportedObjectType(
306 "inverse planning for symlink DeleteNode is deferred".to_string(),
307 )),
308 DecodedOperationKind::ReplaceBinary {
309 node_id,
310 old_blob_id,
311 new_blob_id,
312 } => {
313 let live = live_nodes.get(&node_id).ok_or_else(|| {
314 PrikkError::Integrity("ReplaceBinary inverse target node is not live".to_string())
315 })?;
316 if live.kind != NodeKind::BinaryFile {
317 return Err(PrikkError::Integrity(
318 "ReplaceBinary inverse target node is not BinaryFile".to_string(),
319 ));
320 }
321 let path = live.path.clone();
322 let current_bytes = files.get(&path).ok_or_else(|| {
326 PrikkError::Integrity(format!(
327 "ReplaceBinary inverse target path {path} is absent"
328 ))
329 })?;
330 crate::blob_access::ensure_blob_matches_node_kind(
331 current_bytes,
332 old_blob_id,
333 live.kind,
334 )?;
335 let (new_kind, new_bytes) = read_blob_bytes_with_kind(object_store, new_blob_id)?;
336 if new_kind != NodeKind::BinaryFile {
337 return Err(PrikkError::Integrity(format!(
338 "ReplaceBinary new blob {new_blob_id} is not a binary-file blob"
339 )));
340 }
341 files.insert(path.clone(), new_bytes);
344 Ok(Operation {
345 op_seq: 0,
346 op_id: Some(format!("inverse-replace-binary-{path}")),
347 preconditions: Vec::new(),
348 kind: OperationKind::ReplaceBinary(ReplaceBinary {
349 node_id,
350 old_blob_id: new_blob_id,
351 new_blob_id: old_blob_id,
352 }),
353 })
354 }
355 DecodedOperationKind::ChangePerm {
356 node_id,
357 old_mode,
358 new_mode,
359 } => {
360 let live = live_nodes.get(&node_id).ok_or_else(|| {
361 PrikkError::Integrity("ChangePerm inverse target node is not live".to_string())
362 })?;
363 let path = live.path.clone();
364 Ok(Operation {
365 op_seq: 0,
366 op_id: Some(format!("inverse-change-perm-{path}")),
367 preconditions: Vec::new(),
368 kind: OperationKind::ChangePerm(ChangePerm {
369 node_id,
370 old_mode: new_mode,
371 new_mode: old_mode,
372 }),
373 })
374 }
375 DecodedOperationKind::RenamePath { .. } => Err(PrikkError::UnsupportedObjectType(
379 "inverse planning for RenamePath awaits a rename authoring path".to_string(),
380 )),
381 DecodedOperationKind::CreateSymlink { .. } => Err(PrikkError::UnsupportedObjectType(
382 "inverse planning for CreateSymlink awaits a symlink authoring path".to_string(),
383 )),
384 }
385}
386
387fn renumber_operations(operations: &mut [Operation]) -> Result<()> {
388 for (index, operation) in operations.iter_mut().enumerate() {
389 let next = index
390 .checked_add(1)
391 .ok_or_else(|| PrikkError::CanonicalEncoding("operation count overflow".to_string()))?;
392 operation.op_seq = u32::try_from(next).map_err(|_| {
393 PrikkError::CanonicalEncoding("operation count exceeds u32".to_string())
394 })?;
395 }
396 Ok(())
397}
398
399fn summarize_operations(operations: &[Operation]) -> Vec<PatchInverseOperationSummary> {
400 operations
401 .iter()
402 .map(|operation| {
403 let (kind, path) = match &operation.kind {
404 OperationKind::CreateFile(value) => {
405 (PatchInverseOperationKind::CreateFile, value.path.clone())
406 }
407 OperationKind::DeleteNode(value) => {
408 (PatchInverseOperationKind::DeleteFile, value.path.clone())
409 }
410 OperationKind::EditText(_) => (
411 PatchInverseOperationKind::EditText,
412 operation
413 .op_id
414 .as_deref()
415 .and_then(|value| value.strip_prefix("inverse-edit-text-"))
416 .unwrap_or("<unknown>")
417 .to_string(),
418 ),
419 OperationKind::ReplaceBinary(_) => (
420 PatchInverseOperationKind::ReplaceBinary,
421 operation
422 .op_id
423 .as_deref()
424 .and_then(|value| value.strip_prefix("inverse-replace-binary-"))
425 .unwrap_or("<unknown>")
426 .to_string(),
427 ),
428 OperationKind::ChangePerm(_) => (
429 PatchInverseOperationKind::ChangePerm,
430 operation
431 .op_id
432 .as_deref()
433 .and_then(|value| value.strip_prefix("inverse-change-perm-"))
434 .unwrap_or("<unknown>")
435 .to_string(),
436 ),
437 OperationKind::RenamePath(_) | OperationKind::CreateSymlink(_) => {
438 unreachable!("inverse plan contains unsupported operation kind")
439 }
440 };
441 PatchInverseOperationSummary {
442 op_seq: operation.op_seq,
443 path,
444 kind,
445 }
446 })
447 .collect()
448}
449
450#[cfg(test)]
451mod tests;