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::{CanonicalEncode, CanonicalWriter, ObjectId};
8
9/// Number of bytes in a content-anchored text span hash.
10pub const TEXT_SPAN_HASH_BYTES: usize = 32;
11
12/// Compute the stable hash used by content-anchored text edit preconditions.
13#[must_use]
14pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
15    prikk_hash::sha256(bytes)
16}
17
18/// Validate a stable content-anchor identifier.
19pub fn validate_text_anchor_id(value: &str) -> Result<()> {
20    if value.is_empty() {
21        return Err(PrikkError::CanonicalEncoding(
22            "text anchor id must not be empty".to_string(),
23        ));
24    }
25    if !value.is_ascii() {
26        return Err(PrikkError::CanonicalEncoding(
27            "text anchor id must be ASCII in v1".to_string(),
28        ));
29    }
30    if value.bytes().any(|byte| byte < 0x21 || byte == 0x7f) {
31        return Err(PrikkError::CanonicalEncoding(
32            "text anchor id must not contain whitespace or control characters".to_string(),
33        ));
34    }
35    Ok(())
36}
37
38/// Patch payload.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PatchPayload {
41    /// Operations in semantic order. `op_seq` must be contiguous from 1.
42    pub operations: Vec<Operation>,
43    /// Parent patch IDs. Sorted ascending.
44    pub parent_patch_ids: Vec<ObjectId>,
45    /// Advisory intent.
46    pub intent: Option<Intent>,
47    /// Patch-level preconditions, sorted by key.
48    pub preconditions: Vec<OperationConditionEntry>,
49}
50
51impl PatchPayload {
52    /// Validate ordering and duplicate constraints.
53    pub fn validate(&self) -> Result<()> {
54        let op_seq: Vec<u32> = self.operations.iter().map(|op| op.op_seq).collect();
55        if !is_contiguous_op_seq(&op_seq) {
56            return Err(PrikkError::CanonicalEncoding(
57                "patch operations must have contiguous op_seq values starting at 1".to_string(),
58            ));
59        }
60        if !is_strictly_sorted(&self.parent_patch_ids) {
61            return Err(PrikkError::CanonicalEncoding(
62                "parent_patch_ids must be sorted and unique".to_string(),
63            ));
64        }
65        if !is_strictly_sorted(&self.preconditions) {
66            return Err(PrikkError::CanonicalEncoding(
67                "patch preconditions must be sorted and unique".to_string(),
68            ));
69        }
70        Ok(())
71    }
72}
73
74impl CanonicalEncode for PatchPayload {
75    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
76        self.validate()?;
77        writer.repeated_record(1, &self.operations)?;
78        writer.repeated_object_id(2, &self.parent_patch_ids)?;
79        if let Some(intent) = self.intent {
80            writer.field_u32(3, u32::from(intent.code()))?;
81        }
82        writer.repeated_record(4, &self.preconditions)?;
83        Ok(())
84    }
85}
86
87/// A single operation inside a patch.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Operation {
90    /// Strict operation sequence, starting at 1 inside the patch.
91    pub op_seq: u32,
92    /// Optional stable label for UI/debugging.
93    pub op_id: Option<String>,
94    /// Inline operation preconditions.
95    pub preconditions: Vec<OperationCondition>,
96    /// Operation kind.
97    pub kind: OperationKind,
98}
99
100impl CanonicalEncode for Operation {
101    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
102        writer.field_u32(1, self.op_seq)?;
103        writer.field_string_opt(2, self.op_id.as_deref())?;
104        writer.repeated_record(3, &self.preconditions)?;
105        match &self.kind {
106            OperationKind::CreateFile(value) => writer.field_record(10, value)?,
107            OperationKind::DeleteFile(value) => writer.field_record(11, value)?,
108            OperationKind::EditText(value) => writer.field_record(12, value)?,
109            OperationKind::RenamePath(value) => writer.field_record(13, value)?,
110            OperationKind::ChangePerm(value) => writer.field_record(14, value)?,
111            OperationKind::CreateSymlink(value) => writer.field_record(15, value)?,
112            OperationKind::ReplaceBinary(value) => writer.field_record(16, value)?,
113        }
114        Ok(())
115    }
116}
117
118/// Operation variants.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum OperationKind {
121    /// Create a text or binary file.
122    CreateFile(CreateFile),
123    /// Delete a file.
124    DeleteFile(DeleteFile),
125    /// Edit text using content-anchored spans.
126    EditText(EditText),
127    /// Rename a path.
128    RenamePath(RenamePath),
129    /// Change Unix-like permissions.
130    ChangePerm(ChangePerm),
131    /// Create a symbolic link.
132    CreateSymlink(CreateSymlink),
133    /// Replace an opaque binary blob.
134    ReplaceBinary(ReplaceBinary),
135}
136
137/// Create file payload.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct CreateFile {
140    /// Repo-relative UTF-8 path.
141    pub path: String,
142    /// Initial blob ID.
143    pub blob_id: ObjectId,
144    /// Mode bits.
145    pub mode: u32,
146}
147
148impl CanonicalEncode for CreateFile {
149    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
150        writer.field_string(1, &self.path)?;
151        writer.field_bytes(2, self.blob_id.as_bytes())?;
152        writer.field_u32(3, self.mode)?;
153        Ok(())
154    }
155}
156
157/// Delete file payload.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct DeleteFile {
160    /// Repo-relative UTF-8 path.
161    pub path: String,
162    /// Previous blob ID needed for inverse/repair reachability.
163    pub old_blob_id: ObjectId,
164}
165
166impl CanonicalEncode for DeleteFile {
167    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
168        writer.field_string(1, &self.path)?;
169        writer.field_bytes(2, self.old_blob_id.as_bytes())?;
170        Ok(())
171    }
172}
173
174/// Text edit payload using content-anchor identity.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct EditText {
177    /// Repo-relative UTF-8 path.
178    pub path: String,
179    /// Stable content-anchor identifier.
180    ///
181    /// This is a logical span identity, not a byte or line offset. Presentation offsets may be
182    /// derived by later layers, but they are never part of the patch precondition identity.
183    pub anchor_id: String,
184    /// Old content hash precondition for the edited span.
185    pub old_span_hash: [u8; TEXT_SPAN_HASH_BYTES],
186    /// Replacement text.
187    pub replacement: String,
188}
189
190impl EditText {
191    /// Validate the content-anchor part of the edit contract.
192    pub fn validate(&self) -> Result<()> {
193        if self.path.is_empty() {
194            return Err(PrikkError::CanonicalEncoding(
195                "EditText path must not be empty".to_string(),
196            ));
197        }
198        validate_text_anchor_id(&self.anchor_id)?;
199        Ok(())
200    }
201}
202
203impl CanonicalEncode for EditText {
204    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
205        self.validate()?;
206        writer.field_string(1, &self.path)?;
207        writer.field_string(2, &self.anchor_id)?;
208        writer.field_bytes(3, &self.old_span_hash)?;
209        writer.field_string(4, &self.replacement)?;
210        Ok(())
211    }
212}
213
214/// Rename path payload.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct RenamePath {
217    /// Source path.
218    pub src: String,
219    /// Destination path.
220    pub dst: String,
221}
222
223impl CanonicalEncode for RenamePath {
224    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
225        writer.field_string(1, &self.src)?;
226        writer.field_string(2, &self.dst)?;
227        Ok(())
228    }
229}
230
231/// Permission change payload.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct ChangePerm {
234    /// Path.
235    pub path: String,
236    /// Old mode.
237    pub old_mode: u32,
238    /// New mode.
239    pub new_mode: u32,
240}
241
242impl CanonicalEncode for ChangePerm {
243    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
244        writer.field_string(1, &self.path)?;
245        writer.field_u32(2, self.old_mode)?;
246        writer.field_u32(3, self.new_mode)?;
247        Ok(())
248    }
249}
250
251/// Symlink creation payload.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct CreateSymlink {
254    /// Link path.
255    pub path: String,
256    /// Link target string.
257    pub target: String,
258}
259
260impl CanonicalEncode for CreateSymlink {
261    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
262        writer.field_string(1, &self.path)?;
263        writer.field_string(2, &self.target)?;
264        Ok(())
265    }
266}
267
268/// Binary replacement payload.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct ReplaceBinary {
271    /// Path.
272    pub path: String,
273    /// Old blob ID.
274    pub old_blob_id: ObjectId,
275    /// New blob ID.
276    pub new_blob_id: ObjectId,
277}
278
279impl CanonicalEncode for ReplaceBinary {
280    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
281        writer.field_string(1, &self.path)?;
282        writer.field_bytes(2, self.old_blob_id.as_bytes())?;
283        writer.field_bytes(3, self.new_blob_id.as_bytes())?;
284        Ok(())
285    }
286}