Skip to main content

prikk_object/payload/
patch.rs

1//! Patch payload types.
2//!
3//! Split into two files (DC-58): this file keeps `PatchPayload`/`PatchPurpose`/`Operation`/
4//! `OperationKind` and the text-span helpers; `patch/operations.rs` holds the seven per-kind
5//! payload structs (`CreateFile`, `DeleteNode`, …). All items stay `pub` and are re-exported here
6//! at the same path (`payload::patch::*`), so every existing caller — including the crate-root
7//! re-export at `payload.rs` — is unaffected. No behaviour change.
8
9use prikk_error::{PrikkError, Result};
10
11use crate::canonical::{is_contiguous_op_seq, is_strictly_sorted};
12use crate::payload::common::{Intent, OperationCondition, OperationConditionEntry};
13use crate::{CanonicalEncode, CanonicalWriter, ObjectId, WireType};
14
15mod operations;
16
17pub use operations::{
18    ChangePerm, CreateFile, CreateSymlink, DeleteNode, DeleteNodePreimage, EditText, RenamePath,
19    ReplaceBinary,
20};
21
22/// Number of bytes in a content-anchored text span hash.
23pub const TEXT_SPAN_HASH_BYTES: usize = 32;
24
25/// Compute the stable hash used by content-anchored text edit preconditions.
26#[must_use]
27pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
28    prikk_hash::sha256(bytes)
29}
30
31/// Validate a stable content-anchor identifier.
32pub fn validate_text_anchor_id(value: &str) -> Result<()> {
33    if value.is_empty() {
34        return Err(PrikkError::CanonicalEncoding(
35            "text anchor id must not be empty".to_string(),
36        ));
37    }
38    if !value.is_ascii() {
39        return Err(PrikkError::CanonicalEncoding(
40            "text anchor id must be ASCII in v1".to_string(),
41        ));
42    }
43    if value.bytes().any(|byte| byte < 0x21 || byte == 0x7f) {
44        return Err(PrikkError::CanonicalEncoding(
45            "text anchor id must not contain whitespace or control characters".to_string(),
46        ));
47    }
48    Ok(())
49}
50
51/// Patch payload.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct PatchPayload {
54    /// Operations in semantic order. `op_seq` must be contiguous from 1.
55    pub operations: Vec<Operation>,
56    /// Parent patch IDs. Sorted ascending.
57    pub parent_patch_ids: Vec<ObjectId>,
58    /// Advisory intent.
59    pub intent: Option<Intent>,
60    /// Patch-level preconditions, sorted by key.
61    pub preconditions: Vec<OperationConditionEntry>,
62    /// Identity-bearing patch purpose. `Normal` is canonical by omission.
63    pub purpose: PatchPurpose,
64}
65
66impl PatchPayload {
67    /// Validate ordering and duplicate constraints.
68    pub fn validate(&self) -> Result<()> {
69        if self.operations.is_empty() {
70            return Err(PrikkError::CanonicalEncoding(
71                "patch operations must contain at least one operation".to_string(),
72            ));
73        }
74        let op_seq: Vec<u32> = self.operations.iter().map(|op| op.op_seq).collect();
75        if !is_contiguous_op_seq(&op_seq) {
76            return Err(PrikkError::CanonicalEncoding(
77                "patch operations must have contiguous op_seq values starting at 1".to_string(),
78            ));
79        }
80        if !is_strictly_sorted(&self.parent_patch_ids) {
81            return Err(PrikkError::CanonicalEncoding(
82                "parent_patch_ids must be sorted and unique".to_string(),
83            ));
84        }
85        if !is_strictly_sorted(&self.preconditions) {
86            return Err(PrikkError::CanonicalEncoding(
87                "patch preconditions must be sorted and unique".to_string(),
88            ));
89        }
90        Ok(())
91    }
92}
93
94impl CanonicalEncode for PatchPayload {
95    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
96        self.validate()?;
97        writer.repeated_record_list(1, &self.operations)?;
98        writer.repeated_object_id(2, &self.parent_patch_ids)?;
99        if let Some(intent) = self.intent {
100            writer.field_enum_u16(3, intent.code())?;
101        }
102        writer.repeated_record(4, &self.preconditions)?;
103        if self.purpose != PatchPurpose::Normal {
104            writer.field_enum_u16(5, self.purpose.code())?;
105        }
106        Ok(())
107    }
108}
109
110/// Identity-bearing patch purpose.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
112#[repr(u16)]
113pub enum PatchPurpose {
114    /// Ordinary Patch. This is the default when tag 5 is absent and must not be encoded explicitly.
115    Normal = 1,
116    /// Rollback draft Patch. This survives WAL-to-object persistence for classification.
117    RollbackDraft = 2,
118}
119
120impl PatchPurpose {
121    /// Stable numeric code.
122    #[must_use]
123    pub const fn code(self) -> u16 {
124        self as u16
125    }
126
127    /// Parse a stable code from a present tag-5 purpose field.
128    pub fn from_present_code(code: u16) -> Result<Self> {
129        match code {
130            1 => Err(PrikkError::CanonicalEncoding(
131                "PatchPurpose::Normal must be omitted, not encoded explicitly".to_string(),
132            )),
133            2 => Ok(Self::RollbackDraft),
134            other => Err(PrikkError::CanonicalEncoding(format!(
135                "unknown patch purpose code: {other}"
136            ))),
137        }
138    }
139
140    /// Decode only the top-level `PatchPayload` purpose field, validating tag order and rejecting
141    /// an explicitly encoded `Normal` default. Absence means `Normal`.
142    pub fn decode_from_patch_payload(bytes: &[u8]) -> Result<Self> {
143        let mut cursor = PatchPayloadFieldCursor::new(bytes);
144        let mut purpose = Self::Normal;
145        let mut seen_purpose = false;
146        while let Some(field) = cursor.next_field()? {
147            match field.tag {
148                1..=4 => {}
149                5 => {
150                    if seen_purpose {
151                        return Err(PrikkError::CanonicalEncoding(
152                            "duplicate PatchPurpose field".to_string(),
153                        ));
154                    }
155                    seen_purpose = true;
156                    field.require_wire(WireType::EnumU16)?;
157                    purpose = Self::from_present_code(field.read_u16()?)?;
158                }
159                other => {
160                    return Err(PrikkError::CanonicalEncoding(format!(
161                        "unknown PatchPayload field tag: {other}"
162                    )));
163                }
164            }
165        }
166        Ok(purpose)
167    }
168}
169
170struct PatchPayloadFieldCursor<'a> {
171    bytes: &'a [u8],
172    pos: usize,
173    last_tag: Option<u16>,
174}
175
176impl<'a> PatchPayloadFieldCursor<'a> {
177    const fn new(bytes: &'a [u8]) -> Self {
178        Self {
179            bytes,
180            pos: 0,
181            last_tag: None,
182        }
183    }
184
185    fn next_field(&mut self) -> Result<Option<PatchPayloadField<'a>>> {
186        if self.pos == self.bytes.len() {
187            return Ok(None);
188        }
189        let tag = u16::from_be_bytes(self.read_array::<2>()?);
190        if tag == 0 {
191            return Err(PrikkError::CanonicalEncoding(
192                "field tag 0 is reserved".to_string(),
193            ));
194        }
195        if let Some(last) = self.last_tag {
196            if tag < last {
197                return Err(PrikkError::CanonicalEncoding(format!(
198                    "field tag order violation: {tag} after {last}"
199                )));
200            }
201        }
202        self.last_tag = Some(tag);
203        let wire_type = self.read_u8()?;
204        let len = usize::try_from(u64::from_be_bytes(self.read_array::<8>()?)).map_err(|_| {
205            PrikkError::CanonicalEncoding("canonical field length does not fit usize".to_string())
206        })?;
207        let value = self.read_exact(len)?;
208        Ok(Some(PatchPayloadField {
209            tag,
210            wire_type,
211            value,
212        }))
213    }
214
215    fn read_u8(&mut self) -> Result<u8> {
216        let bytes = self.read_exact(1)?;
217        let Some(byte) = bytes.first() else {
218            return Err(PrikkError::CanonicalEncoding(
219                "unexpected empty byte".to_string(),
220            ));
221        };
222        Ok(*byte)
223    }
224
225    fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
226        let bytes = self.read_exact(N)?;
227        let mut out = [0_u8; N];
228        out.copy_from_slice(bytes);
229        Ok(out)
230    }
231
232    fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> {
233        let end = self
234            .pos
235            .checked_add(len)
236            .ok_or_else(|| PrikkError::CanonicalEncoding("canonical range overflow".to_string()))?;
237        let Some(slice) = self.bytes.get(self.pos..end) else {
238            return Err(PrikkError::CanonicalEncoding(
239                "unexpected end of canonical payload".to_string(),
240            ));
241        };
242        self.pos = end;
243        Ok(slice)
244    }
245}
246
247struct PatchPayloadField<'a> {
248    tag: u16,
249    wire_type: u8,
250    value: &'a [u8],
251}
252
253impl PatchPayloadField<'_> {
254    fn require_wire(&self, expected: WireType) -> Result<()> {
255        if self.wire_type == expected as u8 {
256            return Ok(());
257        }
258        Err(PrikkError::CanonicalEncoding(format!(
259            "field {} has wrong wire type: expected {}, got {}",
260            self.tag, expected as u8, self.wire_type
261        )))
262    }
263
264    fn read_u16(&self) -> Result<u16> {
265        if self.value.len() != 2 {
266            return Err(PrikkError::CanonicalEncoding(format!(
267                "field {} expected 2 bytes, got {}",
268                self.tag,
269                self.value.len()
270            )));
271        }
272        let mut out = [0_u8; 2];
273        out.copy_from_slice(self.value);
274        Ok(u16::from_be_bytes(out))
275    }
276}
277
278/// A single operation inside a patch.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct Operation {
281    /// Strict operation sequence, starting at 1 inside the patch.
282    pub op_seq: u32,
283    /// Optional stable label for UI/debugging.
284    pub op_id: Option<String>,
285    /// Inline operation preconditions.
286    pub preconditions: Vec<OperationCondition>,
287    /// Operation kind.
288    pub kind: OperationKind,
289}
290
291impl CanonicalEncode for Operation {
292    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
293        writer.field_u32(1, self.op_seq)?;
294        writer.field_string_opt(2, self.op_id.as_deref())?;
295        writer.repeated_record(3, &self.preconditions)?;
296        match &self.kind {
297            OperationKind::CreateFile(value) => writer.field_record(10, value)?,
298            OperationKind::DeleteNode(value) => writer.field_record(11, value)?,
299            OperationKind::EditText(value) => writer.field_record(12, value)?,
300            OperationKind::RenamePath(value) => writer.field_record(13, value)?,
301            OperationKind::ChangePerm(value) => writer.field_record(14, value)?,
302            OperationKind::CreateSymlink(value) => writer.field_record(15, value)?,
303            OperationKind::ReplaceBinary(value) => writer.field_record(16, value)?,
304        }
305        Ok(())
306    }
307}
308
309/// Operation variants.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum OperationKind {
312    /// Create a text or binary file.
313    CreateFile(CreateFile),
314    /// Delete a node.
315    DeleteNode(DeleteNode),
316    /// Edit text using content-anchored spans.
317    EditText(EditText),
318    /// Rename a path.
319    RenamePath(RenamePath),
320    /// Change Unix-like permissions.
321    ChangePerm(ChangePerm),
322    /// Create a symbolic link.
323    CreateSymlink(CreateSymlink),
324    /// Replace an opaque binary blob.
325    ReplaceBinary(ReplaceBinary),
326}