Skip to main content

syncor_core/sync/
conflict.rs

1use std::collections::{HashMap, HashSet};
2
3/// A map from file path to its MD5/blake3/etc. hash (16 bytes).
4pub type ManifestMap = HashMap<String, [u8; 16]>;
5
6/// A conflict between base, local, and remote versions of a file.
7#[derive(Debug, Clone, PartialEq)]
8pub struct Conflict {
9    pub path: String,
10    pub base_hash: Option<[u8; 16]>,
11    pub local_hash: Option<[u8; 16]>,
12    pub remote_hash: Option<[u8; 16]>,
13}
14
15/// How a conflict was resolved.
16#[derive(Debug, Clone, PartialEq)]
17pub enum Resolution {
18    KeepLocal,
19    KeepRemote,
20    Merged(Vec<u8>),
21    Skip,
22}
23
24/// An action to take for a given file after three-point comparison.
25#[derive(Debug, Clone, PartialEq)]
26pub enum FileAction {
27    /// Pull the remote version of this file locally.
28    ApplyRemote { path: String, remote_hash: [u8; 16] },
29    /// Delete the local copy (remote deleted it, local was unchanged).
30    DeleteLocal { path: String },
31    /// Both sides changed in incompatible ways; needs manual resolution.
32    Conflict(Conflict),
33}
34
35/// Trait for resolving conflicts programmatically.
36pub trait ConflictResolver {
37    fn resolve(&self, conflict: &Conflict) -> Resolution;
38}
39
40/// Always keeps the local version when a conflict is detected.
41pub struct KeepLocalResolver;
42
43impl ConflictResolver for KeepLocalResolver {
44    fn resolve(&self, _conflict: &Conflict) -> Resolution {
45        Resolution::KeepLocal
46    }
47}
48
49/// Three-point merge: compare base, local, and remote manifests and return
50/// the list of actions that need to be taken.
51///
52/// Match table (base, local, remote):
53/// (A, A, A) → no-op
54/// (A, A, B) → ApplyRemote
55/// (A, B, A) → no-op (keep local)
56/// (A, B, B) → no-op (both same non-base change)
57/// (A, B, C) → Conflict
58/// (-, B, -) → no-op (local added)
59/// (-, -, B) → ApplyRemote
60/// (-, B, C) → Conflict (both added differently)
61/// (A, -, A) → no-op (local deleted, remote unchanged)
62/// (A, A, -) → DeleteLocal (remote deleted, local unchanged)
63/// (A, -, -) → no-op (both deleted)
64/// (A, B, -) → Conflict (local changed, remote deleted)
65/// (A, -, B) → Conflict (local deleted, remote changed)
66pub fn detect_conflicts(
67    base: &ManifestMap,
68    local: &ManifestMap,
69    remote: &ManifestMap,
70) -> Vec<FileAction> {
71    let mut actions = Vec::new();
72
73    // Collect all paths across all three manifests.
74    let all_paths: HashSet<&String> = base
75        .keys()
76        .chain(local.keys())
77        .chain(remote.keys())
78        .collect();
79
80    for path in all_paths {
81        let b = base.get(path);
82        let l = local.get(path);
83        let r = remote.get(path);
84
85        let action = match (b, l, r) {
86            // (A, A, A) — no change
87            (Some(bh), Some(lh), Some(rh)) if bh == lh && lh == rh => None,
88
89            // (A, A, B) — remote changed, local untouched → apply remote
90            (Some(bh), Some(lh), Some(rh)) if bh == lh && lh != rh => {
91                Some(FileAction::ApplyRemote {
92                    path: path.clone(),
93                    remote_hash: *rh,
94                })
95            }
96
97            // (A, B, A) — local changed, remote untouched → keep local (no-op)
98            (Some(bh), Some(_lh), Some(rh)) if bh == rh => None,
99
100            // (A, B, B) — both changed to same value → no-op
101            (Some(_bh), Some(lh), Some(rh)) if lh == rh => None,
102
103            // (A, B, C) — all three differ → conflict
104            (Some(bh), Some(lh), Some(rh)) => Some(FileAction::Conflict(Conflict {
105                path: path.clone(),
106                base_hash: Some(*bh),
107                local_hash: Some(*lh),
108                remote_hash: Some(*rh),
109            })),
110
111            // (-, B, -) — local added, remote doesn't have it → no-op
112            (None, Some(_lh), None) => None,
113
114            // (-, -, B) — remote added, local doesn't have it → apply remote
115            (None, None, Some(rh)) => Some(FileAction::ApplyRemote {
116                path: path.clone(),
117                remote_hash: *rh,
118            }),
119
120            // (-, B, C) — both added with different content → conflict
121            (None, Some(lh), Some(rh)) if lh != rh => Some(FileAction::Conflict(Conflict {
122                path: path.clone(),
123                base_hash: None,
124                local_hash: Some(*lh),
125                remote_hash: Some(*rh),
126            })),
127
128            // (-, B, B) — both added with same content → no-op
129            (None, Some(_lh), Some(_rh)) => None,
130
131            // (A, -, A) — local deleted, remote unchanged → keep deletion (no-op)
132            (Some(bh), None, Some(rh)) if bh == rh => None,
133
134            // (A, -, B) — local deleted, remote changed → conflict
135            (Some(bh), None, Some(rh)) => Some(FileAction::Conflict(Conflict {
136                path: path.clone(),
137                base_hash: Some(*bh),
138                local_hash: None,
139                remote_hash: Some(*rh),
140            })),
141
142            // (A, A, -) — remote deleted, local unchanged → delete local
143            (Some(bh), Some(lh), None) if bh == lh => {
144                Some(FileAction::DeleteLocal { path: path.clone() })
145            }
146
147            // (A, B, -) — local changed, remote deleted → conflict
148            (Some(bh), Some(lh), None) => Some(FileAction::Conflict(Conflict {
149                path: path.clone(),
150                base_hash: Some(*bh),
151                local_hash: Some(*lh),
152                remote_hash: None,
153            })),
154
155            // (A, -, -) — both deleted → no-op
156            (Some(_bh), None, None) => None,
157
158            // (-, -, -) — shouldn't happen, but handle gracefully
159            (None, None, None) => None,
160        };
161
162        if let Some(a) = action {
163            actions.push(a);
164        }
165    }
166
167    actions
168}