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}