1use std::collections::{BTreeMap, BTreeSet};
17
18pub type RelPath = String;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Remote {
24 pub id: String,
25 pub directory: bool,
26 pub blob: Option<String>,
28 pub size: u64,
29 pub vault: Option<String>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct Local {
36 pub directory: bool,
37 pub size: u64,
38 pub modified: i128,
40 pub file: u64,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Base {
47 pub remote: Remote,
48 pub local: Local,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Action {
53 MakeLocalDirectory(RelPath),
55 MakeRemoteDirectory(RelPath),
57 Download { path: RelPath, id: String, blob: String },
59 Upload { path: RelPath, id: Option<String> },
61 DeleteLocal(RelPath),
63 DeleteRemote { path: RelPath, id: String },
65 KeepLocalCopy(RelPath),
67 Adopt(RelPath),
69 MoveLocal { from: RelPath, to: RelPath },
71 MoveRemote { from: RelPath, to: RelPath, id: String },
73}
74
75pub fn plan(
78 base: &BTreeMap<RelPath, Base>,
79 remote: &BTreeMap<RelPath, Remote>,
80 local: &BTreeMap<RelPath, Local>,
81) -> Vec<Action> {
82 let (mut base, mut remote, mut local) = (base.clone(), remote.clone(), local.clone());
83 let mut actions = moved_on_server(&mut base, &remote, &mut local);
84 actions.extend(moved_here(&mut base, &mut remote, &local));
85 actions.extend(compare(&base, &remote, &local));
86 order(actions)
87}
88
89fn moved_on_server(
92 base: &mut BTreeMap<RelPath, Base>,
93 remote: &BTreeMap<RelPath, Remote>,
94 local: &mut BTreeMap<RelPath, Local>,
95) -> Vec<Action> {
96 let by_id: BTreeMap<&str, &RelPath> = remote.iter().map(|(path, node)| (node.id.as_str(), path)).collect();
97 let mut actions = Vec::new();
98 let known: Vec<RelPath> = base.keys().cloned().collect();
100 for from in known {
101 let Some(b) = base.get(&from) else { continue };
102 if remote.get(&from).is_some_and(|r| r.id == b.remote.id) {
103 continue;
104 }
105 let Some(to) = by_id.get(b.remote.id.as_str()).map(|to| (*to).clone()) else { continue };
106 let here = local.get(&from);
107 let unchanged =
108 here.is_some_and(|l| l.directory == b.local.directory && (l.directory || same_file(l, &b.local)));
109 if !unchanged || local.contains_key(&to) || base.contains_key(&to) || under(&to, &from) {
110 continue;
111 }
112 rename(base, &from, &to);
113 rename(local, &from, &to);
114 actions.push(Action::MoveLocal { from, to });
115 }
116 actions
117}
118
119fn moved_here(
122 base: &mut BTreeMap<RelPath, Base>,
123 remote: &mut BTreeMap<RelPath, Remote>,
124 local: &BTreeMap<RelPath, Local>,
125) -> Vec<Action> {
126 let mut by_file: BTreeMap<u64, Option<&RelPath>> = BTreeMap::new();
128 for (path, l) in local.iter().filter(|(_, l)| l.file != 0) {
129 by_file.entry(l.file).and_modify(|seen| *seen = None).or_insert(Some(path));
130 }
131 let mut actions = Vec::new();
132 let known: Vec<RelPath> = base.keys().cloned().collect();
133 for from in known {
134 let Some(b) = base.get(&from).cloned() else { continue };
135 if b.local.file == 0 || local.get(&from).is_some_and(|l| l.file == b.local.file) {
136 continue;
137 }
138 let Some(Some(to)) = by_file.get(&b.local.file).map(|to| to.cloned()) else { continue };
139 let l = &local[&to];
140 if l.directory != b.local.directory || (!l.directory && !same_file(l, &b.local)) {
141 continue;
142 }
143 let Some(r) = remote.get(&from).filter(|r| r.id == b.remote.id).cloned() else { continue };
144 if base.contains_key(&to) || remote.contains_key(&to) || under(&to, &from) {
145 continue;
146 }
147 let target = vault_at(remote, parent(&to));
149 let allowed = if r.vault.as_deref() == Some(r.id.as_str()) { target.is_none() } else { r.vault == target };
150 if !allowed {
151 continue;
152 }
153 rename(base, &from, &to);
154 rename(remote, &from, &to);
155 actions.push(Action::MoveRemote { from, to, id: r.id });
156 }
157 actions
158}
159
160fn same_file(a: &Local, b: &Local) -> bool {
162 a.size == b.size && a.modified == b.modified
163}
164
165fn under(path: &str, root: &str) -> bool {
166 path.strip_prefix(root).is_some_and(|rest| rest.starts_with('/'))
167}
168
169pub fn parent(path: &str) -> &str {
171 path.rsplit_once('/').map_or("", |(parent, _)| parent)
172}
173
174pub fn vault_at(remote: &BTreeMap<RelPath, Remote>, mut path: &str) -> Option<String> {
176 while !path.is_empty() {
177 if let Some(node) = remote.get(path) {
178 return node.vault.clone();
179 }
180 path = parent(path);
181 }
182 None
183}
184
185pub fn rename<T>(map: &mut BTreeMap<RelPath, T>, from: &str, to: &str) {
187 let moved: Vec<RelPath> = map.keys().filter(|path| *path == from || under(path, from)).cloned().collect();
188 for path in moved {
189 if let Some(value) = map.remove(&path) {
190 map.insert(format!("{to}{}", &path[from.len()..]), value);
191 }
192 }
193}
194
195fn compare(
197 base: &BTreeMap<RelPath, Base>,
198 remote: &BTreeMap<RelPath, Remote>,
199 local: &BTreeMap<RelPath, Local>,
200) -> Vec<Action> {
201 let paths: BTreeSet<&RelPath> = base.keys().chain(remote.keys()).chain(local.keys()).collect();
202 let mut actions = Vec::new();
203 let mut gone_here: Vec<&str> = Vec::new();
205 let mut gone_there: Vec<&str> = Vec::new();
206 let under = |path: &str, roots: &[&str]| roots.iter().any(|root| path.starts_with(&format!("{root}/")));
207 for path in &paths {
208 let (b, r, l) = (base.get(*path), remote.get(*path), local.get(*path));
209 let remote_changed = match (b, r) {
210 (Some(b), Some(r)) => b.remote.id != r.id || b.remote.blob != r.blob || b.remote.directory != r.directory,
211 (None, Some(_)) => true,
212 _ => false,
213 };
214 let local_changed = match (b, l) {
215 (Some(b), Some(l)) => {
216 b.local.directory != l.directory
217 || (!l.directory && (b.local.size != l.size || b.local.modified != l.modified))
218 }
219 (None, Some(_)) => true,
220 _ => false,
221 };
222 if under(path, &gone_here) || under(path, &gone_there) {
223 continue;
224 }
225 match (b, r, l) {
226 (_, None, None) => {}
227 (None, Some(r), None) => actions.push(fetch(path, r)),
229 (None, None, Some(l)) => actions.push(send(path, l, None)),
230 (None, Some(r), Some(l)) => {
232 if r.directory && l.directory {
233 actions.push(Action::Adopt((*path).clone()));
234 } else {
235 actions.push(Action::KeepLocalCopy((*path).clone()));
237 actions.push(fetch(path, r));
238 }
239 }
240 (Some(_), Some(r), Some(l)) => match (remote_changed, local_changed) {
242 (false, false) => {}
243 (true, false) => {
244 if l.directory != r.directory {
246 actions.push(Action::DeleteLocal((*path).clone()));
247 }
248 actions.push(fetch(path, r));
249 }
250 (false, true) => {
251 if r.directory && !l.directory {
252 actions.push(Action::KeepLocalCopy((*path).clone()));
254 actions.push(fetch(path, r));
255 } else if !l.directory {
256 actions.push(send(path, l, Some(r.id.clone())));
257 }
258 }
259 (true, true) => {
260 if r.directory && l.directory {
261 actions.push(Action::Adopt((*path).clone()));
262 } else {
263 actions.push(Action::KeepLocalCopy((*path).clone()));
264 actions.push(fetch(path, r));
265 }
266 }
267 },
268 (Some(b), None, Some(l)) => {
270 if local_changed || (l.directory && changed_below(path, base, local)) {
271 actions.push(send(path, l, None));
272 } else {
273 actions.push(Action::DeleteLocal((*path).clone()));
274 if b.local.directory {
275 gone_here.push(path);
276 }
277 }
278 }
279 (Some(b), Some(r), None) => {
281 if remote_changed || (r.directory && changed_below(path, base, remote)) {
282 actions.push(fetch(path, r));
283 } else {
284 actions.push(Action::DeleteRemote { path: (*path).clone(), id: r.id.clone() });
285 if b.remote.directory {
286 gone_there.push(path);
287 }
288 }
289 }
290 }
291 }
292 actions
293}
294
295fn fetch(path: &RelPath, remote: &Remote) -> Action {
296 if remote.directory {
297 Action::MakeLocalDirectory(path.clone())
298 } else {
299 Action::Download { path: path.clone(), id: remote.id.clone(), blob: remote.blob.clone().unwrap_or_default() }
300 }
301}
302
303fn send(path: &RelPath, local: &Local, id: Option<String>) -> Action {
304 if local.directory { Action::MakeRemoteDirectory(path.clone()) } else { Action::Upload { path: path.clone(), id } }
305}
306
307fn changed_below<T: Changed>(directory: &str, base: &BTreeMap<RelPath, Base>, now: &BTreeMap<RelPath, T>) -> bool {
309 let prefix = format!("{directory}/");
310 now.iter().filter(|(path, _)| path.starts_with(&prefix)).any(|(path, node)| match base.get(path) {
311 Some(base) => node.differs(base),
312 None => true,
313 })
314}
315
316trait Changed {
317 fn differs(&self, base: &Base) -> bool;
318}
319
320impl Changed for Local {
321 fn differs(&self, base: &Base) -> bool {
322 base.local.directory != self.directory
323 || (!self.directory && (base.local.size != self.size || base.local.modified != self.modified))
324 }
325}
326
327impl Changed for Remote {
328 fn differs(&self, base: &Base) -> bool {
329 base.remote.id != self.id || base.remote.blob != self.blob
330 }
331}
332
333fn depth(path: &str) -> usize {
334 path.matches('/').count()
335}
336
337fn order(actions: Vec<Action>) -> Vec<Action> {
338 let rank = |action: &Action| -> (u8, isize) {
339 match action {
340 Action::MoveLocal { to, .. } => (0, depth(to) as isize),
341 Action::KeepLocalCopy(path) => (0, depth(path) as isize),
342 Action::DeleteLocal(path) => (1, -(depth(path) as isize)),
343 Action::MakeLocalDirectory(path) => (2, depth(path) as isize),
344 Action::Download { path, .. } => (3, depth(path) as isize),
345 Action::MakeRemoteDirectory(path) => (4, depth(path) as isize),
346 Action::MoveRemote { to, .. } => (5, depth(to) as isize),
347 Action::Upload { path, .. } => (5, depth(path) as isize),
348 Action::DeleteRemote { path, .. } => (6, -(depth(path) as isize)),
349 Action::Adopt(path) => (7, depth(path) as isize),
350 }
351 };
352 let mut actions = actions;
353 actions.sort_by_key(rank);
354 actions
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn file(id: &str, blob: &str) -> Remote {
362 Remote { id: id.into(), directory: false, blob: Some(blob.into()), size: 1, vault: None }
363 }
364 fn dir(id: &str) -> Remote {
365 Remote { id: id.into(), directory: true, blob: None, size: 0, vault: None }
366 }
367 fn here(size: u64, modified: i128) -> Local {
368 Local { directory: false, size, modified, file: 0 }
369 }
370 fn here_dir() -> Local {
371 Local { directory: true, size: 0, modified: 0, file: 0 }
372 }
373 fn with_file(mut local: Local, file: u64) -> Local {
374 local.file = file;
375 local
376 }
377 fn in_vault(mut remote: Remote, vault: &str) -> Remote {
378 remote.vault = Some(vault.into());
379 remote
380 }
381 fn map<T: Clone>(entries: &[(&str, T)]) -> BTreeMap<RelPath, T> {
382 entries.iter().map(|(path, value)| ((*path).to_owned(), value.clone())).collect()
383 }
384 fn agreed(entries: &[(&str, Remote, Local)]) -> BTreeMap<RelPath, Base> {
385 entries.iter().map(|(p, r, l)| ((*p).to_owned(), Base { remote: r.clone(), local: *l })).collect()
386 }
387
388 #[test]
389 fn nothing_changed_nothing_to_do() {
390 let base = agreed(&[("a.txt", file("n1", "b1"), here(1, 10)), ("D", dir("n2"), here_dir())]);
391 let remote = map(&[("a.txt", file("n1", "b1")), ("D", dir("n2"))]);
392 let local = map(&[("a.txt", here(1, 10)), ("D", here_dir())]);
393 assert!(plan(&base, &remote, &local).is_empty());
394 }
395
396 #[test]
397 fn the_first_round_brings_both_sides_together() {
398 let remote = map(&[("D", dir("n1")), ("D/x.txt", file("n2", "b2")), ("same", dir("n3"))]);
399 let local = map(&[("E", here_dir()), ("E/y.txt", here(3, 1)), ("same", here_dir())]);
400 assert_eq!(
401 plan(&BTreeMap::new(), &remote, &local),
402 [
403 Action::MakeLocalDirectory("D".into()),
404 Action::Download { path: "D/x.txt".into(), id: "n2".into(), blob: "b2".into() },
405 Action::MakeRemoteDirectory("E".into()),
406 Action::Upload { path: "E/y.txt".into(), id: None },
407 Action::Adopt("same".into()),
408 ]
409 );
410 }
411
412 #[test]
413 fn a_change_goes_to_the_other_side() {
414 let base = agreed(&[("a", file("n1", "b1"), here(1, 10)), ("b", file("n2", "b2"), here(1, 10))]);
415 let remote = map(&[("a", file("n1", "b9")), ("b", file("n2", "b2"))]);
416 let local = map(&[("a", here(1, 10)), ("b", here(2, 11))]);
417 assert_eq!(
418 plan(&base, &remote, &local),
419 [
420 Action::Download { path: "a".into(), id: "n1".into(), blob: "b9".into() },
421 Action::Upload { path: "b".into(), id: Some("n2".into()) },
422 ]
423 );
424 }
425
426 #[test]
427 fn a_change_on_both_sides_keeps_both() {
428 let base = agreed(&[("a", file("n1", "b1"), here(1, 10))]);
429 let remote = map(&[("a", file("n1", "b2"))]);
430 let local = map(&[("a", here(5, 20))]);
431 assert_eq!(
432 plan(&base, &remote, &local),
433 [
434 Action::KeepLocalCopy("a".into()),
435 Action::Download { path: "a".into(), id: "n1".into(), blob: "b2".into() },
436 ]
437 );
438 let remote = map(&[("n", file("n5", "b5"))]);
440 let local = map(&[("n", here(1, 1))]);
441 assert_eq!(plan(&BTreeMap::new(), &remote, &local)[0], Action::KeepLocalCopy("n".into()));
442 }
443
444 #[test]
445 fn deletions_carry_over_but_never_take_a_change_along() {
446 let base = agreed(&[
447 ("gone-there", file("n1", "b1"), here(1, 10)),
448 ("gone-here", file("n2", "b2"), here(1, 10)),
449 ("gone-there-changed-here", file("n3", "b3"), here(1, 10)),
450 ("gone-here-changed-there", file("n4", "b4"), here(1, 10)),
451 ]);
452 let remote = map(&[("gone-here", file("n2", "b2")), ("gone-here-changed-there", file("n4", "b5"))]);
453 let local = map(&[("gone-there", here(1, 10)), ("gone-there-changed-here", here(7, 11))]);
454 assert_eq!(
455 plan(&base, &remote, &local),
456 [
457 Action::DeleteLocal("gone-there".into()),
458 Action::Download { path: "gone-here-changed-there".into(), id: "n4".into(), blob: "b5".into() },
459 Action::Upload { path: "gone-there-changed-here".into(), id: None },
460 Action::DeleteRemote { path: "gone-here".into(), id: "n2".into() },
461 ]
462 );
463 }
464
465 #[test]
466 fn a_deleted_directory_goes_whole_unless_something_in_it_changed() {
467 let base = agreed(&[
468 ("D", dir("n1"), here_dir()),
469 ("D/a", file("n2", "b2"), here(1, 10)),
470 ("E", dir("n3"), here_dir()),
471 ("E/b", file("n4", "b4"), here(1, 10)),
472 ]);
473 let remote = BTreeMap::new();
476 let local = map(&[("D", here_dir()), ("D/a", here(1, 10)), ("E", here_dir()), ("E/b", here(2, 12))]);
477 assert_eq!(
478 plan(&base, &remote, &local),
479 [
480 Action::DeleteLocal("D".into()),
481 Action::MakeRemoteDirectory("E".into()),
482 Action::Upload { path: "E/b".into(), id: None },
483 ]
484 );
485 let remote = map(&[("D", dir("n1")), ("D/a", file("n2", "b2")), ("E", dir("n3")), ("E/b", file("n4", "b9"))]);
487 assert_eq!(
488 plan(&base, &remote, &BTreeMap::new()),
489 [
490 Action::MakeLocalDirectory("E".into()),
491 Action::Download { path: "E/b".into(), id: "n4".into(), blob: "b9".into() },
492 Action::DeleteRemote { path: "D".into(), id: "n1".into() },
493 ]
494 );
495 }
496
497 #[test]
498 fn deep_directories_are_made_after_their_parents_and_deleted_before() {
499 let remote = map(&[("A/B/C", dir("c")), ("A", dir("a")), ("A/B", dir("b"))]);
500 let made = plan(&BTreeMap::new(), &remote, &BTreeMap::new());
501 assert_eq!(
502 made,
503 [
504 Action::MakeLocalDirectory("A".into()),
505 Action::MakeLocalDirectory("A/B".into()),
506 Action::MakeLocalDirectory("A/B/C".into()),
507 ]
508 );
509 }
510
511 #[test]
512 fn a_move_on_the_server_is_a_move_here() {
513 let base = agreed(&[
514 ("D", dir("d"), here_dir()),
515 ("D/a", file("n1", "b1"), here(1, 10)),
516 ("b", file("n2", "b2"), here(1, 10)),
517 ]);
518 let remote = map(&[("E", dir("d")), ("E/a", file("n1", "b1")), ("E/b", file("n2", "b3"))]);
520 let local = map(&[("D", here_dir()), ("D/a", here(1, 10)), ("b", here(1, 10))]);
521 assert_eq!(
522 plan(&base, &remote, &local),
523 [
524 Action::MoveLocal { from: "D".into(), to: "E".into() },
525 Action::MoveLocal { from: "b".into(), to: "E/b".into() },
526 Action::Download { path: "E/b".into(), id: "n2".into(), blob: "b3".into() },
527 ]
528 );
529 let local = map(&[("D", here_dir()), ("D/a", here(1, 10)), ("b", here(2, 11))]);
531 let actions = plan(&base, &remote, &local);
532 assert!(!actions.contains(&Action::MoveLocal { from: "b".into(), to: "E/b".into() }), "{actions:?}");
533 assert!(actions.contains(&Action::Upload { path: "b".into(), id: None }), "{actions:?}");
534 }
535
536 #[test]
537 fn a_move_here_is_a_move_on_the_server() {
538 let base = agreed(&[
539 ("D", dir("d"), with_file(here_dir(), 7)),
540 ("D/a", file("n1", "b1"), with_file(here(1, 10), 8)),
541 ("b", file("n2", "b2"), with_file(here(1, 10), 9)),
542 ]);
543 let remote = map(&[("D", dir("d")), ("D/a", file("n1", "b1")), ("b", file("n2", "b2"))]);
544 let local = map(&[
545 ("E", with_file(here_dir(), 7)),
546 ("E/a", with_file(here(1, 10), 8)),
547 ("New", with_file(here_dir(), 10)),
548 ("New/b", with_file(here(1, 10), 9)),
549 ]);
550 assert_eq!(
551 plan(&base, &remote, &local),
552 [
553 Action::MakeRemoteDirectory("New".into()),
554 Action::MoveRemote { from: "D".into(), to: "E".into(), id: "d".into() },
555 Action::MoveRemote { from: "b".into(), to: "New/b".into(), id: "n2".into() },
556 ]
557 );
558 }
559
560 #[test]
561 fn nothing_moves_into_or_out_of_a_vault() {
562 let base = agreed(&[
563 ("V", in_vault(dir("v"), "v"), with_file(here_dir(), 1)),
564 ("V/a", in_vault(file("n1", "b1"), "v"), with_file(here(1, 10), 2)),
565 ("b", file("n2", "b2"), with_file(here(1, 10), 3)),
566 ]);
567 let remote =
568 map(&[("V", in_vault(dir("v"), "v")), ("V/a", in_vault(file("n1", "b1"), "v")), ("b", file("n2", "b2"))]);
569 let local = map(&[
571 ("V", with_file(here_dir(), 1)),
572 ("a", with_file(here(1, 10), 2)),
573 ("V/b", with_file(here(1, 10), 3)),
574 ]);
575 let actions = plan(&base, &remote, &local);
576 assert!(!actions.iter().any(|a| matches!(a, Action::MoveRemote { .. })), "{actions:?}");
577 assert!(actions.contains(&Action::Upload { path: "V/b".into(), id: None }), "{actions:?}");
578 assert!(actions.contains(&Action::DeleteRemote { path: "V/a".into(), id: "n1".into() }), "{actions:?}");
579 let local = map(&[
581 ("W", with_file(here_dir(), 1)),
582 ("W/a", with_file(here(1, 10), 2)),
583 ("b", with_file(here(1, 10), 3)),
584 ]);
585 assert_eq!(
586 plan(&base, &remote, &local),
587 [Action::MoveRemote { from: "V".into(), to: "W".into(), id: "v".into() }]
588 );
589 }
590}