Skip to main content

aion_context/
conflict.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Conflict Resolution Module
3//!
4//! Handles divergent version histories when files are modified concurrently
5//! on different machines or by different authors.
6//!
7//! # Conflict Scenarios
8//!
9//! - **Fork**: Same file modified independently from same version
10//! - **Divergent**: Files diverge at some point in history
11//! - **Gap**: Missing versions in the chain
12
13use crate::operations::{FileInfo, VersionInfo};
14
15/// Conflict type detected between two file states
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub enum ConflictType {
18    /// Files diverged from a common ancestor
19    Divergent {
20        /// Common ancestor version number
21        common_ancestor: u64,
22        /// Version where local diverged
23        local_version: u64,
24        /// Version where remote diverged
25        remote_version: u64,
26    },
27    /// Local and remote have same version but different content
28    ContentMismatch {
29        /// Version number with mismatch
30        version: u64,
31        /// Local content hash
32        local_hash: String,
33        /// Remote content hash
34        remote_hash: String,
35    },
36    /// Version gap in the chain
37    VersionGap {
38        /// Expected version number
39        expected: u64,
40        /// Actual version number found
41        found: u64,
42    },
43    /// No conflict detected
44    None,
45}
46
47impl std::fmt::Display for ConflictType {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Divergent {
51                common_ancestor,
52                local_version,
53                remote_version,
54            } => {
55                write!(f, "Divergent histories from version {common_ancestor}: local={local_version}, remote={remote_version}")
56            }
57            Self::ContentMismatch {
58                version,
59                local_hash,
60                remote_hash,
61            } => {
62                write!(f, "Content mismatch at version {version}: local={local_hash}, remote={remote_hash}")
63            }
64            Self::VersionGap { expected, found } => {
65                write!(f, "Version gap: expected {expected}, found {found}")
66            }
67            Self::None => write!(f, "No conflict"),
68        }
69    }
70}
71
72/// Conflict detection result
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct ConflictReport {
75    /// Type of conflict detected
76    pub conflict_type: ConflictType,
77    /// Local file info
78    pub local_version_count: u64,
79    /// Remote file info
80    pub remote_version_count: u64,
81    /// Suggested resolution strategy
82    pub suggested_strategy: MergeStrategy,
83}
84
85/// Merge strategy for resolving conflicts
86#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub enum MergeStrategy {
88    /// Keep local version, discard remote
89    KeepLocal,
90    /// Keep remote version, discard local
91    KeepRemote,
92    /// Keep version with higher version number
93    KeepNewest,
94    /// Manual merge required
95    Manual,
96    /// Append remote versions after local (if linear)
97    Append,
98}
99
100impl std::fmt::Display for MergeStrategy {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::KeepLocal => write!(f, "Keep local"),
104            Self::KeepRemote => write!(f, "Keep remote"),
105            Self::KeepNewest => write!(f, "Keep newest"),
106            Self::Manual => write!(f, "Manual merge required"),
107            Self::Append => write!(f, "Append remote versions"),
108        }
109    }
110}
111
112/// Detect conflicts between local and remote file states
113///
114/// # Arguments
115///
116/// * `local` - Local file info
117/// * `remote` - Remote file info
118///
119/// # Returns
120///
121/// Conflict report with detected type and suggested strategy
122#[must_use]
123pub fn detect_conflict(local: &FileInfo, remote: &FileInfo) -> ConflictReport {
124    if local.file_id != remote.file_id {
125        return file_id_mismatch_report(local, remote);
126    }
127    if local.version_count == remote.version_count {
128        return same_length_report(local, remote);
129    }
130    differing_length_report(local, remote)
131}
132
133fn file_id_mismatch_report(local: &FileInfo, remote: &FileInfo) -> ConflictReport {
134    ConflictReport {
135        conflict_type: ConflictType::ContentMismatch {
136            version: 0,
137            local_hash: format!("{:016x}", local.file_id),
138            remote_hash: format!("{:016x}", remote.file_id),
139        },
140        local_version_count: local.version_count,
141        remote_version_count: remote.version_count,
142        suggested_strategy: MergeStrategy::Manual,
143    }
144}
145
146fn same_length_report(local: &FileInfo, remote: &FileInfo) -> ConflictReport {
147    if versions_match(local, remote) {
148        return ConflictReport {
149            conflict_type: ConflictType::None,
150            local_version_count: local.version_count,
151            remote_version_count: remote.version_count,
152            suggested_strategy: MergeStrategy::KeepLocal,
153        };
154    }
155    ConflictReport {
156        conflict_type: ConflictType::ContentMismatch {
157            version: local.current_version,
158            local_hash: format_version_hash(local),
159            remote_hash: format_version_hash(remote),
160        },
161        local_version_count: local.version_count,
162        remote_version_count: remote.version_count,
163        suggested_strategy: MergeStrategy::Manual,
164    }
165}
166
167fn differing_length_report(local: &FileInfo, remote: &FileInfo) -> ConflictReport {
168    let (shorter, longer) = if local.version_count < remote.version_count {
169        (local, remote)
170    } else {
171        (remote, local)
172    };
173    if is_linear_extension(shorter, longer) {
174        let strategy = if local.version_count < remote.version_count {
175            MergeStrategy::KeepRemote
176        } else {
177            MergeStrategy::KeepLocal
178        };
179        return ConflictReport {
180            conflict_type: ConflictType::None,
181            local_version_count: local.version_count,
182            remote_version_count: remote.version_count,
183            suggested_strategy: strategy,
184        };
185    }
186    ConflictReport {
187        conflict_type: ConflictType::Divergent {
188            common_ancestor: find_common_ancestor(local, remote),
189            local_version: local.current_version,
190            remote_version: remote.current_version,
191        },
192        local_version_count: local.version_count,
193        remote_version_count: remote.version_count,
194        suggested_strategy: MergeStrategy::Manual,
195    }
196}
197
198/// Check if two file states have matching latest versions
199fn versions_match(local: &FileInfo, remote: &FileInfo) -> bool {
200    match (local.versions.last(), remote.versions.last()) {
201        (Some(l), Some(r)) => l.rules_hash == r.rules_hash,
202        (None, None) => true,
203        _ => false,
204    }
205}
206
207/// Format version hash for display
208fn format_version_hash(info: &FileInfo) -> String {
209    info.versions
210        .last()
211        .map_or_else(|| "empty".to_string(), |v| hex::encode(&v.rules_hash[..8]))
212}
213
214/// Check if shorter history is a linear prefix of longer
215fn is_linear_extension(shorter: &FileInfo, longer: &FileInfo) -> bool {
216    if shorter.versions.len() > longer.versions.len() {
217        return false;
218    }
219
220    for (i, short_ver) in shorter.versions.iter().enumerate() {
221        let Some(long_ver) = longer.versions.get(i) else {
222            return false;
223        };
224        if short_ver.rules_hash != long_ver.rules_hash {
225            return false;
226        }
227    }
228
229    true
230}
231
232/// Find the common ancestor version between two divergent histories
233fn find_common_ancestor(local: &FileInfo, remote: &FileInfo) -> u64 {
234    let min_len = std::cmp::min(local.versions.len(), remote.versions.len());
235    let mut last_matching: Option<&VersionInfo> = None;
236
237    for i in 0..min_len {
238        let (Some(l), Some(r)) = (local.versions.get(i), remote.versions.get(i)) else {
239            break;
240        };
241        if l.rules_hash != r.rules_hash {
242            return last_matching.map_or(0, |v| v.version_number);
243        }
244        last_matching = Some(l);
245    }
246
247    last_matching.map_or(0, |v| v.version_number)
248}
249
250/// Conflict marker for manual resolution
251#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
252pub struct ConflictMarker {
253    /// Marker type
254    pub marker_type: MarkerType,
255    /// Start position in content
256    pub start: usize,
257    /// End position in content
258    pub end: usize,
259    /// Source label
260    pub source: String,
261}
262
263/// Type of conflict marker
264#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
265pub enum MarkerType {
266    /// Start of conflicting section
267    ConflictStart,
268    /// Separator between local and remote
269    Separator,
270    /// End of conflicting section
271    ConflictEnd,
272}
273
274/// Create conflict markers for manual merge
275///
276/// Format similar to git merge conflicts:
277/// ```text
278/// <<<<<<< LOCAL
279/// local content
280/// =======
281/// remote content
282/// >>>>>>> REMOTE
283/// ```
284#[must_use]
285pub fn create_conflict_markers(
286    local_content: &[u8],
287    remote_content: &[u8],
288    local_label: &str,
289    remote_label: &str,
290) -> Vec<u8> {
291    let mut result = Vec::new();
292
293    // Start marker
294    result.extend_from_slice(b"<<<<<<< ");
295    result.extend_from_slice(local_label.as_bytes());
296    result.push(b'\n');
297
298    // Local content
299    result.extend_from_slice(local_content);
300    if !local_content.ends_with(b"\n") {
301        result.push(b'\n');
302    }
303
304    // Separator
305    result.extend_from_slice(b"=======\n");
306
307    // Remote content
308    result.extend_from_slice(remote_content);
309    if !remote_content.ends_with(b"\n") {
310        result.push(b'\n');
311    }
312
313    // End marker
314    result.extend_from_slice(b">>>>>>> ");
315    result.extend_from_slice(remote_label.as_bytes());
316    result.push(b'\n');
317
318    result
319}
320
321/// Check if content contains conflict markers
322#[must_use]
323pub fn has_conflict_markers(content: &[u8]) -> bool {
324    let content_str = String::from_utf8_lossy(content);
325    content_str.contains("<<<<<<<") && content_str.contains(">>>>>>>")
326}
327
328/// Parse conflict markers from content
329///
330/// Returns (`local_content`, `remote_content`) if markers found
331#[must_use]
332pub fn parse_conflict_markers(content: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
333    let content_str = String::from_utf8_lossy(content);
334
335    let start_idx = content_str.find("<<<<<<< ")?;
336    let separator_idx = content_str.find("=======")?;
337    let end_idx = content_str.find(">>>>>>> ")?;
338
339    if start_idx >= separator_idx || separator_idx >= end_idx {
340        return None;
341    }
342
343    // Extract local content (after first newline, before separator)
344    let after_start = content_str
345        .get(start_idx..)?
346        .find('\n')?
347        .checked_add(start_idx)?
348        .checked_add(1)?;
349    let local = content_str.get(after_start..separator_idx)?;
350
351    // Extract remote content (after separator newline, before end)
352    let after_sep = separator_idx.checked_add(8)?; // "=======\n"
353    let remote = content_str.get(after_sep..end_idx)?;
354
355    Some((
356        local.trim_end().as_bytes().to_vec(),
357        remote.trim_end().as_bytes().to_vec(),
358    ))
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_conflict_markers() {
367        let local = b"local version";
368        let remote = b"remote version";
369
370        let merged = create_conflict_markers(local, remote, "LOCAL", "REMOTE");
371
372        assert!(has_conflict_markers(&merged));
373
374        let (parsed_local, parsed_remote) =
375            parse_conflict_markers(&merged).unwrap_or_else(|| std::process::abort());
376        assert_eq!(parsed_local, local.to_vec());
377        assert_eq!(parsed_remote, remote.to_vec());
378    }
379
380    #[test]
381    fn test_no_conflict_markers() {
382        let content = b"normal content without markers";
383        assert!(!has_conflict_markers(content));
384    }
385
386    #[test]
387    fn test_merge_strategy_display() {
388        assert_eq!(MergeStrategy::KeepLocal.to_string(), "Keep local");
389        assert_eq!(MergeStrategy::Manual.to_string(), "Manual merge required");
390    }
391
392    #[test]
393    fn test_conflict_type_display() {
394        let conflict = ConflictType::Divergent {
395            common_ancestor: 5,
396            local_version: 7,
397            remote_version: 8,
398        };
399        let display = conflict.to_string();
400        assert!(display.contains("Divergent"));
401        assert!(display.contains('5'));
402    }
403}