Named libraries: switch between several, each syncing on its own

The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
This commit is contained in:
2026-10-16 21:44:55 -05:00
parent 6072d44ebe
commit 81b1df8c9f
21 changed files with 2451 additions and 202 deletions

View File

@@ -20,8 +20,11 @@ type Res<T> = Result<T, String>;
/// High-level status for the top bar / first-run gating.
#[derive(Serialize)]
pub struct Status {
/// Where the library syncs: the account and server, or this device only.
/// The open library's name and where it syncs: the account and server, or
/// this device only. Changes whenever another library is opened.
pub library_label: String,
/// The open library.
pub library: OpenLibrary,
pub reference_path: String,
pub reference_ready: bool,
pub card_count: usize,
@@ -39,18 +42,25 @@ pub struct Status {
pub is_mobile: bool,
}
/// Where the library syncs, for the top bar — never the internal working
/// database, which is an implementation detail the user never sees.
#[derive(Serialize)]
pub struct OpenLibrary {
pub id: String,
pub name: String,
}
/// The open library and where it syncs, for the top bar — never the internal
/// working database, which is an implementation detail the user never sees.
fn library_label(s: &AppState) -> String {
let lib = s.active_library();
match s.sync_account() {
Some(a) if a.token.is_some() => {
Some(a) if a.token.is_some() && lib.sync => {
let host = url::Url::parse(&a.server_url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| a.server_url.clone());
format!("{} @ {host}", a.user.username)
format!("{} · {} @ {host}", lib.name, a.user.username)
}
_ => "This device only (not syncing)".to_string(),
_ => format!("{} · this device only", lib.name),
}
}
@@ -60,8 +70,10 @@ fn status_of(s: &AppState) -> Status {
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let lib = s.active_library();
Status {
library_label: library_label(s),
library: OpenLibrary { id: lib.id.clone(), name: lib.name.clone() },
reference_path: s.reference_path.display().to_string(),
reference_ready: s.catalog.is_some(),
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),

View File

@@ -1,4 +1,5 @@
mod commands;
mod libraries;
mod news;
mod state;
mod sync;
@@ -76,6 +77,12 @@ pub fn run() {
sync::sync_devices,
sync::sync_revoke_device,
sync::import_library_file,
libraries::list_libraries,
libraries::create_library,
libraries::switch_library,
libraries::rename_library,
libraries::delete_library,
libraries::set_library_sync,
sync::export_library_document,
news::get_news,
news::get_set_cards,

190
src-tauri/src/libraries.rs Normal file
View File

@@ -0,0 +1,190 @@
//! The libraries on this device: listing, starting, opening, renaming and
//! deleting them.
//!
//! Each is a separate working copy (see [`AppState`]), and one is open at a
//! time. Linking them up with the account's libraries on a sync server is
//! `sync.rs`'s job; the commands here only touch the server for what the user
//! asks for directly — a rename, or deleting a library everywhere — and
//! otherwise leave it to the next sync.
use mtgc_core::sync::remote::{HttpRemote, NOT_FOUND};
use serde::Serialize;
use tauri::AppHandle;
use crate::state::{AppState, LocalLibrary};
use crate::sync::{blocking, lock, sync_now};
/// A library, as the UI lists it.
#[derive(Serialize)]
pub struct LibraryInfo {
pub id: String,
pub name: String,
/// The one that's open.
pub active: bool,
/// The library the device started with, which can't be deleted.
pub is_default: bool,
/// Whether it should be on the sync server.
pub sync: bool,
/// Whether it's linked to a library on the server yet.
pub linked: bool,
/// Another device deleted it from the server; this copy is all that's left.
pub server_deleted: bool,
}
fn info(s: &AppState, lib: &LocalLibrary) -> LibraryInfo {
LibraryInfo {
id: lib.id.clone(),
name: lib.name.clone(),
active: s.active_library().id == lib.id,
is_default: lib.is_default(),
sync: lib.sync,
linked: lib.remote_id.is_some(),
server_deleted: lib.server_deleted,
}
}
fn list(s: &AppState) -> Vec<LibraryInfo> {
s.libraries().iter().map(|l| info(s, l)).collect()
}
/// The signed-in account's server, if any.
fn account_remote(s: &AppState) -> Option<HttpRemote> {
let a = s.sync_account()?;
Some(HttpRemote::new(&a.server_url, a.token.as_deref()?))
}
/// Let the next sync put a change on the server now rather than at the next
/// trigger, without making the user wait for it.
fn sync_soon(app: &AppHandle) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let _ = sync_now(app).await;
});
}
#[tauri::command]
pub async fn list_libraries(app: AppHandle) -> Result<Vec<LibraryInfo>, String> {
blocking(move || Ok(list(&lock(&app)))).await
}
/// Start a new, empty library and open it. With `sync`, the next sync puts it
/// on the server.
#[tauri::command]
pub async fn create_library(app: AppHandle, name: String, sync: bool) -> Result<Vec<LibraryInfo>, String> {
let app2 = app.clone();
let libs = blocking(move || {
let mut s = lock(&app2);
let lib = s.create_library(&name, sync, None)?;
s.switch_library(&lib.id)?;
crate::mlog!("create_library: {} (sync={sync})", lib.name);
Ok(list(&s))
})
.await;
let libs = crate::logged("create_library", libs)?;
if sync {
sync_soon(&app);
}
Ok(libs)
}
/// Open another library.
#[tauri::command]
pub async fn switch_library(app: AppHandle, id: String) -> Result<Vec<LibraryInfo>, String> {
let libs = blocking(move || {
let mut s = lock(&app);
s.switch_library(&id)?;
crate::mlog!("switch_library: {}", s.active_library().name);
Ok(list(&s))
})
.await;
crate::logged("switch_library", libs)
}
/// Rename a library — on the server too when it's there, so every device
/// picks the new name up.
#[tauri::command]
pub async fn rename_library(app: AppHandle, id: String, name: String) -> Result<Vec<LibraryInfo>, String> {
let libs = blocking(move || {
let (name, remote_id, remote) = {
let s = lock(&app);
let lib = s.library(&id).ok_or("There's no such library on this device.")?;
(s.check_name(&name, Some(&id))?, lib.remote_id.clone(), account_remote(&s))
};
if let Some(rid) = &remote_id {
let remote = remote.ok_or("Sign in to the sync server to rename a library that's on it.")?;
remote.rename_library(rid, &name).map_err(|e| format!("Couldn't rename it on the server: {e}"))?;
}
let mut s = lock(&app);
s.update_library(&id, |l| l.name = name);
Ok(list(&s))
})
.await;
crate::logged("rename_library", libs)
}
/// Delete a library from this device. With `everywhere`, from the sync
/// server too — and so, at their next sync, from every other device's list
/// of what's on the server (they keep their own copies until removed).
#[tauri::command]
pub async fn delete_library(app: AppHandle, id: String, everywhere: bool) -> Result<Vec<LibraryInfo>, String> {
let libs = blocking(move || {
let (remote_id, remote) = {
let s = lock(&app);
let lib = s.library(&id).ok_or("There's no such library on this device.")?;
(lib.remote_id.clone(), account_remote(&s))
};
if let (true, Some(rid)) = (everywhere, &remote_id) {
let remote = remote.ok_or("Sign in to the sync server to delete a library from it.")?;
match remote.delete_library(rid) {
Ok(()) => {}
// Someone got there first.
Err(e) if e.to_string() == NOT_FOUND => {}
Err(e) => return Err(format!("Couldn't delete it from the server: {e}")),
}
}
let mut s = lock(&app);
let lib = s.delete_library(&id)?;
if let (false, Some(rid)) = (everywhere, &remote_id) {
// Still on the server: don't download it straight back.
s.dismiss_remote(rid);
}
crate::mlog!("delete_library: {} (everywhere={everywhere})", lib.name);
Ok(list(&s))
})
.await;
crate::logged("delete_library", libs)
}
/// Start or stop syncing a library. Stopping leaves the server's copy where
/// it is, for the other devices; starting again links back up with it if it's
/// still there (by name), or puts this one on the server.
#[tauri::command]
pub async fn set_library_sync(app: AppHandle, id: String, sync: bool) -> Result<Vec<LibraryInfo>, String> {
let app2 = app.clone();
let libs = blocking(move || {
let mut s = lock(&app2);
let lib = s.library(&id).cloned().ok_or("There's no such library on this device.")?;
if s.sync_running {
return Err("Wait for the sync to finish, then try again.".into());
}
if !sync {
if let Some(rid) = &lib.remote_id {
s.dismiss_remote(rid);
}
}
s.update_library(&id, |l| {
l.sync = sync;
l.server_deleted = false;
if !sync {
l.remote_id = None;
}
});
Ok(list(&s))
})
.await;
let libs = crate::logged("set_library_sync", libs)?;
if sync {
sync_soon(&app);
}
Ok(libs)
}

View File

@@ -1,8 +1,9 @@
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and
//! the sync account.
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths,
//! the device's libraries, and the sync account.
//!
//! The user's library lives in an internal SQLite working copy under the app
//! data dir, which works fully offline. When the device is signed in to a sync
//! Each library lives in an internal SQLite working copy under the app data
//! dir, which works fully offline. One of them is open at a time — the active
//! library — and every command works on that one. When the device is signed in to a sync
//! server, that copy is reconciled with the server's whenever it can be reached
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
@@ -28,6 +29,69 @@ struct PersistedConfig {
/// The sync server this device is (or was last) signed in to.
#[serde(default)]
sync: Option<SyncAccount>,
/// The libraries on this device, the default one first. Never empty once
/// loaded: a config from before named libraries gets the default.
#[serde(default)]
libraries: Vec<LocalLibrary>,
/// The id of the library that's open.
#[serde(default)]
active_library: String,
/// Server libraries removed from this device, which syncing therefore
/// doesn't download again.
#[serde(default)]
dismissed_remote: Vec<String>,
}
/// The id of the library every device starts with. Its files stay where the
/// one library always lived, so upgrading moves nothing.
pub const DEFAULT_LIBRARY: &str = "default";
/// One of the libraries on this device.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct LocalLibrary {
/// Local only: the folder it lives in. Never shown.
pub id: String,
pub name: String,
/// The server's id for it, once linked to a library there.
#[serde(default)]
pub remote_id: Option<String>,
/// Whether it should be on the server. Off for a library kept on this
/// device only — a scratch library to try things in, say.
#[serde(default = "yes")]
pub sync: bool,
/// It was linked, and another device deleted it from the server. The copy
/// here stays until the user removes it or puts it back on the server.
#[serde(default)]
pub server_deleted: bool,
}
fn yes() -> bool {
true
}
impl LocalLibrary {
pub fn is_default(&self) -> bool {
self.id == DEFAULT_LIBRARY
}
}
fn default_library() -> LocalLibrary {
LocalLibrary {
id: DEFAULT_LIBRARY.into(),
name: "Main".into(),
remote_id: None,
sync: true,
server_deleted: false,
}
}
/// A fresh id for a library's folder.
fn new_library_id() -> String {
mtgc_core::sync::account::random_token()
.chars()
.filter(char::is_ascii_alphanumeric)
.take(16)
.collect()
}
/// This device's sync server account.
@@ -52,6 +116,18 @@ fn config_path(data_dir: &Path) -> PathBuf {
}
fn load_config(data_dir: &Path) -> PersistedConfig {
let mut cfg = read_config(data_dir);
// Before named libraries there was one, which is the default now.
if !cfg.libraries.iter().any(LocalLibrary::is_default) {
cfg.libraries.insert(0, default_library());
}
if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) {
cfg.active_library = DEFAULT_LIBRARY.into();
}
cfg
}
fn read_config(data_dir: &Path) -> PersistedConfig {
let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else {
return PersistedConfig::default();
};
@@ -105,14 +181,23 @@ fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
f.sync_all()
}
/// The folder a library's files live in.
fn library_dir(data_dir: &Path, id: &str) -> PathBuf {
if id == DEFAULT_LIBRARY {
data_dir.to_path_buf()
} else {
data_dir.join("libraries").join(id)
}
}
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
pub struct AppState {
data_dir: PathBuf,
/// Held whole so that writing one preference can't drop the others — the
/// setters below persist this rather than reconstructing a fresh config.
config: PersistedConfig,
/// Internal SQLite working copy: the library itself, on this device. Never
/// chosen by, shown to, or moved by the user.
/// Internal SQLite working copy of the active library: the library itself,
/// on this device. Never chosen by, shown to, or moved by the user.
pub working_db_path: PathBuf,
pub reference_path: PathBuf,
pub catalog: Option<Catalog>,
@@ -141,7 +226,7 @@ impl AppState {
let config = load_config(&data_dir);
let mut s = Self {
reference_path: data_dir.join("reference.db"),
working_db_path: data_dir.join("working.db"),
working_db_path: library_dir(&data_dir, &config.active_library).join("working.db"),
config,
data_dir,
catalog: None,
@@ -152,9 +237,13 @@ impl AppState {
sync_needs_choice: false,
sign_in_cancel: None,
};
// A library whose folder vanished would otherwise be recreated empty
// by opening it, which is fine — but its folder must exist first.
let _ = std::fs::create_dir_all(s.library_dir(&s.config.active_library.clone()));
crate::mlog!(
"AppState::new: sync={:?} working={} reference={} (exists={})",
"AppState::new: sync={:?} library={} working={} reference={} (exists={})",
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
s.config.active_library,
s.working_db_path.display(),
s.reference_path.display(),
s.reference_path.exists()
@@ -170,10 +259,6 @@ impl AppState {
s
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
/// The sync account, signed in or not.
pub fn sync_account(&self) -> Option<&SyncAccount> {
self.config.sync.as_ref()
@@ -192,6 +277,151 @@ impl AppState {
}
}
/// Forget every link to a server library. For signing in to a different
/// account or server, where the old ids mean nothing.
pub fn forget_remote_links(&mut self) {
for lib in &mut self.config.libraries {
lib.remote_id = None;
lib.server_deleted = false;
}
self.config.dismissed_remote.clear();
save_config(&self.data_dir, &self.config);
}
// --- libraries ------------------------------------------------------------
pub fn libraries(&self) -> &[LocalLibrary] {
&self.config.libraries
}
pub fn library(&self, id: &str) -> Option<&LocalLibrary> {
self.config.libraries.iter().find(|l| l.id == id)
}
pub fn active_library(&self) -> &LocalLibrary {
self.library(&self.config.active_library).expect("load_config keeps the active library listed")
}
/// The folder a library's files live in.
pub fn library_dir(&self, id: &str) -> PathBuf {
library_dir(&self.data_dir, id)
}
pub fn working_path(&self, id: &str) -> PathBuf {
self.library_dir(id).join("working.db")
}
/// Where a library's sync merge base is kept. The default library keeps
/// the name it had before there were others.
pub fn base_path(&self, id: &str) -> PathBuf {
if id == DEFAULT_LIBRARY {
self.data_dir.join("sync-server-base.json")
} else {
self.library_dir(id).join("sync-base.json")
}
}
/// Server libraries removed from this device.
pub fn dismissed_remote(&self) -> &[String] {
&self.config.dismissed_remote
}
/// Start a new, empty library. It isn't opened.
pub fn create_library(&mut self, name: &str, sync: bool, remote_id: Option<String>) -> Result<LocalLibrary, String> {
let name = self.check_name(name, None)?;
let lib = LocalLibrary { id: new_library_id(), name, remote_id, sync, server_deleted: false };
let dir = self.library_dir(&lib.id);
std::fs::create_dir_all(&dir).map_err(|e| format!("Couldn't create the library: {e}"))?;
// Created now, so the library exists on disk even before anything
// is added to it.
mtgc_core::db::open_library(&self.working_path(&lib.id)).map_err(|e| e.to_string())?;
if let Some(r) = &lib.remote_id {
self.config.dismissed_remote.retain(|d| d != r);
}
self.config.libraries.push(lib.clone());
save_config(&self.data_dir, &self.config);
Ok(lib)
}
/// A trimmed name, if no other library here already has it.
pub fn check_name(&self, name: &str, except: Option<&str>) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("A library needs a name.".into());
}
let taken = self
.config
.libraries
.iter()
.any(|l| Some(l.id.as_str()) != except && l.name.eq_ignore_ascii_case(name));
if taken {
return Err(format!("There's already a library called {name}."));
}
Ok(name.to_string())
}
/// Change a library in place and persist it. A no-op for an unknown id.
pub fn update_library(&mut self, id: &str, f: impl FnOnce(&mut LocalLibrary)) {
if let Some(lib) = self.config.libraries.iter_mut().find(|l| l.id == id) {
f(lib);
save_config(&self.data_dir, &self.config);
}
}
/// Stop downloading a server library to this device.
pub fn dismiss_remote(&mut self, remote_id: &str) {
if !self.config.dismissed_remote.iter().any(|d| d == remote_id) {
self.config.dismissed_remote.push(remote_id.to_string());
save_config(&self.data_dir, &self.config);
}
}
pub fn undismiss_remote(&mut self, remote_id: &str) {
self.config.dismissed_remote.retain(|d| d != remote_id);
save_config(&self.data_dir, &self.config);
}
/// Open another library.
pub fn switch_library(&mut self, id: &str) -> Result<(), String> {
if self.library(id).is_none() {
return Err("There's no such library on this device.".into());
}
if self.config.active_library == id {
return Ok(());
}
// A sync works on paths it took when it started.
if self.sync_running {
return Err("Wait for the sync to finish, then try again.".into());
}
let _ = std::fs::create_dir_all(self.library_dir(id));
self.config.active_library = id.to_string();
save_config(&self.data_dir, &self.config);
self.working_db_path = self.working_path(id);
self.reopen_catalog()
}
/// Delete a library and its files from this device. The active one is
/// left by switching to the default first; the default itself stays.
pub fn delete_library(&mut self, id: &str) -> Result<LocalLibrary, String> {
let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?;
if lib.is_default() {
return Err("The main library can't be deleted.".into());
}
if self.sync_running {
return Err("Wait for the sync to finish, then try again.".into());
}
if self.config.active_library == id {
self.switch_library(DEFAULT_LIBRARY)?;
}
self.config.libraries.retain(|l| l.id != id);
save_config(&self.data_dir, &self.config);
let dir = self.library_dir(id);
if let Err(e) = std::fs::remove_dir_all(&dir) {
crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display());
}
Ok(lib)
}
/// A pre-document `library.db` still waiting to be converted.
pub fn legacy_library_path(&self) -> Option<&Path> {
self.config.legacy_library_path.as_deref()
@@ -228,6 +458,21 @@ impl AppState {
Ok(())
}
/// Reopen just the catalog, on the active library's working copy. The name
/// index only reads card data, so it survives a switch of library.
fn reopen_catalog(&mut self) -> Result<(), String> {
if self.index.is_none() {
return self.reopen();
}
self.catalog = None;
if self.reference_path.exists() {
self.catalog = Some(
Catalog::open(&self.working_db_path, &self.reference_path).map_err(|e| e.to_string())?,
);
}
Ok(())
}
pub fn catalog(&self) -> Result<&Catalog, String> {
self.catalog
.as_ref()
@@ -271,15 +516,28 @@ mod tests {
#[test]
fn every_setting_round_trips() {
let dir = scratch("round-trip");
let scratch_lib = LocalLibrary {
id: "abc".into(),
name: "Scratch".into(),
remote_id: Some("r1".into()),
sync: false,
server_deleted: true,
};
let cfg = PersistedConfig {
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
sync: Some(account()),
libraries: vec![default_library(), scratch_lib],
active_library: "abc".into(),
dismissed_remote: vec!["r2".into()],
};
save_config(&dir, &cfg);
let back = load_config(&dir);
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
assert_eq!(back.sync, cfg.sync);
assert_eq!(back.libraries, cfg.libraries);
assert_eq!(back.active_library, "abc");
assert_eq!(back.dismissed_remote, cfg.dismissed_remote);
assert!(!dir.join("config.json.tmp").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -289,7 +547,7 @@ mod tests {
fn the_config_holding_the_token_is_private() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("private");
save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) });
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
std::fs::remove_dir_all(&dir).unwrap();
@@ -346,4 +604,54 @@ mod tests {
assert!(load_config(&dir).sync.is_none(), "truncated file");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_config_from_before_named_libraries_has_just_the_default() {
let dir = scratch("pre-libraries");
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
let cfg = load_config(&dir);
assert_eq!(cfg.libraries, vec![default_library()]);
assert_eq!(cfg.active_library, DEFAULT_LIBRARY);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_default_library_keeps_the_files_it_always_had() {
let dir = scratch("paths");
let s = AppState::new(dir.clone());
assert_eq!(s.working_db_path, dir.join("working.db"));
assert_eq!(s.base_path(DEFAULT_LIBRARY), dir.join("sync-server-base.json"));
assert_eq!(s.working_path("abc"), dir.join("libraries/abc/working.db"));
assert_eq!(s.base_path("abc"), dir.join("libraries/abc/sync-base.json"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn libraries_can_be_created_switched_to_and_deleted() {
let dir = scratch("libraries");
let mut s = AppState::new(dir.clone());
let lib = s.create_library(" Scratch ", false, None).unwrap();
assert_eq!(lib.name, "Scratch");
assert!(s.working_path(&lib.id).exists());
assert!(s.create_library("scratch", true, None).is_err(), "names are unique");
assert!(s.create_library(" ", true, None).is_err());
s.switch_library(&lib.id).unwrap();
assert_eq!(s.working_db_path, s.working_path(&lib.id));
// Remembered across restarts.
let mut s = AppState::new(dir.clone());
assert_eq!(s.active_library().id, lib.id);
s.sync_running = true;
assert!(s.switch_library(DEFAULT_LIBRARY).is_err(), "not mid-sync");
assert!(s.delete_library(&lib.id).is_err(), "not mid-sync");
s.sync_running = false;
assert!(s.delete_library(DEFAULT_LIBRARY).is_err());
s.delete_library(&lib.id).unwrap();
assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away");
assert!(!s.library_dir(&lib.id).exists());
assert_eq!(s.libraries().len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
}

View File

@@ -1,4 +1,5 @@
//! Library sync with an mtgc sync server: signing in, and running the cycle.
//! Library sync with an mtgc sync server: signing in, matching this device's
//! libraries up with the account's, and running the cycle for each.
//!
//! The reconciliation lives in [`mtgc_core::sync::remote`], as a cycle over a
//! [`Remote`](mtgc_core::sync::remote::Remote) that is tested without any
@@ -6,7 +7,7 @@
//! kept, holding the state lock while the working copy is rewritten (and only
//! then), and the platform-specific half of signing in.
//!
//! The library never depends on the server being reachable. The working copy
//! A library never depends on the server being reachable. Its working copy
//! is the library on this device; every edit lands there, and a sync is just a
//! chance to exchange edits with the other devices. A failed sync loses
//! nothing and is retried on the next trigger.
@@ -20,30 +21,44 @@
//! - desktop listens on a one-shot loopback port (RFC 8252 §7.3);
//! - Android registers the `mtgc://` scheme, and the deep-link plugin delivers
//! the callback to [`deliver_callback`].
//!
//! # Libraries
//!
//! An account can keep several named libraries, and each library on this
//! device is linked to one of them (or kept here only). Every sync first lines
//! the two lists up — [`plan_libraries`] — so a library another device
//! started is downloaded here, one started here is created on the server, and
//! one deleted elsewhere is flagged rather than silently thrown away. Then it
//! runs the cycle for each linked library, the open one first.
//!
//! The library the device started with is linked to the account's default
//! library, which is what a server from before named libraries keeps as its
//! only one. Against such a server just that one syncs.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce};
use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce, RemoteLibrary};
use mtgc_core::sync::remote::{
first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, UNAUTHORIZED,
first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, NOT_FOUND,
UNAUTHORIZED,
};
use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport};
use serde::Serialize;
use tauri::{AppHandle, Manager};
use crate::state::{AppState, SyncAccount};
use crate::state::{AppState, LocalLibrary, SyncAccount};
/// How long to wait for the user to finish signing in in the browser.
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex<AppState>> {
pub(crate) fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex<AppState>> {
app.state::<Mutex<AppState>>()
}
fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> {
pub(crate) fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> {
// Every holder leaves the state consistent between statements, so a panic
// elsewhere is no reason to stop syncing.
app_state(app).inner().lock().unwrap_or_else(|e| e.into_inner())
@@ -52,7 +67,7 @@ fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> {
/// Run blocking work (network, SQLite) off the main thread. Tauri runs
/// non-async commands on the main thread, where a slow server would freeze
/// the whole UI.
async fn blocking<T: Send + 'static>(
pub(crate) async fn blocking<T: Send + 'static>(
f: impl FnOnce() -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
tauri::async_runtime::spawn_blocking(f)
@@ -62,39 +77,63 @@ async fn blocking<T: Send + 'static>(
// --- the merge base -----------------------------------------------------------
/// A new name rather than the folder-sync era's `sync-base.json`, whose
/// contents mean nothing to a server: an old file is simply never read.
fn base_path(data_dir: &Path) -> PathBuf {
data_dir.join("sync-server-base.json")
/// Where one library's base is kept, and what it belongs to on the server.
struct BaseFile<'a> {
path: PathBuf,
account: &'a SyncAccount,
/// The server's id for the library; `None` against a server without
/// named libraries.
library_id: Option<&'a str>,
is_default: bool,
}
/// The stored base, if it belongs to this server and account. Missing,
/// unreadable, or someone else's all mean the same thing: no ancestor, which
/// makes the next sync a first sync.
fn read_base(data_dir: &Path, account: &SyncAccount) -> Option<SyncBase> {
let text = std::fs::read_to_string(base_path(data_dir)).ok()?;
serde_json::from_str::<AccountBase>(&text)
.ok()?
.for_account(&account.server_url, &account.user.id)
}
fn write_base(data_dir: &Path, account: &SyncAccount, base: &SyncBase) {
let stored = AccountBase {
server_url: account.server_url.clone(),
user_id: account.user.id.clone(),
base: base.clone(),
};
let path = base_path(data_dir);
let tmp = path.with_extension("json.tmp");
let result = serde_json::to_string(&stored)
.map_err(|e| e.to_string())
.and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string()))
.and_then(|_| std::fs::rename(&tmp, &path).map_err(|e| e.to_string()));
if let Err(e) = result {
// Not fatal: without a base the next sync is a first sync, which can
// ask a question but never deletes anything.
crate::mlog!("sync: couldn't save the merge base: {e}");
impl BaseFile<'_> {
/// The stored base, if it belongs to this server, account and library.
/// Missing, unreadable, or someone else's all mean the same thing: no
/// ancestor, which makes the next sync a first sync.
fn read(&self) -> Option<SyncBase> {
let text = std::fs::read_to_string(&self.path).ok()?;
serde_json::from_str::<AccountBase>(&text).ok()?.for_library(
&self.account.server_url,
&self.account.user.id,
self.library_id,
self.is_default,
)
}
fn write(&self, base: &SyncBase) {
let stored = AccountBase {
server_url: self.account.server_url.clone(),
user_id: self.account.user.id.clone(),
library_id: self.library_id.map(str::to_string),
base: base.clone(),
};
let tmp = self.path.with_extension("json.tmp");
let result = serde_json::to_string(&stored)
.map_err(|e| e.to_string())
.and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string()))
.and_then(|_| std::fs::rename(&tmp, &self.path).map_err(|e| e.to_string()));
if let Err(e) = result {
// Not fatal: without a base the next sync is a first sync, which can
// ask a question but never deletes anything.
crate::mlog!("sync: couldn't save the merge base: {e}");
}
}
}
fn base_file<'a>(s: &AppState, account: &'a SyncAccount, lib: &'a LocalLibrary) -> BaseFile<'a> {
BaseFile {
path: s.base_path(&lib.id),
account,
library_id: lib.remote_id.as_deref(),
is_default: lib.is_default(),
}
}
/// Whether a library takes part in syncing: linked to a server library, or
/// the default one, which a server without named libraries syncs unlinked.
fn syncs(lib: &LocalLibrary) -> bool {
lib.remote_id.is_some() || (lib.is_default() && lib.sync)
}
/// The working copy as a document.
@@ -123,18 +162,19 @@ pub struct SyncStatus {
pub legacy_library: Option<String>,
}
fn status(app: &AppHandle) -> SyncStatus {
pub(crate) fn status(app: &AppHandle) -> SyncStatus {
let s = lock(app);
let account = s.sync_account().cloned();
let pending_changes = account
.as_ref()
.filter(|a| a.token.is_some())
.map(|a| match read_base(s.data_dir(), a) {
Some(base) => local_doc(&s.working_db_path).map(|d| d != base.doc).unwrap_or(false),
// Never synced with this account: anything here is unsent.
None => local_doc(&s.working_db_path).map(|d| !d.is_empty()).unwrap_or(false),
let pending_changes = account.as_ref().filter(|a| a.token.is_some()).is_some_and(|a| {
s.libraries().iter().filter(|l| syncs(l)).any(|lib| {
let local = local_doc(&s.working_path(&lib.id));
match base_file(&s, a, lib).read() {
Some(base) => local.map(|d| d != base.doc).unwrap_or(false),
// Never synced with this account: anything here is unsent.
None => local.map(|d| !d.is_empty()).unwrap_or(false),
}
})
.unwrap_or(false);
});
SyncStatus {
signed_in: account.as_ref().is_some_and(|a| a.token.is_some()),
server_url: account.as_ref().map(|a| a.server_url.clone()),
@@ -213,6 +253,10 @@ pub async fn sync_sign_in(
let switching = previous
.as_ref()
.is_some_and(|p| p.server_url != signed_in.0 || p.user.id != signed_in.1.user.id);
if switching {
// Links to the other account's libraries mean nothing here.
s.forget_remote_links();
}
s.set_sync_account(Some(SyncAccount {
server_url: signed_in.0.clone(),
token: Some(signed_in.1.token),
@@ -478,15 +522,22 @@ pub async fn sync_sign_out(app: AppHandle) -> Result<SyncStatus, String> {
/// Outcome of one sync.
#[derive(Serialize, Default)]
pub struct SyncReport {
/// Whether this device's library changed.
/// Whether the open library changed.
pub changed: bool,
pub published: bool,
/// The first sync is waiting on the user: see [`sync_connect`].
pub needs_choice: bool,
/// What changed in the open library.
pub report: MergeReport,
/// Libraries were added, renamed or flagged as deleted on the server, so
/// the list of them is out of date.
pub libraries_changed: bool,
/// What went wrong with any one library. The others still synced.
#[serde(skip)]
error: Option<String>,
}
/// Reconcile the working copy with the server.
/// Reconcile this device's libraries with the server.
///
/// `Ok(None)` when there is nothing to do — not signed in — or a sync was
/// already in flight and this call stood down. Sync runs automatically on
@@ -513,15 +564,15 @@ async fn run_sync(app: AppHandle, choice: Option<FirstSync>) -> Result<Option<Sy
s.sync_running = true;
}
let app2 = app.clone();
let result = blocking(move || sync_inner(&app2, choice)).await;
let result = blocking(move || sync_all(&app2, choice)).await;
let mut s = lock(&app);
s.sync_running = false;
match &result {
Ok(r) => {
s.sync_error = None;
s.sync_error = r.error.clone();
s.sync_needs_choice = r.needs_choice;
if !r.needs_choice {
if !r.needs_choice && r.error.is_none() {
s.update_sync_account(|a| a.last_synced_at = Some(unix_now()));
}
}
@@ -537,20 +588,103 @@ async fn run_sync(app: AppHandle, choice: Option<FirstSync>) -> Result<Option<Sy
crate::logged("sync_now", result).map(Some)
}
fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport, String> {
let (account, data_dir, working) = {
let s = lock(app);
let account = s.sync_account().cloned().ok_or("Not signed in.")?;
(account, s.data_dir().to_path_buf(), s.working_db_path.clone())
};
let token = account.token.as_deref().ok_or("Not signed in.")?;
let remote = HttpRemote::new(&account.server_url, token);
fn sync_all(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport, String> {
let account = lock(app).sync_account().cloned().ok_or("Not signed in.")?;
let token = account.token.clone().ok_or("Not signed in.")?;
let remote = HttpRemote::new(&account.server_url, &token);
let mut report = SyncReport::default();
let mut base = match read_base(&data_dir, &account) {
// A server without named libraries answers the list with a 404: it keeps
// one library, and only the default one syncs with it.
let named = match remote.list_libraries() {
Ok(list) => {
report.libraries_changed = line_up_libraries(app, &remote, &list, &mut report)?;
true
}
Err(e) if e.to_string() == NOT_FOUND => false,
Err(e) => return Err(e.to_string()),
};
let (libs, active) = {
let s = lock(app);
let active = s.active_library().id.clone();
let mut libs: Vec<LocalLibrary> = s.libraries().iter().filter(|l| syncs(l)).cloned().collect();
if !named {
if s.libraries().iter().any(|l| !l.is_default() && l.sync) {
report.error.get_or_insert_with(|| {
"That sync server keeps only one library — update it to sync the others.".into()
});
}
libs.retain(LocalLibrary::is_default);
}
// The open library first: it's the one the user is waiting on.
libs.sort_by_key(|l| l.id != active);
(libs, active)
};
for lib in libs {
let remote = match (&lib.remote_id, named) {
(Some(id), true) => HttpRemote::for_library(&account.server_url, &token, id),
(_, false) => HttpRemote::new(&account.server_url, &token),
// The default library, not linked because it's kept off the server.
(None, true) => continue,
};
// Only the default library's first sync can ask: the others are
// either new on one side or matched by name, and combine.
let choice = if lib.is_default() { choice } else { Some(FirstSync::Combine) };
match sync_one(app, &account, &lib, &remote, choice) {
Ok(one) => {
report.needs_choice |= one.needs_choice;
if lib.id == active {
report.changed = one.changed;
report.published = one.published;
report.report = one.report;
}
}
Err(e) if e == UNAUTHORIZED => return Err(e),
Err(e) if e == NOT_FOUND => {
// Deleted on the server since the list was read.
lock(app).update_library(&lib.id, |l| {
l.remote_id = None;
l.server_deleted = true;
});
report.libraries_changed = true;
}
Err(e) => {
crate::mlog!("sync: {} failed: {e}", lib.name);
report.error.get_or_insert_with(|| format!("{}: {e}", lib.name));
}
}
}
Ok(report)
}
/// One library's sync.
#[derive(Default)]
struct OneReport {
changed: bool,
published: bool,
needs_choice: bool,
report: MergeReport,
}
fn sync_one(
app: &AppHandle,
account: &SyncAccount,
lib: &LocalLibrary,
remote: &HttpRemote,
choice: Option<FirstSync>,
) -> Result<OneReport, String> {
let (base_file, working) = {
let s = lock(app);
(base_file(&s, account, lib), s.working_path(&lib.id))
};
let mut base = match base_file.read() {
Some(base) => base,
None => {
// Never synced with this account's library from here, so there is
// no ancestor to tell edits from deletions with.
// Never synced with this library on the server from here, so
// there is no ancestor to tell edits from deletions with.
use mtgc_core::sync::remote::Remote;
let theirs = remote.get().map_err(|e| e.to_string())?;
let ours = {
@@ -558,7 +692,7 @@ fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport,
local_doc(&working)?
};
match first_sync_plan(&ours, &theirs.doc, choice) {
None => return Ok(SyncReport { needs_choice: true, ..Default::default() }),
None => return Ok(OneReport { needs_choice: true, ..Default::default() }),
// An empty ancestor: nothing on either side reads as deleted.
Some(FirstSync::Combine) => SyncBase::default(),
Some(FirstSync::UseServer) => {
@@ -574,24 +708,25 @@ fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport,
// The state lock is held only while the working copy is read, merged and
// rewritten — every edit command takes the same lock, so none can land in
// between and be overwritten. The network calls happen without it.
let result = sync_cycle(&remote, &mut base, |base_doc, theirs| {
let result = sync_cycle(remote, &mut base, |base_doc, theirs| {
let _s = lock(app);
let conn = mtgc_core::db::open_library(&working)?;
core_sync::merge_into(&conn, base_doc, theirs)
});
// Saved whether or not the cycle finished: it only ever records what is
// known to be on the server, so a partial run's base is still sound.
write_base(&data_dir, &account, &base);
base_file.write(&base);
let cycle = result.map_err(|e| e.to_string())?;
crate::mlog!(
"sync_now: changed={} published={} version={} conflicts={}",
"sync_now: {} changed={} published={} version={} conflicts={}",
lib.name,
!cycle.report.is_empty(),
cycle.published,
cycle.version,
cycle.report.conflicts.len()
);
Ok(SyncReport {
Ok(OneReport {
changed: !cycle.report.is_empty(),
published: cycle.published,
needs_choice: false,
@@ -599,6 +734,168 @@ fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport,
})
}
// --- lining up libraries ----------------------------------------------------------
/// How to bring this device's libraries in line with the account's.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct LibraryPlan {
/// (local id, server id): link these, combining their contents.
link: Vec<(String, String)>,
/// Server libraries to download as new libraries here.
download: Vec<RemoteLibrary>,
/// Local ids whose server library is gone.
deleted: Vec<String>,
/// (local id, name): take the server's name.
rename: Vec<(String, String)>,
/// Local ids to create on the server.
create: Vec<String>,
}
impl LibraryPlan {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
/// Line this device's libraries up with the server's list. Pure, so the
/// rules are tested without a server:
///
/// - a linked library whose server library is gone is flagged as deleted
/// there, and keeps its contents;
/// - the default library links to the account's default;
/// - a server library not on this device links to a not-yet-linked library
/// here with the same name, or is downloaded — unless it was removed from
/// this device;
/// - a library here that should be on the server but isn't linked is created
/// there.
pub(crate) fn plan_libraries(
local: &[LocalLibrary],
dismissed: &[String],
server: &[RemoteLibrary],
) -> LibraryPlan {
let mut plan = LibraryPlan::default();
let mut linked: Vec<&str> = Vec::new();
let mut matched: Vec<&str> = Vec::new();
let link = |plan: &mut LibraryPlan, lib: &LocalLibrary, remote: &RemoteLibrary| {
plan.link.push((lib.id.clone(), remote.id.clone()));
if lib.name != remote.name {
plan.rename.push((lib.id.clone(), remote.name.clone()));
}
};
for lib in local {
let Some(rid) = &lib.remote_id else { continue };
match server.iter().find(|r| &r.id == rid) {
Some(r) => {
linked.push(&r.id);
if r.name != lib.name {
plan.rename.push((lib.id.clone(), r.name.clone()));
}
}
None => plan.deleted.push(lib.id.clone()),
}
}
let unlinked = |l: &&LocalLibrary| l.remote_id.is_none() && l.sync && !l.server_deleted;
for r in server {
if linked.contains(&r.id.as_str()) {
continue;
}
if r.default {
if let Some(lib) = local.iter().filter(unlinked).find(|l| l.is_default()) {
link(&mut plan, lib, r);
matched.push(&lib.id);
}
// Otherwise the default library is kept off the server here.
continue;
}
let same_name = local
.iter()
.filter(unlinked)
.filter(|l| !l.is_default() && !matched.contains(&l.id.as_str()))
.find(|l| l.name.eq_ignore_ascii_case(&r.name));
if let Some(lib) = same_name {
link(&mut plan, lib, r);
matched.push(&lib.id);
} else if !dismissed.contains(&r.id) {
plan.download.push(r.clone());
}
}
for lib in local.iter().filter(unlinked) {
if !lib.is_default() && !matched.contains(&lib.id.as_str()) {
plan.create.push(lib.id.clone());
}
}
plan
}
/// Carry out [`plan_libraries`] for this sync. Returns whether the list of
/// libraries changed.
fn line_up_libraries(
app: &AppHandle,
remote: &HttpRemote,
server: &[RemoteLibrary],
report: &mut SyncReport,
) -> Result<bool, String> {
let plan = {
let s = lock(app);
plan_libraries(s.libraries(), s.dismissed_remote(), server)
};
if plan.is_empty() {
return Ok(false);
}
crate::mlog!("sync: lining up libraries: {plan:?}");
// Creating on the server happens without the lock; the rest is local.
let mut created = Vec::new();
for id in &plan.create {
let Some(name) = lock(app).library(id).map(|l| l.name.clone()) else { continue };
match remote.create_library(&name) {
Ok(r) => created.push((id.clone(), r.id)),
Err(e) => {
crate::mlog!("sync: couldn't create {name} on the server: {e}");
report.error.get_or_insert_with(|| format!("{name}: {e}"));
}
}
}
let mut s = lock(app);
for (id, rid) in plan.link.iter().chain(&created) {
s.update_library(id, |l| l.remote_id = Some(rid.clone()));
s.undismiss_remote(rid);
}
for (id, name) in &plan.rename {
let name = unique_name(&s, name, Some(id));
s.update_library(id, |l| l.name = name);
}
for id in &plan.deleted {
s.update_library(id, |l| {
l.remote_id = None;
l.server_deleted = true;
});
}
for r in &plan.download {
let name = unique_name(&s, &r.name, None);
if let Err(e) = s.create_library(&name, true, Some(r.id.clone())) {
crate::mlog!("sync: couldn't download {}: {e}", r.name);
report.error.get_or_insert_with(|| format!("{}: {e}", r.name));
}
}
Ok(true)
}
/// `name`, or `name (2)` and so on if a library here already has it.
fn unique_name(s: &AppState, name: &str, except: Option<&str>) -> String {
let mut candidate = name.to_string();
let mut n = 1;
while s.check_name(&candidate, except).is_err() {
n += 1;
candidate = format!("{name} ({n})");
}
candidate
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -646,7 +943,7 @@ pub struct ImportReport {
pub report: MergeReport,
}
/// Merge a library file into this device's library: a JSON library document
/// Merge a library file into the open library: a JSON library document
/// (a backup, or the file a folder-synced build kept), or a `library.db` from
/// before documents existed. Nothing already here is lost — it's merged with
/// an empty ancestor — and the next sync shares the result.
@@ -689,7 +986,7 @@ pub async fn import_library_file(app: AppHandle, path: String) -> Result<ImportR
Ok(report)
}
/// Save the whole library as a JSON document — a backup that
/// Save the whole open library as a JSON document — a backup that
/// [`import_library_file`] reads back.
#[tauri::command]
pub async fn export_library_document(app: AppHandle, path: String) -> Result<(), String> {
@@ -731,29 +1028,114 @@ mod tests {
dir
}
fn base<'a>(dir: &Path, account: &'a SyncAccount, library_id: Option<&'a str>, is_default: bool) -> BaseFile<'a> {
BaseFile { path: dir.join("sync-server-base.json"), account, library_id, is_default }
}
#[test]
fn a_base_is_only_used_with_the_account_it_came_from() {
fn a_base_is_only_used_with_the_account_and_library_it_came_from() {
let dir = scratch("base");
let mine = account("https://a.example", "u1");
let base = SyncBase { version: 4, ..Default::default() };
write_base(&dir, &mine, &base);
let b = SyncBase { version: 4, ..Default::default() };
base(&dir, &mine, Some("lib1"), false).write(&b);
assert_eq!(read_base(&dir, &mine), Some(base));
assert_eq!(read_base(&dir, &account("https://a.example", "u2")), None);
assert_eq!(read_base(&dir, &account("https://b.example", "u1")), None);
assert_eq!(base(&dir, &mine, Some("lib1"), false).read(), Some(b));
assert_eq!(base(&dir, &mine, Some("lib2"), false).read(), None);
assert_eq!(base(&dir, &account("https://a.example", "u2"), Some("lib1"), false).read(), None);
assert_eq!(base(&dir, &account("https://b.example", "u1"), Some("lib1"), false).read(), None);
assert!(!dir.join("sync-server-base.json.tmp").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_sync_era_base_is_never_read() {
// That build's file held a bare document under a different name.
let dir = scratch("old-base");
std::fs::write(dir.join("sync-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap();
assert_eq!(read_base(&dir, &account("https://a.example", "u1")), None);
fn the_base_from_before_named_libraries_carries_over_to_the_default_one() {
let dir = scratch("upgrade-base");
let mine = account("https://a.example", "u1");
let b = SyncBase { version: 9, ..Default::default() };
// What the previous build saved: no library id.
base(&dir, &mine, None, true).write(&b);
assert_eq!(base(&dir, &mine, Some("main"), true).read(), Some(b), "no first-sync question after upgrading");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_sync_era_base_is_never_read() {
// That build's file held a bare document.
let dir = scratch("old-base");
std::fs::write(dir.join("sync-server-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap();
assert_eq!(base(&dir, &account("https://a.example", "u1"), None, true).read(), None);
std::fs::remove_dir_all(&dir).unwrap();
}
fn local(id: &str, name: &str, remote: Option<&str>) -> LocalLibrary {
LocalLibrary {
id: id.into(),
name: name.into(),
remote_id: remote.map(str::to_string),
sync: true,
server_deleted: false,
}
}
fn server(id: &str, name: &str, default: bool) -> RemoteLibrary {
RemoteLibrary { id: id.into(), name: name.into(), default, version: 1 }
}
#[test]
fn the_default_library_links_to_the_accounts_default() {
let plan = plan_libraries(&[local("default", "Main", None)], &[], &[server("m", "Main", true)]);
assert_eq!(plan, LibraryPlan { link: vec![("default".into(), "m".into())], ..Default::default() });
// Linked already, and nothing else on either side: nothing to do.
let plan = plan_libraries(&[local("default", "Main", Some("m"))], &[], &[server("m", "Main", true)]);
assert_eq!(plan, LibraryPlan::default());
}
#[test]
fn libraries_started_elsewhere_are_downloaded_unless_removed_here() {
let libs = [local("default", "Main", Some("m"))];
let list = [server("m", "Main", true), server("s", "Scratch", false), server("t", "Trades", false)];
let plan = plan_libraries(&libs, &["t".into()], &list);
assert_eq!(plan.download, vec![list[1].clone()]);
assert!(plan.link.is_empty() && plan.create.is_empty());
}
#[test]
fn libraries_started_here_are_created_on_the_server_or_matched_by_name() {
let mut kept_here = local("k", "Kept here", None);
kept_here.sync = false;
let libs = [
local("default", "Main", Some("m")),
local("a", "scratch", None),
local("b", "Cube", None),
kept_here,
];
// Matched even when removed here before: it's what the user means.
let list = [server("m", "Main", true), server("s", "Scratch", false)];
let plan = plan_libraries(&libs, &["s".into()], &list);
assert_eq!(plan.link, vec![("a".into(), "s".into())]);
assert_eq!(plan.rename, vec![("a".into(), "Scratch".into())]);
assert_eq!(plan.create, vec!["b".to_string()]);
assert!(plan.download.is_empty());
}
#[test]
fn libraries_deleted_or_renamed_elsewhere_are_noticed() {
let mut gone_before = local("g", "Old", None);
gone_before.server_deleted = true;
let libs = [
local("default", "Main", Some("m")),
local("a", "Scratch", Some("s")),
local("b", "Cube", Some("c")),
gone_before,
];
let list = [server("m", "Collection", true), server("c", "Cube", false)];
let plan = plan_libraries(&libs, &[], &list);
assert_eq!(plan.deleted, vec!["a".to_string()]);
assert_eq!(plan.rename, vec![("default".into(), "Collection".into())]);
assert!(plan.create.is_empty(), "one deleted on the server isn't put back unasked");
}
#[cfg(not(target_os = "android"))]
#[test]
fn the_loopback_listener_takes_only_the_callback() {