Skip to main content

aida_core/
conflict.rs

1// trace:ARCH-distributed-conflict | ai:claude
2//! Conflict detection and resolution for distributed AIDA.
3//!
4//! When two nodes edit the same requirement concurrently, we need to:
5//! 1. Detect the conflict (two versions with divergent histories)
6//! 2. Surface it to the user (don't silently overwrite)
7//! 3. Provide resolution options (accept-mine, accept-theirs, merge)
8//!
9//! This module implements field-level conflict detection by comparing
10//! two versions of a requirement and identifying which fields diverged.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::models::Requirement;
17
18/// A detected conflict between two versions of a requirement.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RequirementConflict {
21    /// The requirement's UUID
22    pub id: Uuid,
23    /// The spec_id (for display)
24    pub spec_id: String,
25    /// Fields that have conflicting values
26    pub fields: Vec<FieldConflict>,
27    /// Timestamp of the local version
28    pub local_modified: DateTime<Utc>,
29    /// Timestamp of the remote version
30    pub remote_modified: DateTime<Utc>,
31}
32
33/// A conflict on a single field.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FieldConflict {
36    /// Name of the conflicting field
37    pub field: String,
38    /// Local value
39    pub local_value: String,
40    /// Remote value
41    pub remote_value: String,
42}
43
44/// How to resolve a conflict.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Resolution {
47    /// Keep the local version for all conflicting fields
48    AcceptLocal,
49    /// Keep the remote version for all conflicting fields
50    AcceptRemote,
51    /// Keep the version with the later timestamp (LWW)
52    LastWriteWins,
53}
54
55/// Detect conflicts between a local and remote version of a requirement.
56/// Returns None if there are no conflicts (versions are identical or one is strictly newer).
57pub fn detect_conflict(local: &Requirement, remote: &Requirement) -> Option<RequirementConflict> {
58    if local.id != remote.id {
59        return None; // Different requirements, not a conflict
60    }
61
62    // If timestamps are identical, no conflict
63    if local.modified_at == remote.modified_at {
64        return None;
65    }
66
67    let mut fields = Vec::new();
68
69    // Compare fields that matter for conflict detection
70    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; // Same content despite different timestamps
120    }
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
131/// Apply a resolution strategy to a conflict.
132/// Returns the resolved requirement.
133pub 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
151/// Detect conflicts between a local store and a set of remote requirements.
152/// Returns all detected conflicts.
153pub 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
181/// Format a conflict for display.
182impl 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        // Same content, different timestamps — no real conflict
225        local.modified_at = Utc::now();
226        remote.modified_at = Utc::now();
227        // modified_at will differ by nanoseconds but content is the same
228        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); // title, status, owner
261    }
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        // Make remote newer
292        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"); // remote is newer
301    }
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"); // no conflict
311
312        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}