1use std::collections::{BTreeMap, BTreeSet};
12
13pub type RelPath = String;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Remote {
19 pub id: String,
20 pub directory: bool,
21 pub blob: Option<String>,
23 pub size: u64,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Local {
29 pub directory: bool,
30 pub size: u64,
31 pub modified: i128,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Base {
38 pub remote: Remote,
39 pub local: Local,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Action {
44 MakeLocalDirectory(RelPath),
46 MakeRemoteDirectory(RelPath),
48 Download { path: RelPath, id: String, blob: String },
50 Upload { path: RelPath, id: Option<String> },
52 DeleteLocal(RelPath),
54 DeleteRemote { path: RelPath, id: String },
56 KeepLocalCopy(RelPath),
58 Adopt(RelPath),
60}
61
62pub fn plan(
65 base: &BTreeMap<RelPath, Base>,
66 remote: &BTreeMap<RelPath, Remote>,
67 local: &BTreeMap<RelPath, Local>,
68) -> Vec<Action> {
69 let paths: BTreeSet<&RelPath> = base.keys().chain(remote.keys()).chain(local.keys()).collect();
70 let mut actions = Vec::new();
71 let mut gone_here: Vec<&str> = Vec::new();
73 let mut gone_there: Vec<&str> = Vec::new();
74 let under = |path: &str, roots: &[&str]| roots.iter().any(|root| path.starts_with(&format!("{root}/")));
75 for path in &paths {
76 let (b, r, l) = (base.get(*path), remote.get(*path), local.get(*path));
77 let remote_changed = match (b, r) {
78 (Some(b), Some(r)) => b.remote.id != r.id || b.remote.blob != r.blob || b.remote.directory != r.directory,
79 (None, Some(_)) => true,
80 _ => false,
81 };
82 let local_changed = match (b, l) {
83 (Some(b), Some(l)) => {
84 b.local.directory != l.directory
85 || (!l.directory && (b.local.size != l.size || b.local.modified != l.modified))
86 }
87 (None, Some(_)) => true,
88 _ => false,
89 };
90 if under(path, &gone_here) || under(path, &gone_there) {
91 continue;
92 }
93 match (b, r, l) {
94 (_, None, None) => {}
95 (None, Some(r), None) => actions.push(fetch(path, r)),
97 (None, None, Some(l)) => actions.push(send(path, l, None)),
98 (None, Some(r), Some(l)) => {
100 if r.directory && l.directory {
101 actions.push(Action::Adopt((*path).clone()));
102 } else {
103 actions.push(Action::KeepLocalCopy((*path).clone()));
105 actions.push(fetch(path, r));
106 }
107 }
108 (Some(_), Some(r), Some(l)) => match (remote_changed, local_changed) {
110 (false, false) => {}
111 (true, false) => {
112 if l.directory != r.directory {
114 actions.push(Action::DeleteLocal((*path).clone()));
115 }
116 actions.push(fetch(path, r));
117 }
118 (false, true) => {
119 if r.directory && !l.directory {
120 actions.push(Action::KeepLocalCopy((*path).clone()));
122 actions.push(fetch(path, r));
123 } else if !l.directory {
124 actions.push(send(path, l, Some(r.id.clone())));
125 }
126 }
127 (true, true) => {
128 if r.directory && l.directory {
129 actions.push(Action::Adopt((*path).clone()));
130 } else {
131 actions.push(Action::KeepLocalCopy((*path).clone()));
132 actions.push(fetch(path, r));
133 }
134 }
135 },
136 (Some(b), None, Some(l)) => {
138 if local_changed || (l.directory && changed_below(path, base, local)) {
139 actions.push(send(path, l, None));
140 } else {
141 actions.push(Action::DeleteLocal((*path).clone()));
142 if b.local.directory {
143 gone_here.push(path);
144 }
145 }
146 }
147 (Some(b), Some(r), None) => {
149 if remote_changed || (r.directory && changed_below(path, base, remote)) {
150 actions.push(fetch(path, r));
151 } else {
152 actions.push(Action::DeleteRemote { path: (*path).clone(), id: r.id.clone() });
153 if b.remote.directory {
154 gone_there.push(path);
155 }
156 }
157 }
158 }
159 }
160 order(actions)
161}
162
163fn fetch(path: &RelPath, remote: &Remote) -> Action {
164 if remote.directory {
165 Action::MakeLocalDirectory(path.clone())
166 } else {
167 Action::Download { path: path.clone(), id: remote.id.clone(), blob: remote.blob.clone().unwrap_or_default() }
168 }
169}
170
171fn send(path: &RelPath, local: &Local, id: Option<String>) -> Action {
172 if local.directory { Action::MakeRemoteDirectory(path.clone()) } else { Action::Upload { path: path.clone(), id } }
173}
174
175fn changed_below<T: Changed>(directory: &str, base: &BTreeMap<RelPath, Base>, now: &BTreeMap<RelPath, T>) -> bool {
177 let prefix = format!("{directory}/");
178 now.iter().filter(|(path, _)| path.starts_with(&prefix)).any(|(path, node)| match base.get(path) {
179 Some(base) => node.differs(base),
180 None => true,
181 })
182}
183
184trait Changed {
185 fn differs(&self, base: &Base) -> bool;
186}
187
188impl Changed for Local {
189 fn differs(&self, base: &Base) -> bool {
190 base.local.directory != self.directory
191 || (!self.directory && (base.local.size != self.size || base.local.modified != self.modified))
192 }
193}
194
195impl Changed for Remote {
196 fn differs(&self, base: &Base) -> bool {
197 base.remote.id != self.id || base.remote.blob != self.blob
198 }
199}
200
201fn depth(path: &str) -> usize {
202 path.matches('/').count()
203}
204
205fn order(actions: Vec<Action>) -> Vec<Action> {
206 let rank = |action: &Action| -> (u8, isize) {
207 match action {
208 Action::KeepLocalCopy(path) => (0, depth(path) as isize),
209 Action::DeleteLocal(path) => (1, -(depth(path) as isize)),
210 Action::MakeLocalDirectory(path) => (2, depth(path) as isize),
211 Action::Download { path, .. } => (3, depth(path) as isize),
212 Action::MakeRemoteDirectory(path) => (4, depth(path) as isize),
213 Action::Upload { path, .. } => (5, depth(path) as isize),
214 Action::DeleteRemote { path, .. } => (6, -(depth(path) as isize)),
215 Action::Adopt(path) => (7, depth(path) as isize),
216 }
217 };
218 let mut actions = actions;
219 actions.sort_by_key(rank);
220 actions
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn file(id: &str, blob: &str) -> Remote {
228 Remote { id: id.into(), directory: false, blob: Some(blob.into()), size: 1 }
229 }
230 fn dir(id: &str) -> Remote {
231 Remote { id: id.into(), directory: true, blob: None, size: 0 }
232 }
233 fn here(size: u64, modified: i128) -> Local {
234 Local { directory: false, size, modified }
235 }
236 fn here_dir() -> Local {
237 Local { directory: true, size: 0, modified: 0 }
238 }
239 fn map<T: Clone>(entries: &[(&str, T)]) -> BTreeMap<RelPath, T> {
240 entries.iter().map(|(path, value)| ((*path).to_owned(), value.clone())).collect()
241 }
242 fn agreed(entries: &[(&str, Remote, Local)]) -> BTreeMap<RelPath, Base> {
243 entries.iter().map(|(p, r, l)| ((*p).to_owned(), Base { remote: r.clone(), local: *l })).collect()
244 }
245
246 #[test]
247 fn nothing_changed_nothing_to_do() {
248 let base = agreed(&[("a.txt", file("n1", "b1"), here(1, 10)), ("D", dir("n2"), here_dir())]);
249 let remote = map(&[("a.txt", file("n1", "b1")), ("D", dir("n2"))]);
250 let local = map(&[("a.txt", here(1, 10)), ("D", here_dir())]);
251 assert!(plan(&base, &remote, &local).is_empty());
252 }
253
254 #[test]
255 fn the_first_round_brings_both_sides_together() {
256 let remote = map(&[("D", dir("n1")), ("D/x.txt", file("n2", "b2")), ("same", dir("n3"))]);
257 let local = map(&[("E", here_dir()), ("E/y.txt", here(3, 1)), ("same", here_dir())]);
258 assert_eq!(
259 plan(&BTreeMap::new(), &remote, &local),
260 [
261 Action::MakeLocalDirectory("D".into()),
262 Action::Download { path: "D/x.txt".into(), id: "n2".into(), blob: "b2".into() },
263 Action::MakeRemoteDirectory("E".into()),
264 Action::Upload { path: "E/y.txt".into(), id: None },
265 Action::Adopt("same".into()),
266 ]
267 );
268 }
269
270 #[test]
271 fn a_change_goes_to_the_other_side() {
272 let base = agreed(&[("a", file("n1", "b1"), here(1, 10)), ("b", file("n2", "b2"), here(1, 10))]);
273 let remote = map(&[("a", file("n1", "b9")), ("b", file("n2", "b2"))]);
274 let local = map(&[("a", here(1, 10)), ("b", here(2, 11))]);
275 assert_eq!(
276 plan(&base, &remote, &local),
277 [
278 Action::Download { path: "a".into(), id: "n1".into(), blob: "b9".into() },
279 Action::Upload { path: "b".into(), id: Some("n2".into()) },
280 ]
281 );
282 }
283
284 #[test]
285 fn a_change_on_both_sides_keeps_both() {
286 let base = agreed(&[("a", file("n1", "b1"), here(1, 10))]);
287 let remote = map(&[("a", file("n1", "b2"))]);
288 let local = map(&[("a", here(5, 20))]);
289 assert_eq!(
290 plan(&base, &remote, &local),
291 [
292 Action::KeepLocalCopy("a".into()),
293 Action::Download { path: "a".into(), id: "n1".into(), blob: "b2".into() },
294 ]
295 );
296 let remote = map(&[("n", file("n5", "b5"))]);
298 let local = map(&[("n", here(1, 1))]);
299 assert_eq!(plan(&BTreeMap::new(), &remote, &local)[0], Action::KeepLocalCopy("n".into()));
300 }
301
302 #[test]
303 fn deletions_carry_over_but_never_take_a_change_along() {
304 let base = agreed(&[
305 ("gone-there", file("n1", "b1"), here(1, 10)),
306 ("gone-here", file("n2", "b2"), here(1, 10)),
307 ("gone-there-changed-here", file("n3", "b3"), here(1, 10)),
308 ("gone-here-changed-there", file("n4", "b4"), here(1, 10)),
309 ]);
310 let remote = map(&[("gone-here", file("n2", "b2")), ("gone-here-changed-there", file("n4", "b5"))]);
311 let local = map(&[("gone-there", here(1, 10)), ("gone-there-changed-here", here(7, 11))]);
312 assert_eq!(
313 plan(&base, &remote, &local),
314 [
315 Action::DeleteLocal("gone-there".into()),
316 Action::Download { path: "gone-here-changed-there".into(), id: "n4".into(), blob: "b5".into() },
317 Action::Upload { path: "gone-there-changed-here".into(), id: None },
318 Action::DeleteRemote { path: "gone-here".into(), id: "n2".into() },
319 ]
320 );
321 }
322
323 #[test]
324 fn a_deleted_directory_goes_whole_unless_something_in_it_changed() {
325 let base = agreed(&[
326 ("D", dir("n1"), here_dir()),
327 ("D/a", file("n2", "b2"), here(1, 10)),
328 ("E", dir("n3"), here_dir()),
329 ("E/b", file("n4", "b4"), here(1, 10)),
330 ]);
331 let remote = BTreeMap::new();
334 let local = map(&[("D", here_dir()), ("D/a", here(1, 10)), ("E", here_dir()), ("E/b", here(2, 12))]);
335 assert_eq!(
336 plan(&base, &remote, &local),
337 [
338 Action::DeleteLocal("D".into()),
339 Action::MakeRemoteDirectory("E".into()),
340 Action::Upload { path: "E/b".into(), id: None },
341 ]
342 );
343 let remote = map(&[("D", dir("n1")), ("D/a", file("n2", "b2")), ("E", dir("n3")), ("E/b", file("n4", "b9"))]);
345 assert_eq!(
346 plan(&base, &remote, &BTreeMap::new()),
347 [
348 Action::MakeLocalDirectory("E".into()),
349 Action::Download { path: "E/b".into(), id: "n4".into(), blob: "b9".into() },
350 Action::DeleteRemote { path: "D".into(), id: "n1".into() },
351 ]
352 );
353 }
354
355 #[test]
356 fn deep_directories_are_made_after_their_parents_and_deleted_before() {
357 let remote = map(&[("A/B/C", dir("c")), ("A", dir("a")), ("A/B", dir("b"))]);
358 let made = plan(&BTreeMap::new(), &remote, &BTreeMap::new());
359 assert_eq!(
360 made,
361 [
362 Action::MakeLocalDirectory("A".into()),
363 Action::MakeLocalDirectory("A/B".into()),
364 Action::MakeLocalDirectory("A/B/C".into()),
365 ]
366 );
367 }
368}