Skip to main content

engram/sync/conflict/
mod.rs

1//! Conflict Resolution with Three-Way Merge (RML-887)
2//!
3//! Provides:
4//! - Automatic conflict detection during sync
5//! - Three-way merge for text content
6//! - Conflict resolution strategies
7//! - Manual conflict review queue
8
9mod detector;
10mod merge;
11mod resolver;
12
13pub use detector::{ConflictDetector, ConflictInfo, ConflictType};
14pub use merge::{MergeResult, ThreeWayMerge};
15pub use resolver::{ConflictQueue, ConflictResolver, Resolution, ResolutionStrategy};
16
17use crate::types::Memory;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20
21/// A version of a memory for conflict resolution (different from types::MemoryVersion)
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SyncMemoryVersion {
24    /// The memory content
25    pub memory: Memory,
26    /// Version identifier (e.g., device ID + timestamp)
27    pub version_id: String,
28    /// When this version was created
29    pub created_at: DateTime<Utc>,
30    /// Device/client that created this version
31    pub source: String,
32    /// Hash of the content for quick comparison
33    pub content_hash: String,
34}
35
36impl SyncMemoryVersion {
37    /// Create a new memory version
38    pub fn new(memory: Memory, source: impl Into<String>) -> Self {
39        let source_str = source.into();
40        let content_hash = Self::compute_hash(&memory);
41        Self {
42            memory,
43            version_id: format!("{}_{}", source_str, Utc::now().timestamp_millis()),
44            created_at: Utc::now(),
45            source: source_str,
46            content_hash,
47        }
48    }
49
50    /// Compute hash of memory content
51    fn compute_hash(memory: &Memory) -> String {
52        use sha2::{Digest, Sha256};
53        let mut hasher = Sha256::new();
54        hasher.update(memory.content.as_bytes());
55        hasher.update(
56            serde_json::to_string(&memory.metadata)
57                .unwrap_or_default()
58                .as_bytes(),
59        );
60        hex::encode(hasher.finalize())[..16].to_string()
61    }
62
63    /// Check if this version has the same content as another
64    pub fn has_same_content(&self, other: &SyncMemoryVersion) -> bool {
65        self.content_hash == other.content_hash
66    }
67}
68
69/// Represents a conflict between versions
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Conflict {
72    /// Unique conflict identifier
73    pub id: String,
74    /// Memory ID that has conflict
75    pub memory_id: i64,
76    /// The base version (common ancestor)
77    pub base: Option<SyncMemoryVersion>,
78    /// Local version
79    pub local: SyncMemoryVersion,
80    /// Remote version
81    pub remote: SyncMemoryVersion,
82    /// Type of conflict
83    pub conflict_type: ConflictType,
84    /// When the conflict was detected
85    pub detected_at: DateTime<Utc>,
86    /// Whether this has been resolved
87    pub resolved: bool,
88    /// Resolution if resolved
89    pub resolution: Option<Resolution>,
90}
91
92impl Conflict {
93    /// Create a new conflict
94    pub fn new(
95        memory_id: i64,
96        base: Option<SyncMemoryVersion>,
97        local: SyncMemoryVersion,
98        remote: SyncMemoryVersion,
99        conflict_type: ConflictType,
100    ) -> Self {
101        Self {
102            id: uuid::Uuid::new_v4().to_string(),
103            memory_id,
104            base,
105            local,
106            remote,
107            conflict_type,
108            detected_at: Utc::now(),
109            resolved: false,
110            resolution: None,
111        }
112    }
113
114    /// Check if auto-resolution is possible
115    pub fn can_auto_resolve(&self) -> bool {
116        matches!(
117            self.conflict_type,
118            ConflictType::MetadataOnly | ConflictType::TagsOnly | ConflictType::NonOverlapping
119        )
120    }
121
122    /// Mark as resolved
123    pub fn resolve(&mut self, resolution: Resolution) {
124        self.resolved = true;
125        self.resolution = Some(resolution);
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::types::MemoryType;
133    use std::collections::HashMap;
134
135    fn create_test_memory(content: &str) -> Memory {
136        Memory {
137            id: 1,
138            content: content.to_string(),
139            memory_type: MemoryType::Note,
140            tags: vec!["test".to_string()],
141            metadata: HashMap::new(),
142            importance: 0.5,
143            access_count: 0,
144            created_at: Utc::now(),
145            updated_at: Utc::now(),
146            last_accessed_at: None,
147            owner_id: None,
148            visibility: crate::types::Visibility::Private,
149            scope: crate::types::MemoryScope::Global,
150            workspace: "default".to_string(),
151            tier: crate::types::MemoryTier::Permanent,
152            version: 1,
153            has_embedding: false,
154            expires_at: None,
155            content_hash: None,
156            event_time: None,
157            event_duration_seconds: None,
158            trigger_pattern: None,
159            procedure_success_count: 0,
160            procedure_failure_count: 0,
161            summary_of_id: None,
162            lifecycle_state: crate::types::LifecycleState::Active,
163        }
164    }
165
166    #[test]
167    fn test_memory_version_hash() {
168        let memory = create_test_memory("Test content");
169        let v1 = SyncMemoryVersion::new(memory.clone(), "device1");
170        let v2 = SyncMemoryVersion::new(memory, "device2");
171
172        // Same content should have same hash
173        assert!(v1.has_same_content(&v2));
174    }
175
176    #[test]
177    fn test_different_content_different_hash() {
178        let m1 = create_test_memory("Content A");
179        let m2 = create_test_memory("Content B");
180
181        let v1 = SyncMemoryVersion::new(m1, "device1");
182        let v2 = SyncMemoryVersion::new(m2, "device1");
183
184        assert!(!v1.has_same_content(&v2));
185    }
186
187    #[test]
188    fn test_conflict_creation() {
189        let base = SyncMemoryVersion::new(create_test_memory("Original"), "base");
190        let local = SyncMemoryVersion::new(create_test_memory("Local change"), "local");
191        let remote = SyncMemoryVersion::new(create_test_memory("Remote change"), "remote");
192
193        let conflict = Conflict::new(1, Some(base), local, remote, ConflictType::ContentConflict);
194
195        assert!(!conflict.resolved);
196        assert!(conflict.resolution.is_none());
197        assert!(!conflict.can_auto_resolve());
198    }
199
200    #[test]
201    fn test_auto_resolvable_conflict() {
202        let local = SyncMemoryVersion::new(create_test_memory("Same"), "local");
203        let remote = SyncMemoryVersion::new(create_test_memory("Same"), "remote");
204
205        let conflict = Conflict::new(1, None, local, remote, ConflictType::MetadataOnly);
206
207        assert!(conflict.can_auto_resolve());
208    }
209}