Skip to main content

prikk_object/payload/
patch.rs

1//! Patch payload types.
2
3use prikk_error::{PrikkError, Result};
4
5use crate::canonical::{is_contiguous_op_seq, is_strictly_sorted};
6use crate::payload::common::{Intent, OperationCondition, OperationConditionEntry};
7use crate::payload::node::{NodeId, NodeKind};
8use crate::{CanonicalEncode, CanonicalWriter, ObjectId};
9
10/// Number of bytes in a content-anchored text span hash.
11pub const TEXT_SPAN_HASH_BYTES: usize = 32;
12
13/// Compute the stable hash used by content-anchored text edit preconditions.
14#[must_use]
15pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
16    prikk_hash::sha256(bytes)
17}
18
19/// Validate a stable content-anchor identifier.
20pub fn validate_text_anchor_id(value: &str) -> Result<()> {
21    if value.is_empty() {
22        return Err(PrikkError::CanonicalEncoding(
23            "text anchor id must not be empty".to_string(),
24        ));
25    }
26    if !value.is_ascii() {
27        return Err(PrikkError::CanonicalEncoding(
28            "text anchor id must be ASCII in v1".to_string(),
29        ));
30    }
31    if value.bytes().any(|byte| byte < 0x21 || byte == 0x7f) {
32        return Err(PrikkError::CanonicalEncoding(
33            "text anchor id must not contain whitespace or control characters".to_string(),
34        ));
35    }
36    Ok(())
37}
38
39/// Patch payload.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PatchPayload {
42    /// Operations in semantic order. `op_seq` must be contiguous from 1.
43    pub operations: Vec<Operation>,
44    /// Parent patch IDs. Sorted ascending.
45    pub parent_patch_ids: Vec<ObjectId>,
46    /// Advisory intent.
47    pub intent: Option<Intent>,
48    /// Patch-level preconditions, sorted by key.
49    pub preconditions: Vec<OperationConditionEntry>,
50}
51
52impl PatchPayload {
53    /// Validate ordering and duplicate constraints.
54    pub fn validate(&self) -> Result<()> {
55        if self.operations.is_empty() {
56            return Err(PrikkError::CanonicalEncoding(
57                "patch operations must contain at least one operation".to_string(),
58            ));
59        }
60        let op_seq: Vec<u32> = self.operations.iter().map(|op| op.op_seq).collect();
61        if !is_contiguous_op_seq(&op_seq) {
62            return Err(PrikkError::CanonicalEncoding(
63                "patch operations must have contiguous op_seq values starting at 1".to_string(),
64            ));
65        }
66        if !is_strictly_sorted(&self.parent_patch_ids) {
67            return Err(PrikkError::CanonicalEncoding(
68                "parent_patch_ids must be sorted and unique".to_string(),
69            ));
70        }
71        if !is_strictly_sorted(&self.preconditions) {
72            return Err(PrikkError::CanonicalEncoding(
73                "patch preconditions must be sorted and unique".to_string(),
74            ));
75        }
76        Ok(())
77    }
78}
79
80impl CanonicalEncode for PatchPayload {
81    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
82        self.validate()?;
83        writer.repeated_record_list(1, &self.operations)?;
84        writer.repeated_object_id(2, &self.parent_patch_ids)?;
85        if let Some(intent) = self.intent {
86            writer.field_enum_u16(3, intent.code())?;
87        }
88        writer.repeated_record(4, &self.preconditions)?;
89        Ok(())
90    }
91}
92
93/// A single operation inside a patch.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Operation {
96    /// Strict operation sequence, starting at 1 inside the patch.
97    pub op_seq: u32,
98    /// Optional stable label for UI/debugging.
99    pub op_id: Option<String>,
100    /// Inline operation preconditions.
101    pub preconditions: Vec<OperationCondition>,
102    /// Operation kind.
103    pub kind: OperationKind,
104}
105
106impl CanonicalEncode for Operation {
107    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
108        writer.field_u32(1, self.op_seq)?;
109        writer.field_string_opt(2, self.op_id.as_deref())?;
110        writer.repeated_record(3, &self.preconditions)?;
111        match &self.kind {
112            OperationKind::CreateFile(value) => writer.field_record(10, value)?,
113            OperationKind::DeleteNode(value) => writer.field_record(11, value)?,
114            OperationKind::EditText(value) => writer.field_record(12, value)?,
115            OperationKind::RenamePath(value) => writer.field_record(13, value)?,
116            OperationKind::ChangePerm(value) => writer.field_record(14, value)?,
117            OperationKind::CreateSymlink(value) => writer.field_record(15, value)?,
118            OperationKind::ReplaceBinary(value) => writer.field_record(16, value)?,
119        }
120        Ok(())
121    }
122}
123
124/// Operation variants.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum OperationKind {
127    /// Create a text or binary file.
128    CreateFile(CreateFile),
129    /// Delete a node.
130    DeleteNode(DeleteNode),
131    /// Edit text using content-anchored spans.
132    EditText(EditText),
133    /// Rename a path.
134    RenamePath(RenamePath),
135    /// Change Unix-like permissions.
136    ChangePerm(ChangePerm),
137    /// Create a symbolic link.
138    CreateSymlink(CreateSymlink),
139    /// Replace an opaque binary blob.
140    ReplaceBinary(ReplaceBinary),
141}
142
143/// Create file payload (FDD-03 §9.3).
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct CreateFile {
146    /// Repo-relative UTF-8 path (`repo_path`).
147    pub path: String,
148    /// Node identity (`bytes`, 32).
149    pub node_id: NodeId,
150    /// Initial blob ID (`object_id`).
151    pub blob_id: ObjectId,
152    /// Mode bits (`u32`).
153    pub mode: u32,
154}
155
156impl CreateFile {
157    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
158    /// persisted node-bearing operation, and the encoder produces identity bytes.
159    pub fn validate(&self) -> Result<()> {
160        if self.node_id.is_zero() {
161            return Err(PrikkError::CanonicalEncoding(
162                "CreateFile node_id must be nonzero".to_string(),
163            ));
164        }
165        Ok(())
166    }
167}
168
169impl CanonicalEncode for CreateFile {
170    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
171        self.validate()?;
172        writer.field_repo_path(1, &self.path)?;
173        writer.field_bytes(2, self.node_id.as_bytes())?;
174        writer.field_object_id(3, &self.blob_id)?;
175        writer.field_u32(4, self.mode)?;
176        Ok(())
177    }
178}
179
180/// Discriminated deletion preimage (FDD-03 §9.3).
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum DeleteNodePreimage {
183    /// File or binary node: blob + mode preimage.
184    File {
185        /// Previous blob ID (`object_id`).
186        old_blob_id: ObjectId,
187        /// Previous mode bits (`u32`).
188        old_mode: u32,
189    },
190    /// Symlink node: target preimage.
191    Symlink {
192        /// Previous symlink target (`utf8`).
193        old_target: String,
194    },
195}
196
197/// Delete a node (FDD-03 §9.3; the wire tag is retained as `delete_file`). The
198/// preimage is discriminated by `old_node_kind`: text/binary file nodes carry
199/// `old_blob_id` + `old_mode`; symlink nodes carry `old_target`.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct DeleteNode {
202    /// Repo-relative UTF-8 path (`repo_path`).
203    pub path: String,
204    /// Node identity (`bytes`, 32).
205    pub node_id: NodeId,
206    /// Previous node kind (`enum_u16`); must agree with the preimage.
207    pub old_node_kind: NodeKind,
208    /// Discriminated deletion preimage.
209    pub preimage: DeleteNodePreimage,
210}
211
212impl DeleteNode {
213    /// Reject `old_node_kind` / preimage discriminator mismatches and an all-zero
214    /// `node_id` (FDD-03 §9.3 forbids the reserved value in any node-bearing op).
215    pub fn validate(&self) -> Result<()> {
216        if self.node_id.is_zero() {
217            return Err(PrikkError::CanonicalEncoding(
218                "DeleteNode node_id must be nonzero".to_string(),
219            ));
220        }
221        let consistent = matches!(
222            (self.old_node_kind, &self.preimage),
223            (
224                NodeKind::TextFile | NodeKind::BinaryFile,
225                DeleteNodePreimage::File { .. }
226            ) | (NodeKind::Symlink, DeleteNodePreimage::Symlink { .. })
227        );
228        if !consistent {
229            return Err(PrikkError::CanonicalEncoding(
230                "DeleteNode old_node_kind does not match preimage discriminator".to_string(),
231            ));
232        }
233        Ok(())
234    }
235}
236
237impl CanonicalEncode for DeleteNode {
238    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
239        self.validate()?;
240        writer.field_repo_path(1, &self.path)?;
241        writer.field_bytes(2, self.node_id.as_bytes())?;
242        writer.field_enum_u16(3, self.old_node_kind.code())?;
243        match &self.preimage {
244            DeleteNodePreimage::File {
245                old_blob_id,
246                old_mode,
247            } => {
248                writer.field_object_id(4, old_blob_id)?;
249                writer.field_u32(6, *old_mode)?;
250            }
251            DeleteNodePreimage::Symlink { old_target } => {
252                writer.field_string(5, old_target)?;
253            }
254        }
255        Ok(())
256    }
257}
258
259/// Text edit payload using content-anchor identity.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct EditText {
262    /// Node identity (`bytes`, 32). EditText is node-addressed, not path-addressed.
263    pub node_id: NodeId,
264    /// Content-anchor span identity (`bytes`, 32; FDD-01 §5.1).
265    pub span_id: [u8; TEXT_SPAN_HASH_BYTES],
266    /// SHA-256 of `old_span_text`; the validator binds the two.
267    pub old_span_hash: [u8; TEXT_SPAN_HASH_BYTES],
268    /// Bounded left-context hash (`bytes`, 32).
269    pub left_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
270    /// Bounded right-context hash (`bytes`, 32).
271    pub right_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
272    /// New span bytes (`bytes`); UTF-8 text for v1, stored verbatim (never NFC).
273    pub replacement_text: Vec<u8>,
274    /// Optional presentation hint (line); not part of algebraic identity.
275    pub presentation_hint_line: Option<u32>,
276    /// Optional presentation hint (column); not part of algebraic identity.
277    pub presentation_hint_column: Option<u32>,
278    /// Old span bytes (`bytes`); UTF-8 for v1, verbatim; inverse material.
279    pub old_span_text: Vec<u8>,
280}
281
282impl EditText {
283    /// Validate the FDD-03 §9.3 EditText record contract: nonzero `node_id`,
284    /// `old_span_hash == SHA-256(old_span_text)`, and both span-text fields are
285    /// well-formed UTF-8 (non-UTF-8 content must use `ReplaceBinary`).
286    pub fn validate(&self) -> Result<()> {
287        if self.node_id.is_zero() {
288            return Err(PrikkError::CanonicalEncoding(
289                "EditText node_id must be nonzero".to_string(),
290            ));
291        }
292        if self.old_span_hash != text_span_hash(&self.old_span_text) {
293            return Err(PrikkError::CanonicalEncoding(
294                "EditText old_span_hash must equal SHA-256(old_span_text)".to_string(),
295            ));
296        }
297        if core::str::from_utf8(&self.old_span_text).is_err() {
298            return Err(PrikkError::CanonicalEncoding(
299                "EditText old_span_text must be well-formed UTF-8".to_string(),
300            ));
301        }
302        if core::str::from_utf8(&self.replacement_text).is_err() {
303            return Err(PrikkError::CanonicalEncoding(
304                "EditText replacement_text must be well-formed UTF-8".to_string(),
305            ));
306        }
307        Ok(())
308    }
309}
310
311impl CanonicalEncode for EditText {
312    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
313        self.validate()?;
314        writer.field_bytes(1, self.node_id.as_bytes())?;
315        writer.field_bytes(2, &self.span_id)?;
316        writer.field_bytes(3, &self.old_span_hash)?;
317        writer.field_bytes(4, &self.left_anchor_hash)?;
318        writer.field_bytes(5, &self.right_anchor_hash)?;
319        writer.field_bytes(6, &self.replacement_text)?;
320        if let Some(line) = self.presentation_hint_line {
321            writer.field_u32(7, line)?;
322        }
323        if let Some(column) = self.presentation_hint_column {
324            writer.field_u32(8, column)?;
325        }
326        writer.field_bytes(9, &self.old_span_text)?;
327        Ok(())
328    }
329}
330
331/// Rename path payload (FDD-03 §9.3, node-addressed).
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct RenamePath {
334    /// Node identity (`bytes`, 32).
335    pub node_id: NodeId,
336    /// Old repo-relative path (`repo_path`).
337    pub old_path: String,
338    /// New repo-relative path (`repo_path`).
339    pub new_path: String,
340}
341
342impl RenamePath {
343    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
344    /// persisted node-bearing operation, and the encoder produces identity bytes.
345    pub fn validate(&self) -> Result<()> {
346        if self.node_id.is_zero() {
347            return Err(PrikkError::CanonicalEncoding(
348                "RenamePath node_id must be nonzero".to_string(),
349            ));
350        }
351        Ok(())
352    }
353}
354
355impl CanonicalEncode for RenamePath {
356    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
357        self.validate()?;
358        writer.field_bytes(1, self.node_id.as_bytes())?;
359        writer.field_repo_path(2, &self.old_path)?;
360        writer.field_repo_path(3, &self.new_path)?;
361        Ok(())
362    }
363}
364
365/// Permission change payload (FDD-03 §9.3, node-addressed).
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct ChangePerm {
368    /// Node identity (`bytes`, 32).
369    pub node_id: NodeId,
370    /// Old mode bits (`u32`).
371    pub old_mode: u32,
372    /// New mode bits (`u32`).
373    pub new_mode: u32,
374}
375
376impl ChangePerm {
377    /// Reject an all-zero `node_id` (FDD-03 §9.3).
378    pub fn validate(&self) -> Result<()> {
379        if self.node_id.is_zero() {
380            return Err(PrikkError::CanonicalEncoding(
381                "ChangePerm node_id must be nonzero".to_string(),
382            ));
383        }
384        Ok(())
385    }
386}
387
388impl CanonicalEncode for ChangePerm {
389    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
390        self.validate()?;
391        writer.field_bytes(1, self.node_id.as_bytes())?;
392        writer.field_u32(2, self.old_mode)?;
393        writer.field_u32(3, self.new_mode)?;
394        Ok(())
395    }
396}
397
398/// Symlink creation payload (FDD-03 §9.3). Note tag order: `path` (1), then
399/// `node_id` (2), then `target` (3).
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct CreateSymlink {
402    /// Repo-relative UTF-8 path (`repo_path`).
403    pub path: String,
404    /// Node identity (`bytes`, 32).
405    pub node_id: NodeId,
406    /// Symlink target (`utf8_string`). Static escape/four-boundary validation
407    /// (FDD-04 §5.4a / §13.1) is a later increment; this reconciles identity bytes.
408    pub target: String,
409}
410
411impl CreateSymlink {
412    /// Reject an all-zero `node_id` (FDD-03 §9.3).
413    pub fn validate(&self) -> Result<()> {
414        if self.node_id.is_zero() {
415            return Err(PrikkError::CanonicalEncoding(
416                "CreateSymlink node_id must be nonzero".to_string(),
417            ));
418        }
419        Ok(())
420    }
421}
422
423impl CanonicalEncode for CreateSymlink {
424    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
425        self.validate()?;
426        writer.field_repo_path(1, &self.path)?;
427        writer.field_bytes(2, self.node_id.as_bytes())?;
428        writer.field_string(3, &self.target)?;
429        Ok(())
430    }
431}
432
433/// Binary replacement payload.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct ReplaceBinary {
436    /// Node identity (`bytes`, 32).
437    pub node_id: NodeId,
438    /// Old blob ID (`object_id`).
439    pub old_blob_id: ObjectId,
440    /// New blob ID (`object_id`).
441    pub new_blob_id: ObjectId,
442}
443
444impl ReplaceBinary {
445    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
446    /// persisted node-bearing operation, and the encoder produces identity bytes.
447    pub fn validate(&self) -> Result<()> {
448        if self.node_id.is_zero() {
449            return Err(PrikkError::CanonicalEncoding(
450                "ReplaceBinary node_id must be nonzero".to_string(),
451            ));
452        }
453        Ok(())
454    }
455}
456
457impl CanonicalEncode for ReplaceBinary {
458    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
459        self.validate()?;
460        writer.field_bytes(1, self.node_id.as_bytes())?;
461        writer.field_object_id(2, &self.old_blob_id)?;
462        writer.field_object_id(3, &self.new_blob_id)?;
463        Ok(())
464    }
465}