objects/object/
structured_conflict.rs1use 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 pub id: String,
66 pub anchor: SymbolAnchor,
67 pub base: ConflictSide,
68 pub ours: ConflictSide,
69 pub theirs: ConflictSide,
70 #[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 #[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}