Skip to main content

heddle_object_model/object/
structured_conflict.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Merge conflicts as content-addressed, inspectable regions.
3
4use 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/// One independently addressable conflict region in a file.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ConflictRegion {
60    /// Deterministic BLAKE3 address derived from the anchor and three hunk hashes.
61    pub id: String,
62    pub path: String,
63    /// Stable semantic address when the source language can be parsed.
64    #[serde(default)]
65    pub symbol: Option<String>,
66    /// Disambiguates byte-identical conflicts under the same anchor.
67    pub occurrence: u32,
68    /// Range occupied by the rendered marker block.
69    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/// Zero-based, half-open line range (`start_line..end_line`).
142#[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/// Provenance and integrity information for one side of a conflict region.
172#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ConflictSide {
174    pub source_state: StateId,
175    /// `None` means the path was absent in this source state.
176    #[serde(default)]
177    pub blob_id: Option<ContentHash>,
178    pub range: ConflictRange,
179    /// BLAKE3 of the exact bytes selected by `range` from `blob_id`.
180    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    /// Verify both the whole-blob address and the selected hunk hash.
210    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}