heddle_object_model/object/
structured_conflict.rs1use std::collections::HashSet;
5
6use serde::{Deserialize, Serialize};
7
8use crate::object::{
9 blob::Blob,
10 hash::{ContentHash, StateId},
11};
12
13const CONFLICT_ID_DOMAIN: &[u8] = b"heddle-conflict-region-v1\0";
14
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct StructuredConflict {
17 pub format_version: u8,
18 pub conflicts: Vec<ConflictRegion>,
19}
20
21impl StructuredConflict {
22 pub const FORMAT_VERSION: u8 = 2;
23
24 pub fn new(conflicts: Vec<ConflictRegion>) -> Self {
25 Self {
26 format_version: Self::FORMAT_VERSION,
27 conflicts,
28 }
29 }
30
31 pub fn encode(&self) -> Result<Vec<u8>, ConflictError> {
32 rmp_serde::to_vec(self).map_err(|err| ConflictError::Encoding(err.to_string()))
33 }
34
35 pub fn decode(bytes: &[u8]) -> Result<Self, ConflictError> {
36 let blob: Self =
37 rmp_serde::from_slice(bytes).map_err(|err| ConflictError::Encoding(err.to_string()))?;
38 blob.validate()?;
39 Ok(blob)
40 }
41
42 pub fn validate(&self) -> Result<(), ConflictError> {
43 if self.format_version != Self::FORMAT_VERSION {
44 return Err(ConflictError::UnsupportedVersion(self.format_version));
45 }
46 let mut ids = HashSet::new();
47 for conflict in &self.conflicts {
48 conflict.validate()?;
49 if !ids.insert(&conflict.id) {
50 return Err(ConflictError::DuplicateId(conflict.id.clone()));
51 }
52 }
53 Ok(())
54 }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ConflictRegion {
60 pub id: String,
62 pub path: String,
63 #[serde(default)]
65 pub symbol: Option<String>,
66 pub occurrence: u32,
68 pub merged_range: ConflictRange,
70 pub base: ConflictSide,
71 pub ours: ConflictSide,
72 pub theirs: ConflictSide,
73}
74
75impl ConflictRegion {
76 #[allow(clippy::too_many_arguments)]
77 pub fn new(
78 path: impl Into<String>,
79 symbol: Option<String>,
80 occurrence: u32,
81 merged_range: ConflictRange,
82 base: ConflictSide,
83 ours: ConflictSide,
84 theirs: ConflictSide,
85 ) -> Result<Self, ConflictError> {
86 let path = path.into();
87 let id = stable_conflict_id(
88 &path,
89 symbol.as_deref(),
90 occurrence,
91 &base.hunk_hash,
92 &ours.hunk_hash,
93 &theirs.hunk_hash,
94 );
95 let conflict = Self {
96 id,
97 path,
98 symbol,
99 occurrence,
100 merged_range,
101 base,
102 ours,
103 theirs,
104 };
105 conflict.validate()?;
106 Ok(conflict)
107 }
108
109 pub fn validate(&self) -> Result<(), ConflictError> {
110 if self.path.is_empty() {
111 return Err(ConflictError::EmptyPath);
112 }
113 if self.symbol.as_ref().is_some_and(String::is_empty) {
114 return Err(ConflictError::EmptySymbol);
115 }
116 self.merged_range.validate()?;
117 if self.merged_range.is_empty() {
118 return Err(ConflictError::EmptyMergedRange);
119 }
120 self.base.validate()?;
121 self.ours.validate()?;
122 self.theirs.validate()?;
123 let expected = stable_conflict_id(
124 &self.path,
125 self.symbol.as_deref(),
126 self.occurrence,
127 &self.base.hunk_hash,
128 &self.ours.hunk_hash,
129 &self.theirs.hunk_hash,
130 );
131 if self.id != expected {
132 return Err(ConflictError::IdMismatch {
133 actual: self.id.clone(),
134 expected,
135 });
136 }
137 Ok(())
138 }
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ConflictRange {
144 pub start_line: u32,
145 pub end_line: u32,
146}
147
148impl ConflictRange {
149 pub fn new(start_line: usize, end_line: usize) -> Result<Self, ConflictError> {
150 let start_line = u32::try_from(start_line).map_err(|_| ConflictError::RangeOverflow)?;
151 let end_line = u32::try_from(end_line).map_err(|_| ConflictError::RangeOverflow)?;
152 let range = Self {
153 start_line,
154 end_line,
155 };
156 range.validate()?;
157 Ok(range)
158 }
159
160 pub fn validate(self) -> Result<(), ConflictError> {
161 if self.start_line > self.end_line {
162 return Err(ConflictError::InvalidRange {
163 start: self.start_line,
164 end: self.end_line,
165 });
166 }
167 Ok(())
168 }
169}
170
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ConflictSide {
174 pub source_state: StateId,
175 #[serde(default)]
177 pub blob_id: Option<ContentHash>,
178 pub range: ConflictRange,
179 pub hunk_hash: ContentHash,
181}
182
183impl ConflictSide {
184 pub fn new(
185 source_state: StateId,
186 blob_id: Option<ContentHash>,
187 range: ConflictRange,
188 blob_bytes: &[u8],
189 ) -> Result<Self, ConflictError> {
190 let hunk = slice_lines(blob_bytes, range)?;
191 let side = Self {
192 source_state,
193 blob_id,
194 range,
195 hunk_hash: ContentHash::compute(hunk),
196 };
197 side.verify_blob(blob_bytes)?;
198 Ok(side)
199 }
200
201 pub fn validate(&self) -> Result<(), ConflictError> {
202 self.range.validate()?;
203 if self.blob_id.is_none() && !self.range.is_empty() {
204 return Err(ConflictError::AbsentBlobHasLines);
205 }
206 Ok(())
207 }
208
209 pub fn verify_blob(&self, blob_bytes: &[u8]) -> Result<(), ConflictError> {
211 match self.blob_id {
212 Some(expected) if Blob::from_slice(blob_bytes).hash() != expected => {
213 return Err(ConflictError::BlobHashMismatch);
214 }
215 None if !blob_bytes.is_empty() => return Err(ConflictError::UnexpectedBlobBytes),
216 _ => {}
217 }
218 let hunk = slice_lines(blob_bytes, self.range)?;
219 if ContentHash::compute(hunk) != self.hunk_hash {
220 return Err(ConflictError::HunkHashMismatch);
221 }
222 Ok(())
223 }
224}
225
226impl ConflictRange {
227 fn is_empty(self) -> bool {
228 self.start_line == self.end_line
229 }
230}
231
232fn stable_conflict_id(
233 path: &str,
234 symbol: Option<&str>,
235 occurrence: u32,
236 base: &ContentHash,
237 ours: &ContentHash,
238 theirs: &ContentHash,
239) -> String {
240 let mut bytes = Vec::with_capacity(CONFLICT_ID_DOMAIN.len() + path.len() + 128);
241 bytes.extend_from_slice(CONFLICT_ID_DOMAIN);
242 push_field(&mut bytes, path.as_bytes());
243 push_field(&mut bytes, symbol.unwrap_or("").as_bytes());
244 bytes.extend_from_slice(&occurrence.to_le_bytes());
245 bytes.extend_from_slice(base.as_bytes());
246 bytes.extend_from_slice(ours.as_bytes());
247 bytes.extend_from_slice(theirs.as_bytes());
248 format!("conflict-{}", ContentHash::compute(&bytes).to_hex())
249}
250
251fn push_field(bytes: &mut Vec<u8>, field: &[u8]) {
252 bytes.extend_from_slice(&(field.len() as u64).to_le_bytes());
253 bytes.extend_from_slice(field);
254}
255
256fn slice_lines(bytes: &[u8], range: ConflictRange) -> Result<&[u8], ConflictError> {
257 range.validate()?;
258 let start = line_offset(bytes, range.start_line).ok_or(ConflictError::RangeOutOfBounds)?;
259 let end = line_offset(bytes, range.end_line).ok_or(ConflictError::RangeOutOfBounds)?;
260 Ok(&bytes[start..end])
261}
262
263fn line_offset(bytes: &[u8], line: u32) -> Option<usize> {
264 if line == 0 {
265 return Some(0);
266 }
267 let mut current = 0u32;
268 for (index, byte) in bytes.iter().enumerate() {
269 if *byte == b'\n' {
270 current += 1;
271 if current == line {
272 return Some(index + 1);
273 }
274 }
275 }
276 (current == line || (bytes.last().is_some_and(|byte| *byte != b'\n') && current + 1 == line))
277 .then_some(bytes.len())
278}
279
280#[derive(Debug, thiserror::Error)]
281pub enum ConflictError {
282 #[error("unsupported structured conflict version {0}")]
283 UnsupportedVersion(u8),
284 #[error("conflict path must not be empty")]
285 EmptyPath,
286 #[error("conflict symbol must be absent rather than empty")]
287 EmptySymbol,
288 #[error("rendered conflict marker range must not be empty")]
289 EmptyMergedRange,
290 #[error("duplicate conflict id {0}")]
291 DuplicateId(String),
292 #[error("conflict id {actual} does not match stable address {expected}")]
293 IdMismatch { actual: String, expected: String },
294 #[error("conflict range {start}..{end} is invalid")]
295 InvalidRange { start: u32, end: u32 },
296 #[error("conflict range exceeds u32 line addressing")]
297 RangeOverflow,
298 #[error("conflict range exceeds source blob")]
299 RangeOutOfBounds,
300 #[error("an absent source blob cannot contain hunk lines")]
301 AbsentBlobHasLines,
302 #[error("source blob bytes do not match the recorded BLAKE3 id")]
303 BlobHashMismatch,
304 #[error("source hunk bytes do not match the recorded BLAKE3 hash")]
305 HunkHashMismatch,
306 #[error("bytes were supplied for an absent source blob")]
307 UnexpectedBlobBytes,
308 #[error("structured conflict encoding error: {0}")]
309 Encoding(String),
310}