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
28versioned_msgpack_blob! {
29    blob: StructuredConflict,
30    item: ConflictSymbol,
31    field: conflicts,
32    error: ConflictError,
33    codec_err: Encoding,
34    version: 1,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ConflictSymbol {
39    /// Stable id for this specific conflict (e.g., a UUID); used by
40    /// `heddle conflict resolve <id>` to address it without parsing
41    /// file paths or line numbers.
42    pub id: String,
43    pub anchor: SymbolAnchor,
44    pub base: ConflictSide,
45    pub ours: ConflictSide,
46    pub theirs: ConflictSide,
47    /// Auto-detected candidate resolutions, in display order. The list may
48    /// be empty when no candidate is obvious.
49    #[serde(default)]
50    pub candidate_resolutions: Vec<ConflictResolution>,
51}
52
53impl ConflictSymbol {
54    pub fn validate(&self) -> Result<(), ConflictError> {
55        if self.id.is_empty() {
56            return Err(ConflictError::EmptyId);
57        }
58        if self.anchor.file.is_empty() {
59            return Err(ConflictError::EmptyAnchorFile);
60        }
61        if self.anchor.symbol.is_empty() {
62            return Err(ConflictError::EmptyAnchorSymbol);
63        }
64        Ok(())
65    }
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ConflictSide {
70    /// The state this side originated from. `None` is permitted only for the
71    /// base when there is no common ancestor.
72    #[serde(default)]
73    pub source_state: Option<ChangeId>,
74    pub body: String,
75    pub body_hash: ContentHash,
76}
77
78impl ConflictSide {
79    pub fn from_body(source_state: Option<ChangeId>, body: impl Into<String>) -> Self {
80        let body = body.into();
81        let body_hash = ContentHash::compute(body.as_bytes());
82        Self {
83            source_state,
84            body,
85            body_hash,
86        }
87    }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub enum ConflictResolution {
92    TakeOurs,
93    TakeTheirs,
94    TakeBase,
95    Custom { body: String, rationale: String },
96}
97
98#[derive(Debug, thiserror::Error)]
99pub enum ConflictError {
100    #[error("unsupported structured conflict version {0}")]
101    UnsupportedVersion(u8),
102    #[error("conflict id must not be empty")]
103    EmptyId,
104    #[error("conflict anchor must reference a non-empty file")]
105    EmptyAnchorFile,
106    #[error("conflict anchor must reference a non-empty symbol")]
107    EmptyAnchorSymbol,
108    #[error("structured conflict encoding error: {0}")]
109    Encoding(String),
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn sample_conflict() -> ConflictSymbol {
117        ConflictSymbol {
118            id: "c-1".into(),
119            anchor: SymbolAnchor::new("src/lib.rs", "merge_target"),
120            base: ConflictSide::from_body(Some(ChangeId::from_bytes([1; 16])), "fn x() { 0 }"),
121            ours: ConflictSide::from_body(Some(ChangeId::from_bytes([2; 16])), "fn x() { 1 }"),
122            theirs: ConflictSide::from_body(Some(ChangeId::from_bytes([3; 16])), "fn x() { 2 }"),
123            candidate_resolutions: vec![
124                ConflictResolution::TakeOurs,
125                ConflictResolution::TakeTheirs,
126            ],
127        }
128    }
129
130    #[test]
131    fn three_way_conflict_roundtrip() {
132        let blob = StructuredConflict::new(vec![sample_conflict()]);
133        let bytes = blob.encode().unwrap();
134        let decoded = StructuredConflict::decode(&bytes).unwrap();
135        assert_eq!(blob, decoded);
136    }
137
138    #[test]
139    fn empty_conflicts_list_validates() {
140        let blob = StructuredConflict::new(vec![]);
141        blob.validate().unwrap();
142    }
143
144    #[test]
145    fn empty_id_rejected() {
146        let mut c = sample_conflict();
147        c.id = String::new();
148        assert!(matches!(c.validate(), Err(ConflictError::EmptyId)));
149    }
150
151    #[test]
152    fn body_hash_matches_body() {
153        let side = ConflictSide::from_body(None, "fn x() { 0 }");
154        assert_eq!(side.body_hash, ContentHash::compute(b"fn x() { 0 }"));
155    }
156
157    #[test]
158    fn future_version_rejected() {
159        let blob = StructuredConflict {
160            format_version: StructuredConflict::FORMAT_VERSION + 1,
161            conflicts: vec![],
162        };
163        assert!(matches!(
164            blob.validate(),
165            Err(ConflictError::UnsupportedVersion(_))
166        ));
167    }
168}