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
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 pub id: String,
43 pub anchor: SymbolAnchor,
44 pub base: ConflictSide,
45 pub ours: ConflictSide,
46 pub theirs: ConflictSide,
47 #[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 #[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}