Skip to main content

kernoid_sync/
plan.rs

1// SPDX-License-Identifier: MIT
2//! What to do to bring a folder and a store into step – decided from three views of every path: as both sides were
3//! when they last agreed (the base), as the store is now, as the folder is now. Pure: no I/O, so every case is tested.
4//!
5//! A change on one side is carried to the other. A change on both sides is a conflict: the server's version wins the
6//! name, the local one is kept beside it as a copy – never merged, never lost (the copy is uploaded in the next round).
7//! A deletion never takes a change of the other side with it: a file deleted here but changed on the server comes
8//! back, a file deleted on the server but changed here is uploaded again.
9//!
10//! A move is carried over as a move – what was moved keeps its node on the server (its versions, its shares) and is not
11//! sent again. On the server a move is told by the node's id at another path; here by the file's id on the file system
12//! (its inode) at another path, the size and modification time unchanged. A move that cannot be one – onto a path taken
13//! on the other side, into or out of a vault (RFC 0006, 6), a file system without file ids (**OPEN**: Windows) – is
14//! a deletion and a creation.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18/// A path relative to the folder, `/`-separated, without a leading `/` (`Projects/plan.txt`).
19pub type RelPath = String;
20
21/// A node of the store at a path.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Remote {
24    pub id: String,
25    pub directory: bool,
26    /// The content's blob; `None` for a directory.
27    pub blob: Option<String>,
28    pub size: u64,
29    /// The vault the node lies in – its own id on a vault's root.
30    pub vault: Option<String>,
31}
32
33/// A file or directory of the folder at a path.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct Local {
36    pub directory: bool,
37    pub size: u64,
38    /// Modification time, nanoseconds since 1970 – with the size, what tells a change without reading the file.
39    pub modified: i128,
40    /// The file's id on its file system (the inode); 0 where there is none to read.
41    pub file: u64,
42}
43
44/// A path as both sides were when they last agreed.
45#[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    /// Make the directory here.
54    MakeLocalDirectory(RelPath),
55    /// Make the directory in the store (its parent first).
56    MakeRemoteDirectory(RelPath),
57    /// Download the node's content to the path (replacing what is there).
58    Download { path: RelPath, id: String, blob: String },
59    /// Upload the file as a new node (`id: None`) or as new content of a node.
60    Upload { path: RelPath, id: Option<String> },
61    /// Delete here (a directory with what is in it).
62    DeleteLocal(RelPath),
63    /// Move the node to the store's trash.
64    DeleteRemote { path: RelPath, id: String },
65    /// Rename the local file to a conflict copy beside it, before the server's version is downloaded.
66    KeepLocalCopy(RelPath),
67    /// Both sides agree already (both made the same directory): remember it.
68    Adopt(RelPath),
69    /// Moved on the server: move it here too (with what is in it).
70    MoveLocal { from: RelPath, to: RelPath },
71    /// Moved here: move the node on the server too.
72    MoveRemote { from: RelPath, to: RelPath, id: String },
73}
74
75/// The actions in the order they must run: moves here first, local directories and copies, then downloads; remote
76/// directories shallow before deep, moves on the server, then uploads; deletions deep before shallow.
77pub 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
89/// Paths whose node is at another path on the server now, unchanged here and with nothing in the way: moved here
90/// too. `base` and `local` are changed as if done, so that the rest is compared at the new paths.
91fn 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    // Shallow first: a directory moved takes what is in it along, which then needs nothing of its own.
99    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
119/// Paths whose file is at another path here now (the same file id, size and time), unchanged on the server and with
120/// nothing in the way: moved on the server too. `base` and `remote` are changed as if done.
121fn moved_here(
122    base: &mut BTreeMap<RelPath, Base>,
123    remote: &mut BTreeMap<RelPath, Remote>,
124    local: &BTreeMap<RelPath, Local>,
125) -> Vec<Action> {
126    // Ids seen twice (hard links) tell nothing.
127    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        // A vault's root may go where there is no vault; anything else stays in its vault.
148        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
160/// The same file content as far as the folder tells: size and modification time.
161fn 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
169/// The parent of a path; `""` at the top.
170pub fn parent(path: &str) -> &str {
171    path.rsplit_once('/').map_or("", |(parent, _)| parent)
172}
173
174/// The vault a path of the store lies in: that of its nearest ancestor known.
175pub 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
185/// Moves a path and what is below it to another path in a map.
186pub 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
195/// What to do at each path, moves already applied.
196fn 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    // Deleted directories take what is below them along; their contents need no actions of their own.
204    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            // New on one side only.
228            (None, Some(r), None) => actions.push(fetch(path, r)),
229            (None, None, Some(l)) => actions.push(send(path, l, None)),
230            // New on both sides.
231            (None, Some(r), Some(l)) => {
232                if r.directory && l.directory {
233                    actions.push(Action::Adopt((*path).clone()));
234                } else {
235                    // Two files, or a file and a directory, of one name: the server's keeps the name.
236                    actions.push(Action::KeepLocalCopy((*path).clone()));
237                    actions.push(fetch(path, r));
238                }
239            }
240            // Known to both before.
241            (Some(_), Some(r), Some(l)) => match (remote_changed, local_changed) {
242                (false, false) => {}
243                (true, false) => {
244                    // A file replaced by a directory on the server, or the other way round.
245                    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                        // A directory replaced by a file here.
253                        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            // Gone on the server.
269            (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            // Gone here.
280            (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
307/// Whether anything below a directory changed on the side `now` describes, against the base.
308fn 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        // New on both sides, never seen before: the same.
439        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        // D gone on the server, unchanged here: deleted here, with what is in it. E gone on the server, but E/b
474        // changed here: E and E/b go up again.
475        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        // The same the other way round.
486        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        // D renamed to E (with D/a), b moved into E and changed there.
519        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        // Changed here too: no move – the server's version comes, the one here goes to the server as new.
530        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        // a out of the vault, b into it: each a deletion and a creation.
570        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        // The vault itself may be renamed.
580        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}