Skip to main content

prikk_store/
patch_inverse.rs

1//! Read-only inverse planning for the supported patch-operation subset.
2//!
3//! PR-026 deliberately keeps inverse handling non-mutating. It validates a supported single-parent
4//! patch chain while replaying it, derives the inverse operation sequence for the currently
5//! supported operation subset, and exposes the unsigned inverse Patch payload as planning metadata.
6//! Publishing, rollback refs, conflict witnesses, and full patch algebra remain later increments.
7
8use 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/// Read-only inverse plan for the supported patch-operation subset.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PatchInversePlan {
34    /// Ref used as the inverse planning target.
35    pub ref_name: String,
36    /// Target block ID whose supported patch chain was inspected.
37    pub target_block_id: ObjectId,
38    /// Number of blocks walked from the root side to the target.
39    pub block_count: usize,
40    /// Number of patch objects inspected.
41    pub patch_count: usize,
42    /// Number of original supported operations validated.
43    pub original_operation_count: usize,
44    /// Number of inverse operations generated.
45    pub inverse_operation_count: usize,
46    /// Unsigned inverse Patch object ID hint.
47    ///
48    /// This ID is only a deterministic planning hint for the unsigned payload. It is not a
49    /// published object, and it is not sufficient authorization for rollback.
50    pub inverse_patch_id_hint: ObjectId,
51    /// Unsigned inverse Patch payload.
52    pub inverse_payload: PatchPayload,
53    /// Human-readable summary of inverse operations in application order.
54    pub operations: Vec<PatchInverseOperationSummary>,
55}
56
57/// Summary of one inverse operation.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PatchInverseOperationSummary {
60    /// Operation sequence inside the inverse payload.
61    pub op_seq: u32,
62    /// Repository-relative path affected by the inverse operation.
63    pub path: String,
64    /// Inverse operation kind.
65    pub kind: PatchInverseOperationKind,
66}
67
68/// Supported inverse operation kind.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum PatchInverseOperationKind {
71    /// Inverse operation creates a file.
72    CreateFile,
73    /// Inverse operation deletes a file.
74    DeleteFile,
75    /// Inverse operation replaces a binary blob.
76    ReplaceBinary,
77    /// Inverse operation performs a text edit.
78    EditText,
79    /// Inverse operation changes a node's mode bits (DC-73).
80    ChangePerm,
81}
82
83impl PatchInverseOperationKind {
84    /// Return a stable CLI label.
85    #[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
97/// Prepare an unsigned inverse Patch payload for the supported patch-operation subset.
98pub 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            // `files` holds forward (original-history) state at this point in the walk — the
323            // bytes *before* this original ReplaceBinary took effect, which must match its
324            // `old_blob_id`, not `new_blob_id`.
325            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            // Advance `files` to this original operation's forward result, so a later operation
342            // in the same walk sees correct "current" state.
343            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        // DC-73: unreachable in practice — nothing authors either kind (renames become
376        // delete+create; symlink authoring is refused), so inverse stays deferred pending an
377        // authoring path, not the node model.
378        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;