1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::models::Requirement;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RequirementConflict {
21 pub id: Uuid,
23 pub spec_id: String,
25 pub fields: Vec<FieldConflict>,
27 pub local_modified: DateTime<Utc>,
29 pub remote_modified: DateTime<Utc>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FieldConflict {
36 pub field: String,
38 pub local_value: String,
40 pub remote_value: String,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Resolution {
47 AcceptLocal,
49 AcceptRemote,
51 LastWriteWins,
53}
54
55pub fn detect_conflict(local: &Requirement, remote: &Requirement) -> Option<RequirementConflict> {
58 if local.id != remote.id {
59 return None; }
61
62 if local.modified_at == remote.modified_at {
64 return None;
65 }
66
67 let mut fields = Vec::new();
68
69 if local.title != remote.title {
71 fields.push(FieldConflict {
72 field: "title".to_string(),
73 local_value: local.title.clone(),
74 remote_value: remote.title.clone(),
75 });
76 }
77
78 if local.description != remote.description {
79 fields.push(FieldConflict {
80 field: "description".to_string(),
81 local_value: truncate(&local.description, 100),
82 remote_value: truncate(&remote.description, 100),
83 });
84 }
85
86 if local.effective_status() != remote.effective_status() {
87 fields.push(FieldConflict {
88 field: "status".to_string(),
89 local_value: local.effective_status(),
90 remote_value: remote.effective_status(),
91 });
92 }
93
94 if local.effective_priority() != remote.effective_priority() {
95 fields.push(FieldConflict {
96 field: "priority".to_string(),
97 local_value: local.effective_priority(),
98 remote_value: remote.effective_priority(),
99 });
100 }
101
102 if local.owner != remote.owner {
103 fields.push(FieldConflict {
104 field: "owner".to_string(),
105 local_value: local.owner.clone(),
106 remote_value: remote.owner.clone(),
107 });
108 }
109
110 if local.tags != remote.tags {
111 fields.push(FieldConflict {
112 field: "tags".to_string(),
113 local_value: format!("{:?}", local.tags),
114 remote_value: format!("{:?}", remote.tags),
115 });
116 }
117
118 if fields.is_empty() {
119 return None; }
121
122 Some(RequirementConflict {
123 id: local.id,
124 spec_id: local.spec_id.clone().unwrap_or_else(|| local.id.to_string()),
125 fields,
126 local_modified: local.modified_at,
127 remote_modified: remote.modified_at,
128 })
129}
130
131pub fn resolve_conflict(
134 local: &Requirement,
135 remote: &Requirement,
136 resolution: Resolution,
137) -> Requirement {
138 match resolution {
139 Resolution::AcceptLocal => local.clone(),
140 Resolution::AcceptRemote => remote.clone(),
141 Resolution::LastWriteWins => {
142 if local.modified_at >= remote.modified_at {
143 local.clone()
144 } else {
145 remote.clone()
146 }
147 }
148 }
149}
150
151pub fn detect_store_conflicts(
154 local_reqs: &[Requirement],
155 remote_reqs: &[Requirement],
156) -> Vec<RequirementConflict> {
157 let mut conflicts = Vec::new();
158
159 for local in local_reqs {
160 for remote in remote_reqs {
161 if local.id == remote.id {
162 if let Some(conflict) = detect_conflict(local, remote) {
163 conflicts.push(conflict);
164 }
165 break;
166 }
167 }
168 }
169
170 conflicts
171}
172
173fn truncate(s: &str, max: usize) -> String {
174 if s.len() <= max {
175 s.to_string()
176 } else {
177 format!("{}...", &s[..max])
178 }
179}
180
181impl std::fmt::Display for RequirementConflict {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 writeln!(f, "Conflict on {}", self.spec_id)?;
185 writeln!(
186 f,
187 " Local modified: {}",
188 self.local_modified.format("%Y-%m-%d %H:%M:%S")
189 )?;
190 writeln!(
191 f,
192 " Remote modified: {}",
193 self.remote_modified.format("%Y-%m-%d %H:%M:%S")
194 )?;
195 for field in &self.fields {
196 writeln!(f, " Field: {}", field.field)?;
197 writeln!(f, " Local: {}", field.local_value)?;
198 writeln!(f, " Remote: {}", field.remote_value)?;
199 }
200 Ok(())
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn make_req(title: &str, status: &str) -> Requirement {
209 let mut req = Requirement::new(title.to_string(), "description".to_string());
210 req.set_status_from_str(status);
211 req
212 }
213
214 #[test]
215 fn test_no_conflict_identical() {
216 let req = make_req("Title", "Draft");
217 assert!(detect_conflict(&req, &req).is_none());
218 }
219
220 #[test]
221 fn test_no_conflict_same_content_different_time() {
222 let mut local = make_req("Title", "Draft");
223 let mut remote = local.clone();
224 local.modified_at = Utc::now();
226 remote.modified_at = Utc::now();
227 assert!(detect_conflict(&local, &remote).is_none());
229 }
230
231 #[test]
232 fn test_conflict_on_title() {
233 let mut local = make_req("Local Title", "Draft");
234 let mut remote = local.clone();
235 remote.title = "Remote Title".to_string();
236 remote.modified_at = Utc::now();
237
238 let conflict = detect_conflict(&local, &remote);
239 assert!(conflict.is_some());
240
241 let c = conflict.unwrap();
242 assert_eq!(c.fields.len(), 1);
243 assert_eq!(c.fields[0].field, "title");
244 assert_eq!(c.fields[0].local_value, "Local Title");
245 assert_eq!(c.fields[0].remote_value, "Remote Title");
246 }
247
248 #[test]
249 fn test_conflict_multiple_fields() {
250 let mut local = make_req("Title", "Draft");
251 local.owner = "joe".to_string();
252
253 let mut remote = local.clone();
254 remote.title = "Changed Title".to_string();
255 remote.owner = "alice".to_string();
256 remote.set_status_from_str("Approved");
257 remote.modified_at = Utc::now();
258
259 let conflict = detect_conflict(&local, &remote).unwrap();
260 assert_eq!(conflict.fields.len(), 3); }
262
263 #[test]
264 fn test_resolve_accept_local() {
265 let mut local = make_req("Local", "Draft");
266 let mut remote = local.clone();
267 remote.title = "Remote".to_string();
268 remote.modified_at = Utc::now();
269
270 let resolved = resolve_conflict(&local, &remote, Resolution::AcceptLocal);
271 assert_eq!(resolved.title, "Local");
272 }
273
274 #[test]
275 fn test_resolve_accept_remote() {
276 let local = make_req("Local", "Draft");
277 let mut remote = local.clone();
278 remote.title = "Remote".to_string();
279 remote.modified_at = Utc::now();
280
281 let resolved = resolve_conflict(&local, &remote, Resolution::AcceptRemote);
282 assert_eq!(resolved.title, "Remote");
283 }
284
285 #[test]
286 fn test_resolve_lww() {
287 let mut local = make_req("Local", "Draft");
288 let mut remote = local.clone();
289 remote.title = "Remote".to_string();
290
291 local.modified_at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
293 .unwrap()
294 .with_timezone(&Utc);
295 remote.modified_at = chrono::DateTime::parse_from_rfc3339("2026-01-02T00:00:00Z")
296 .unwrap()
297 .with_timezone(&Utc);
298
299 let resolved = resolve_conflict(&local, &remote, Resolution::LastWriteWins);
300 assert_eq!(resolved.title, "Remote"); }
302
303 #[test]
304 fn test_store_conflicts() {
305 let mut req1_local = make_req("Req 1 Local", "Draft");
306 let mut req1_remote = req1_local.clone();
307 req1_remote.title = "Req 1 Remote".to_string();
308 req1_remote.modified_at = Utc::now();
309
310 let req2 = make_req("Req 2", "Draft"); let conflicts = detect_store_conflicts(
313 &[req1_local, req2.clone()],
314 &[req1_remote, req2],
315 );
316 assert_eq!(conflicts.len(), 1);
317 assert_eq!(conflicts[0].fields[0].field, "title");
318 }
319
320 #[test]
321 fn test_conflict_display() {
322 let mut local = make_req("Local", "Draft");
323 local.spec_id = Some("FR-1-001".to_string());
324 let mut remote = local.clone();
325 remote.title = "Remote".to_string();
326 remote.modified_at = Utc::now();
327
328 let conflict = detect_conflict(&local, &remote).unwrap();
329 let display = format!("{}", conflict);
330 assert!(display.contains("FR-1-001"));
331 assert!(display.contains("title"));
332 assert!(display.contains("Local"));
333 assert!(display.contains("Remote"));
334 }
335}