Skip to main content

objects/object/
structured_conflict.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Merge conflicts as structured data.
3//!
4//! Today, merge conflicts surface only as text markers in the working tree
5//! (`<<<<<<<` / `=======` / `>>>>>>>`). That works for humans with editors,
6//! and is unworkable for agents that need to resolve conflicts
7//! programmatically without parsing markers.
8//!
9//! [`StructuredConflict`] makes the conflict itself first-class: the
10//! conflicting symbol, the three sides (base / ours / theirs), and any
11//! candidate resolutions an upstream module suggested. The text-marker
12//! representation in the working tree is *one rendering* of this object, not
13//! its source of truth — see `crates/repo/src/merge_state.rs::render_text_markers`.
14
15use serde::{Deserialize, Serialize};
16
17use crate::object::{
18    hash::{ChangeId, ContentHash},
19    state_review::SymbolAnchor,
20};
21
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct StructuredConflict {
24    pub format_version: u8,
25    pub conflicts: Vec<ConflictSymbol>,
26}
27
28impl StructuredConflict {
29    pub const FORMAT_VERSION: u8 = 1;
30
31    pub fn new(conflicts: Vec<ConflictSymbol>) -> Self {
32        Self {
33            format_version: Self::FORMAT_VERSION,
34            conflicts,
35        }
36    }
37
38    pub fn encode(&self) -> Result<Vec<u8>, ConflictError> {
39        rmp_serde::to_vec(self).map_err(|err| ConflictError::Encoding(err.to_string()))
40    }
41
42    pub fn decode(bytes: &[u8]) -> Result<Self, ConflictError> {
43        let blob: Self =
44            rmp_serde::from_slice(bytes).map_err(|err| ConflictError::Encoding(err.to_string()))?;
45        blob.validate()?;
46        Ok(blob)
47    }
48
49    pub fn validate(&self) -> Result<(), ConflictError> {
50        if self.format_version != Self::FORMAT_VERSION {
51            return Err(ConflictError::UnsupportedVersion(self.format_version));
52        }
53        for c in &self.conflicts {
54            c.validate()?;
55        }
56        Ok(())
57    }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ConflictSymbol {
62    /// Stable id for this specific conflict (e.g., a UUID); used by
63    /// `heddle conflict resolve <id>` to address it without parsing
64    /// file paths or line numbers.
65    pub id: String,
66    pub anchor: SymbolAnchor,
67    pub base: ConflictSide,
68    pub ours: ConflictSide,
69    pub theirs: ConflictSide,
70    /// Auto-detected candidate resolutions, in display order. The list may
71    /// be empty when no candidate is obvious.
72    #[serde(default)]
73    pub candidate_resolutions: Vec<ConflictResolution>,
74}
75
76impl ConflictSymbol {
77    pub fn validate(&self) -> Result<(), ConflictError> {
78        if self.id.is_empty() {
79            return Err(ConflictError::EmptyId);
80        }
81        if self.anchor.file.is_empty() {
82            return Err(ConflictError::EmptyAnchorFile);
83        }
84        if self.anchor.symbol.is_empty() {
85            return Err(ConflictError::EmptyAnchorSymbol);
86        }
87        Ok(())
88    }
89}
90
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ConflictSide {
93    /// The state this side originated from. `None` is permitted only for the
94    /// base when there is no common ancestor.
95    #[serde(default)]
96    pub source_state: Option<ChangeId>,
97    pub body: String,
98    pub body_hash: ContentHash,
99}
100
101impl ConflictSide {
102    pub fn from_body(source_state: Option<ChangeId>, body: impl Into<String>) -> Self {
103        let body = body.into();
104        let body_hash = ContentHash::compute(body.as_bytes());
105        Self {
106            source_state,
107            body,
108            body_hash,
109        }
110    }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub enum ConflictResolution {
115    TakeOurs,
116    TakeTheirs,
117    TakeBase,
118    Custom { body: String, rationale: String },
119}
120
121#[derive(Debug, thiserror::Error)]
122pub enum ConflictError {
123    #[error("unsupported structured conflict version {0}")]
124    UnsupportedVersion(u8),
125    #[error("conflict id must not be empty")]
126    EmptyId,
127    #[error("conflict anchor must reference a non-empty file")]
128    EmptyAnchorFile,
129    #[error("conflict anchor must reference a non-empty symbol")]
130    EmptyAnchorSymbol,
131    #[error("structured conflict encoding error: {0}")]
132    Encoding(String),
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn sample_conflict() -> ConflictSymbol {
140        ConflictSymbol {
141            id: "c-1".into(),
142            anchor: SymbolAnchor::new("src/lib.rs", "merge_target"),
143            base: ConflictSide::from_body(Some(ChangeId::from_bytes([1; 16])), "fn x() { 0 }"),
144            ours: ConflictSide::from_body(Some(ChangeId::from_bytes([2; 16])), "fn x() { 1 }"),
145            theirs: ConflictSide::from_body(Some(ChangeId::from_bytes([3; 16])), "fn x() { 2 }"),
146            candidate_resolutions: vec![
147                ConflictResolution::TakeOurs,
148                ConflictResolution::TakeTheirs,
149            ],
150        }
151    }
152
153    #[test]
154    fn three_way_conflict_roundtrip() {
155        let blob = StructuredConflict::new(vec![sample_conflict()]);
156        let bytes = blob.encode().unwrap();
157        let decoded = StructuredConflict::decode(&bytes).unwrap();
158        assert_eq!(blob, decoded);
159    }
160
161    #[test]
162    fn empty_conflicts_list_validates() {
163        let blob = StructuredConflict::new(vec![]);
164        blob.validate().unwrap();
165    }
166
167    #[test]
168    fn empty_id_rejected() {
169        let mut c = sample_conflict();
170        c.id = String::new();
171        assert!(matches!(c.validate(), Err(ConflictError::EmptyId)));
172    }
173
174    #[test]
175    fn body_hash_matches_body() {
176        let side = ConflictSide::from_body(None, "fn x() { 0 }");
177        assert_eq!(side.body_hash, ContentHash::compute(b"fn x() { 0 }"));
178    }
179
180    #[test]
181    fn future_version_rejected() {
182        let blob = StructuredConflict {
183            format_version: StructuredConflict::FORMAT_VERSION + 1,
184            conflicts: vec![],
185        };
186        assert!(matches!(
187            blob.validate(),
188            Err(ConflictError::UnsupportedVersion(_))
189        ));
190    }
191}