//! The library as a portable JSON document, and the merge that reconciles two //! copies of it. //! //! The document is the library's shape for exchange: what a sync server stores //! (see [`remote`]), what a backup export writes, and what an import reads. On //! each device the library lives in an internal SQLite working copy, which can //! always be rebuilt by merging a document into an empty one — so "first //! sync", "imported a backup", and "changed on another device" are all the same //! code path. //! //! # Why a three-way merge //! //! Reconciling two edited copies needs to know *who changed what*, and — the //! hard part — to tell "deleted over there" apart from "never existed over //! there". Given a common ancestor, both fall straight out of comparison. That //! is the whole reason this module needs no timestamp columns, no tombstone //! table, and no triggers: the ancestor carries the information they would have //! had to record. //! //! # The one subtle rule //! //! The stored ancestor must be **the remote document as last known**, never //! the result we last *tried* to publish. If a publish is lost — the //! connection dropped, the process died — the remote is still what we last //! read, so the next merge sees our edits as ours and republishes them. //! Recording the attempted result instead would make us believe those edits //! were already shared, and the next merge would read their absence from the //! remote as a deletion and destroy them. (A publish the server *confirmed* is //! known, so the ancestor may advance to it — see [`remote::sync_cycle`].) //! //! # Conflicts //! //! When both sides changed the same field, [`merge3`] takes *theirs*. That is //! forced by convergence, not taste: "prefer ours" has each device re-asserting //! its own value forever, so the two never agree. Preferring theirs settles //! immediately, because after merging we hold the value they published. In //! practice it behaves as roughly last-published-wins. Every such choice is //! counted in [`MergeReport`] so the UI can tell the user what was overruled. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use crate::error::Result; pub mod account; pub mod remote; /// The newest document format this build reads. Readers reject anything newer /// rather than silently misreading it. /// /// A document is written at the *lowest* version that can hold it (see /// [`LibraryDoc::required_version`]), not at this one. A reader that silently /// dropped printing data would publish its loss as a deletion on the next /// sync, so documents that carry any are marked 2 and older builds refuse /// them — while libraries that don't use printings stay readable everywhere. pub const DOC_VERSION: u32 = 2; /// The format of a document with no printing data in it. const BASE_VERSION: u32 = 1; /// One owned card. Keyed by `oracle_id` in [`LibraryDoc::cards`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct CardEntry { pub quantity: i64, #[serde(default, skip_serializing_if = "String::is_empty")] pub location: String, /// Copies pinned to a printing + finish, keyed by [`printing_key`]. A /// subset of `quantity`; whatever they don't cover is unspecified. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub printings: BTreeMap, } /// One card in a deck. Keyed by `oracle_id` in [`DeckDoc::cards`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct DeckCardEntry { pub quantity: i64, #[serde(default, skip_serializing_if = "is_false")] pub commander: bool, #[serde(default, skip_serializing_if = "is_false")] pub maybeboard: bool, /// Scryfall id of the printing whose art this slot shows. #[serde(default, skip_serializing_if = "Option::is_none")] pub printing: Option, } /// The [`CardEntry::printings`] key for a copy group: `"/"`. /// Scryfall ids are UUIDs, so the separator can't be ambiguous. pub fn printing_key(scryfall_id: &str, finish: &str) -> String { format!("{scryfall_id}/{finish}") } /// Split a [`printing_key`] back into `(scryfall_id, finish)`. pub fn parse_printing_key(key: &str) -> Option<(&str, &str)> { key.rsplit_once('/') } /// One deck. Keyed by its uuid in [`LibraryDoc::decks`] — see migration 0005 /// for why the integer id can't be used. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct DeckDoc { pub name: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub format: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub commander: Option, #[serde(default, skip_serializing_if = "String::is_empty")] pub notes: String, #[serde(default)] pub cards: BTreeMap, } /// The user's whole library. /// /// `BTreeMap` throughout so serialisation is deterministic: the same library /// always produces byte-identical JSON, which keeps sync tools from seeing /// spurious changes and makes the file diffable by hand. /// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LibraryDoc { pub version: u32, #[serde(default)] pub cards: BTreeMap, #[serde(default)] pub decks: BTreeMap, } impl Default for LibraryDoc { fn default() -> Self { Self { version: BASE_VERSION, cards: BTreeMap::new(), decks: BTreeMap::new(), } } } #[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool { !*b } impl LibraryDoc { pub fn to_json(&self) -> Result { Ok(serde_json::to_string_pretty(self)?) } /// Parse a document, rejecting one written by a newer version of the app /// rather than silently dropping fields it doesn't understand. pub fn from_json(text: &str) -> Result { let doc: Self = serde_json::from_str(text)?; if doc.version > DOC_VERSION { return Err(crate::error::CoreError::Other(format!( "this library file was written by a newer version of mtgc \ (format {}, this build understands {DOC_VERSION}) — update the app first", doc.version ))); } Ok(doc) } pub fn is_empty(&self) -> bool { self.cards.is_empty() && self.decks.is_empty() } /// The lowest format that can hold this document: 2 once it carries any /// printing data, else 1. See [`DOC_VERSION`]. pub fn required_version(&self) -> u32 { let has_copies = self.cards.values().any(|c| !c.printings.is_empty()); let has_deck_printings = self .decks .values() .flat_map(|d| d.cards.values()) .any(|c| c.printing.is_some()); if has_copies || has_deck_printings { 2 } else { BASE_VERSION } } } /// What a merge did, for reporting to the user. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] pub struct MergeReport { pub cards_changed: usize, pub decks_changed: usize, pub decks_removed: usize, /// Fields both sides changed differently, where theirs was taken. Described /// in user terms (a deck or card name), not field paths. pub conflicts: Vec, } impl MergeReport { /// Whether anything at all changed, so callers can stay quiet about the /// no-op merges that make up almost every sync. pub fn is_empty(&self) -> bool { self.cards_changed == 0 && self.decks_changed == 0 && self.decks_removed == 0 } } /// Reconcile our copy with theirs, given the common ancestor both descend from. /// /// A pure function — no database, no filesystem, no clock — which is what makes /// the interesting cases (concurrent edits, deletes racing edits, idempotency) /// directly testable. /// /// Per key, across `base`/`ours`/`theirs`: /// - unchanged on one side → take the other side's version /// - changed identically → no conflict, either will do /// - changed differently → take theirs, and record it /// - present in base, gone from one side → that side deleted it; the deletion /// wins unless the other side also *changed* it, in which case the edit does /// (destroying edited work is worse than resurrecting a deleted deck, which /// the user can simply delete again) pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (LibraryDoc, MergeReport) { let mut report = MergeReport::default(); let mut out = LibraryDoc::default(); for id in keys(&base.cards, &ours.cards, &theirs.cards) { let (b, o, t) = (base.cards.get(&id), ours.cards.get(&id), theirs.cards.get(&id)); if let Some(entry) = merge_card(b, o, t, &id, &mut report) { if Some(&entry) != o { report.cards_changed += 1; } out.cards.insert(id, entry); } else if o.is_some() { // Cards are never deleted by the app (quantity drops to zero // instead), so this only happens if a document was hand-edited. report.cards_changed += 1; } } for id in keys(&base.decks, &ours.decks, &theirs.decks) { let (b, o, t) = (base.decks.get(&id), ours.decks.get(&id), theirs.decks.get(&id)); match (b, o, t) { // Deleted on one side; kept and untouched on the other. (Some(bd), Some(od), None) if od == bd => { report.decks_removed += 1; continue; } (Some(bd), None, Some(td)) if td == bd => { report.decks_removed += 1; continue; } // Deleted on both. (Some(_), None, None) => { report.decks_removed += 1; continue; } _ => {} } let name = o.or(t).or(b).map(|d| d.name.clone()).unwrap_or_default(); let merged = merge_deck(b, o, t, &name, &mut report); if Some(&merged) != o { report.decks_changed += 1; } out.decks.insert(id, merged); } out.version = out.required_version(); (out, report) } /// Union of the keys of three maps, in deterministic order. fn keys( base: &BTreeMap, ours: &BTreeMap, theirs: &BTreeMap, ) -> BTreeSet { base.keys() .chain(ours.keys()) .chain(theirs.keys()) .cloned() .collect() } /// Three-way merge of a single value. `None` means "not present on this side". /// Returns `None` when the entry should not exist in the result. fn merge_entry( base: Option<&T>, ours: Option<&T>, theirs: Option<&T>, on_conflict: &mut dyn FnMut(), ) -> Option { match (base, ours, theirs) { (_, None, None) => None, // Only one side has it: either it was just added there, or the other // side deleted it. Deletion only wins if the surviving side left it // exactly as the ancestor had it. (Some(b), Some(o), None) => (o != b).then(|| o.clone()), (Some(b), None, Some(t)) => (t != b).then(|| t.clone()), (None, Some(o), None) => Some(o.clone()), (None, None, Some(t)) => Some(t.clone()), (base, Some(o), Some(t)) => { if o == t { return Some(o.clone()); } match base { Some(b) if o == b => Some(t.clone()), // only they changed it Some(b) if t == b => Some(o.clone()), // only we changed it _ => { // Both changed it, differently — or there's no ancestor and // they disagree. Theirs wins, for convergence. on_conflict(); Some(t.clone()) } } } } } /// Three-way merge of one owned card. Whether it exists at all is decided /// exactly as for any entry ([`merge_entry`]); when both sides kept it, its /// fields merge independently and its copy groups per printing, so a quantity /// change on one device and a printing recorded on another both survive. fn merge_card( base: Option<&CardEntry>, ours: Option<&CardEntry>, theirs: Option<&CardEntry>, id: &str, report: &mut MergeReport, ) -> Option { let (Some(o), Some(t)) = (ours, theirs) else { return merge_entry(base, ours, theirs, &mut || { report.conflicts.push(format!("owned quantity for card {id}")) }); }; if o == t { return Some(o.clone()); } let empty = CardEntry::default(); let b = base.unwrap_or(&empty); let mut merged = CardEntry { quantity: merge_field(&b.quantity, &o.quantity, &t.quantity, || { report.conflicts.push(format!("owned quantity for card {id}")) }), location: merge_field(&b.location, &o.location, &t.location, || { report.conflicts.push(format!("location of card {id}")) }), printings: BTreeMap::new(), }; for key in keys(&b.printings, &o.printings, &t.printings) { let qty = merge_entry( b.printings.get(&key), o.printings.get(&key), t.printings.get(&key), &mut || report.conflicts.push(format!("tracked printing of card {id}")), ); if let Some(qty) = qty { merged.printings.insert(key, qty); } } clamp_printings(&mut merged); Some(merged) } /// Trim copy groups until they fit within the owned quantity — the merge's /// counterpart to the database's own clamp. Trims from the end of key order /// rather than "newest first", because a document has no edit order and the /// result must come out the same on every device. fn clamp_printings(card: &mut CardEntry) { let mut excess = card.printings.values().sum::() - card.quantity.max(0); while excess > 0 { let Some(mut last) = card.printings.last_entry() else { break; }; let cut = (*last.get()).min(excess); if cut == *last.get() { last.remove(); } else { *last.get_mut() -= cut; } excess -= cut; } } fn merge_deck( base: Option<&DeckDoc>, ours: Option<&DeckDoc>, theirs: Option<&DeckDoc>, name: &str, report: &mut MergeReport, ) -> DeckDoc { // Present on only one side. Reaching here means the other side deleted it // *and* this side changed it, so the surviving version is taken whole — an // absent deck must not be treated as an empty one, or its fields would lose // a field-by-field merge against a deck full of empty strings. match (ours, theirs) { (Some(o), None) => return o.clone(), (None, Some(t)) => return t.clone(), (None, None) => return DeckDoc::default(), // filtered out by merge3 _ => {} } let empty = DeckDoc::default(); let o = ours.unwrap_or(&empty); let t = theirs.unwrap_or(&empty); let b = base.unwrap_or(&empty); // Scalar fields merge independently, so renaming a deck here while changing // its format there keeps both. let mut merged = DeckDoc { name: merge_field(&b.name, &o.name, &t.name, || { report.conflicts.push(format!("name of deck \"{name}\"")) }), format: merge_field(&b.format, &o.format, &t.format, || { report.conflicts.push(format!("format of deck \"{name}\"")) }), commander: merge_field(&b.commander, &o.commander, &t.commander, || { report.conflicts.push(format!("commander of deck \"{name}\"")) }), notes: merge_field(&b.notes, &o.notes, &t.notes, || { report.conflicts.push(format!("notes of deck \"{name}\"")) }), cards: BTreeMap::new(), }; for id in keys(&b.cards, &o.cards, &t.cards) { let entry = merge_entry( b.cards.get(&id), o.cards.get(&id), t.cards.get(&id), &mut || { report .conflicts .push(format!("card {id} in deck \"{name}\"")) }, ); if let Some(entry) = entry { merged.cards.insert(id, entry); } } merged } /// Three-way merge of an always-present field. fn merge_field(base: &T, ours: &T, theirs: &T, on_conflict: impl FnOnce()) -> T { if ours == theirs || theirs == base { return ours.clone(); } if ours == base { return theirs.clone(); } on_conflict(); theirs.clone() } /// Serialise the working database into a document. pub fn read_doc(conn: &Connection) -> Result { let mut doc = LibraryDoc::default(); let mut stmt = conn.prepare( "SELECT oracle_id, quantity_owned, location FROM library_cards WHERE quantity_owned <> 0 OR location <> ''", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { doc.cards.insert( row.get(0)?, CardEntry { quantity: row.get(1)?, location: row.get(2)?, printings: BTreeMap::new(), }, ); } let mut stmt = conn.prepare( "SELECT lc.oracle_id, c.scryfall_id, c.finish, c.quantity FROM library_card_copies c JOIN library_cards lc ON lc.id = c.library_card_id WHERE c.quantity > 0 AND c.scryfall_id IS NOT NULL AND c.finish IS NOT NULL", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let oracle_id: String = row.get(0)?; let key = printing_key(&row.get::<_, String>(1)?, &row.get::<_, String>(2)?); // Copies are clamped to quantity_owned, so the card is always listed. if let Some(card) = doc.cards.get_mut(&oracle_id) { card.printings.insert(key, row.get(3)?); } } let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?; let mut rows = stmt.query([])?; let mut by_id: BTreeMap = BTreeMap::new(); while let Some(row) = rows.next()? { let id: i64 = row.get(0)?; let uuid: String = row.get(1)?; by_id.insert(id, uuid.clone()); doc.decks.insert( uuid, DeckDoc { name: row.get(2)?, format: row.get(3)?, commander: row.get(4)?, notes: row.get(5)?, cards: BTreeMap::new(), }, ); } let mut stmt = conn.prepare( "SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id FROM deck_cards", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let deck_id: i64 = row.get(0)?; let Some(uuid) = by_id.get(&deck_id) else { continue; // Orphan row; the foreign key should make this impossible. }; if let Some(deck) = doc.decks.get_mut(uuid) { deck.cards.insert( row.get(1)?, DeckCardEntry { quantity: row.get(2)?, commander: row.get::<_, i64>(3)? != 0, maybeboard: row.get::<_, i64>(4)? != 0, printing: row.get(5)?, }, ); } } doc.version = doc.required_version(); Ok(doc) } /// Reconcile the working database to `doc`, in one transaction. /// /// Rows absent from the document are removed, so this is a true "make the /// database equal this document" — which is what lets the database be treated /// as a rebuildable cache rather than a second source of truth. pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { let tx = conn.unchecked_transaction()?; for (oracle_id, entry) in &doc.cards { tx.execute( "INSERT INTO library_cards(oracle_id, quantity_owned, location) VALUES (?1, ?2, ?3) ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned, location = excluded.location", rusqlite::params![oracle_id, entry.quantity, entry.location], )?; } // Cards the document doesn't mention are zeroed rather than deleted, so any // deck still referencing them keeps working. { let keep = json_array(doc.cards.keys())?; tx.execute( "UPDATE library_cards SET quantity_owned = 0, location = '' WHERE oracle_id NOT IN (SELECT value FROM json_each(?1))", [&keep], )?; } // Copy groups are rebuilt wholesale: their row ids are local, and the // document is the whole truth about them. tx.execute("DELETE FROM library_card_copies", [])?; for (oracle_id, entry) in &doc.cards { for (key, qty) in &entry.printings { let Some((scryfall_id, finish)) = parse_printing_key(key) else { continue; // Hand-edited nonsense; nothing sensible to store. }; tx.execute( "INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity) SELECT id, ?2, ?3, ?4 FROM library_cards WHERE oracle_id = ?1", rusqlite::params![oracle_id, scryfall_id, finish, qty], )?; } } // Decks are matched by uuid; anything else is a deck the document no longer // has. ON DELETE CASCADE clears its cards. { let keep = json_array(doc.decks.keys())?; tx.execute( "DELETE FROM decks WHERE uuid NOT IN (SELECT value FROM json_each(?1))", [&keep], )?; } for (uuid, deck) in &doc.decks { tx.execute( "INSERT INTO decks(uuid, name, format, commander_oracle_id, notes) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(uuid) DO UPDATE SET name = excluded.name, format = excluded.format, commander_oracle_id = excluded.commander_oracle_id, notes = excluded.notes", rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes], )?; let deck_id: i64 = tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?; for (oracle_id, card) in &deck.cards { tx.execute( "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(deck_id, oracle_id) DO UPDATE SET quantity = excluded.quantity, is_commander = excluded.is_commander, is_maybeboard = excluded.is_maybeboard, preferred_scryfall_id = excluded.preferred_scryfall_id", rusqlite::params![ deck_id, oracle_id, card.quantity, card.commander as i64, card.maybeboard as i64, card.printing ], )?; } let keep = json_array(deck.cards.keys())?; tx.execute( "DELETE FROM deck_cards WHERE deck_id = ?1 AND oracle_id NOT IN (SELECT value FROM json_each(?2))", rusqlite::params![deck_id, keep], )?; } tx.commit()?; Ok(()) } /// Merge `theirs` into the working copy against `base`, and return the result. /// /// The working copy is only rewritten when the merge changed it, so the common /// nothing-happened sync costs one read. pub fn merge_into( conn: &Connection, base: &LibraryDoc, theirs: &LibraryDoc, ) -> Result<(LibraryDoc, MergeReport)> { let ours = read_doc(conn)?; let (merged, report) = merge3(base, &ours, theirs); if merged != ours { apply_doc(conn, &merged)?; } Ok((merged, report)) } /// Render keys as a JSON array, for `json_each` to expand inside a query — /// SQLite has no array parameter, and this avoids building SQL by /// concatenation. fn json_array<'a>(keys: impl Iterator) -> Result { Ok(serde_json::to_string(&keys.collect::>())?) } /// Read a library database written before the document format existed (schema /// v1–v4, where `library.db` was the file the user kept) and produce a document. /// /// Migrations run on open, so the file is upgraded to the current schema first; /// that's what gives its decks the uuids the document needs. pub fn convert_legacy_library(path: &Path) -> Result { let conn = crate::db::open_library(path)?; read_doc(&conn) } #[cfg(test)] mod tests { use super::*; use crate::Catalog; fn doc(cards: &[(&str, i64)], decks: &[(&str, &str, &[(&str, i64)])]) -> LibraryDoc { let mut d = LibraryDoc::default(); for (id, qty) in cards { d.cards.insert( (*id).into(), CardEntry { quantity: *qty, ..Default::default() }, ); } for (uuid, name, cards) in decks { let mut deck = DeckDoc { name: (*name).into(), ..Default::default() }; for (id, qty) in *cards { deck.cards.insert( (*id).into(), DeckCardEntry { quantity: *qty, ..Default::default() }, ); } d.decks.insert((*uuid).into(), deck); } d } fn qty(d: &LibraryDoc, id: &str) -> Option { d.cards.get(id).map(|c| c.quantity) } // --- the merge, as a pure function ------------------------------------ #[test] fn edits_to_different_cards_on_each_side_both_survive() { // The scenario this whole design exists for: edit offline on the phone, // edit something else on the desktop, then sync. let base = doc(&[("a", 1), ("b", 1)], &[]); let ours = doc(&[("a", 3), ("b", 1)], &[]); let theirs = doc(&[("a", 1), ("b", 5)], &[]); let (merged, report) = merge3(&base, &ours, &theirs); assert_eq!(qty(&merged, "a"), Some(3)); assert_eq!(qty(&merged, "b"), Some(5)); assert!(report.conflicts.is_empty(), "these edits don't collide"); } #[test] fn the_same_card_changed_on_both_sides_takes_theirs_and_reports_it() { let base = doc(&[("a", 1)], &[]); let ours = doc(&[("a", 3)], &[]); let theirs = doc(&[("a", 9)], &[]); let (merged, report) = merge3(&base, &ours, &theirs); assert_eq!(qty(&merged, "a"), Some(9)); assert_eq!(report.conflicts.len(), 1, "the user must be told"); } #[test] fn preferring_theirs_converges_rather_than_ping_ponging() { // Both devices resolve the same clash, then exchange again. If the rule // were "prefer ours" they would swap values forever; this pins down // that a second exchange is a no-op. let base = doc(&[("a", 1)], &[]); let phone = doc(&[("a", 3)], &[]); let desktop = doc(&[("a", 9)], &[]); let (phone_after, _) = merge3(&base, &phone, &desktop); let (desktop_after, _) = merge3(&base, &desktop, &phone_after); assert_eq!(qty(&phone_after, "a"), qty(&desktop_after, "a")); let (phone_again, _) = merge3(&phone_after, &phone_after, &desktop_after); assert_eq!(phone_again, phone_after, "a second exchange must settle"); } #[test] fn merging_the_same_document_twice_changes_nothing() { let base = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); let ours = doc(&[("a", 3)], &[("u1", "Fub", &[("a", 2)])]); let theirs = doc(&[("a", 1), ("b", 4)], &[("u1", "Fub", &[("a", 1)])]); let (once, _) = merge3(&base, &ours, &theirs); let (twice, report) = merge3(&theirs, &once, &theirs); assert_eq!(once, twice, "merge must be idempotent"); assert!(report.is_empty(), "a re-merge should report no changes"); } #[test] fn a_deck_deleted_on_one_side_stays_deleted() { let base = doc(&[], &[("u1", "Fub", &[])]); let ours = doc(&[], &[("u1", "Fub", &[])]); let theirs = doc(&[], &[]); let (merged, report) = merge3(&base, &ours, &theirs); assert!(merged.decks.is_empty()); assert_eq!(report.decks_removed, 1); } #[test] fn a_deck_edited_on_one_side_survives_deletion_on_the_other() { // Losing edited work is worse than resurrecting a deck the user can // simply delete again. let base = doc(&[], &[("u1", "Fub", &[])]); let ours = doc(&[], &[("u1", "Renamed", &[])]); let theirs = doc(&[], &[]); let (merged, _) = merge3(&base, &ours, &theirs); assert_eq!(merged.decks.get("u1").map(|d| d.name.as_str()), Some("Renamed")); } #[test] fn a_deck_we_deleted_comes_back_if_they_edited_it() { // The mirror of the case above: the rule has to hold whichever side // did the deleting, or it would depend on who happens to sync first. let base = doc(&[], &[("u1", "Fub", &[])]); let ours = doc(&[], &[]); let theirs = doc(&[], &[("u1", "Fub", &[("a", 2)])]); let (merged, _) = merge3(&base, &ours, &theirs); let deck = merged.decks.get("u1").expect("their edit should win"); assert_eq!(deck.name, "Fub"); assert_eq!(deck.cards["a"].quantity, 2); } #[test] fn an_empty_base_never_deletes_anything() { // First sync, or after the working copy was rebuilt: with no ancestor, // nothing may be interpreted as a deletion. let base = LibraryDoc::default(); let ours = doc(&[("a", 1)], &[("u1", "Mine", &[])]); let theirs = doc(&[("b", 2)], &[("u2", "Theirs", &[])]); let (merged, report) = merge3(&base, &ours, &theirs); assert_eq!(qty(&merged, "a"), Some(1)); assert_eq!(qty(&merged, "b"), Some(2)); assert_eq!(merged.decks.len(), 2); assert_eq!(report.decks_removed, 0); } #[test] fn a_lost_publish_is_re_detected_on_the_next_sync() { // Base is the remote *as read*, so if our publish never landed the // remote is unchanged and our edits are still recognisably ours. let remote = doc(&[("a", 1)], &[]); let ours = doc(&[("a", 3)], &[]); let (published, _) = merge3(&remote, &ours, &remote); assert_eq!(qty(&published, "a"), Some(3)); // The publish is dropped; the remote still reads as it did before. let (retry, _) = merge3(&remote, &published, &remote); assert_eq!(qty(&retry, "a"), Some(3), "our edit must survive to be republished"); } #[test] fn deck_fields_merge_independently() { let mut base = doc(&[], &[("u1", "Fub", &[])]); let mut ours = base.clone(); let mut theirs = base.clone(); ours.decks.get_mut("u1").unwrap().name = "Renamed".into(); theirs.decks.get_mut("u1").unwrap().format = "Commander".into(); base.decks.get_mut("u1").unwrap().notes = String::new(); let (merged, report) = merge3(&base, &ours, &theirs); let deck = merged.decks.get("u1").unwrap(); assert_eq!(deck.name, "Renamed", "our rename survives"); assert_eq!(deck.format, "Commander", "their format change survives"); assert!(report.conflicts.is_empty()); } #[test] fn a_quantity_edit_and_a_printing_added_elsewhere_both_survive() { // Cards used to merge as one unit, so these two edits would have // collided and one would have been lost. let base = doc(&[("a", 2)], &[]); let ours = doc(&[("a", 4)], &[]); let mut theirs = base.clone(); theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1); let (merged, report) = merge3(&base, &ours, &theirs); let a = &merged.cards["a"]; assert_eq!(a.quantity, 4); assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1)); assert!(report.conflicts.is_empty(), "different fields, no conflict"); } #[test] fn printings_recorded_on_each_side_merge_side_by_side() { let base = doc(&[("a", 3)], &[]); let mut ours = base.clone(); let mut theirs = base.clone(); ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "nonfoil"), 1); theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 2); let (merged, report) = merge3(&base, &ours, &theirs); assert_eq!(merged.cards["a"].printings.len(), 2); assert!(report.conflicts.is_empty()); } #[test] fn merged_printings_are_clamped_the_same_way_on_every_device() { // Each side's own edit fits, but together they track 4 of 2 copies. let base = doc(&[("a", 3)], &[]); let mut ours = doc(&[("a", 2)], &[]); ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1); let mut theirs = base.clone(); theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 3); let (phone, _) = merge3(&base, &ours, &theirs); let (desktop, _) = merge3(&base, &theirs, &ours); assert_eq!(phone.cards["a"], desktop.cards["a"], "both devices must agree"); let a = &phone.cards["a"]; assert_eq!(a.quantity, 2); assert_eq!(a.printings.values().sum::(), 2); // Trimmed from the end of key order: p1 kept whole. assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1)); assert_eq!(a.printings.get(&printing_key("p2", "foil")), Some(&1)); } #[test] fn version_2_is_used_only_once_printing_data_exists() { let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); assert_eq!(d.required_version(), 1); d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().printing = Some("p1".into()); assert_eq!(d.required_version(), 2); let mut d = doc(&[("a", 1)], &[]); d.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1); assert_eq!(d.required_version(), 2); // A merge result is stamped with what it needs. let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default()); assert_eq!(merged.version, 2); // And this build reads it back, printing data intact. let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap(); assert_eq!(back, merged); } #[test] fn a_version_1_document_carries_no_printing_fields() { // Older builds must see exactly what they always saw. let d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); let text = d.to_json().unwrap(); assert!(text.contains("\"version\": 1")); assert!(!text.contains("printing")); } // --- document <-> database -------------------------------------------- fn catalog(dir: &Path, name: &str) -> Catalog { let reference = dir.join("reference.db"); if !reference.exists() { crate::db::open_reference_for_build(&reference).unwrap(); } Catalog::open(&dir.join(format!("{name}.db")), &reference).unwrap() } #[test] fn a_populated_library_round_trips_through_a_document() { let tmp = tempfile::tempdir().unwrap(); let c = catalog(tmp.path(), "lib"); let deck = c.create_deck("Fub", None).unwrap(); c.set_deck_format(deck.id, "Commander").unwrap(); c.set_deck_card(deck.id, "card-a", 2, false, false).unwrap(); c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap(); c.set_owned("card-a", 4).unwrap(); c.set_location("card-a", "Box 1").unwrap(); let before = read_doc(&c.conn).unwrap(); // Round-trip through JSON too, so the serde attributes are exercised. let text = before.to_json().unwrap(); let parsed = LibraryDoc::from_json(&text).unwrap(); assert_eq!(before, parsed); // Applying it to a *different*, empty library reproduces it exactly. let other = catalog(tmp.path(), "other"); apply_doc(&other.conn, &parsed).unwrap(); assert_eq!(read_doc(&other.conn).unwrap(), before); } #[test] fn printings_round_trip_through_a_document() { let tmp = tempfile::tempdir().unwrap(); let c = catalog(tmp.path(), "lib"); c.conn .execute_batch( "INSERT INTO ref.printings(scryfall_id, oracle_id, finishes) VALUES ('p1', 'card-a', 'nonfoil,foil'), ('p2', 'card-a', 'nonfoil');", ) .unwrap(); c.set_owned("card-a", 5).unwrap(); c.set_library_copy("card-a", "p1", "foil", 2).unwrap(); c.set_library_copy("card-a", "p2", "nonfoil", 1).unwrap(); let deck = c.create_deck("Fub", None).unwrap(); c.set_deck_card(deck.id, "card-a", 1, false, false).unwrap(); c.set_deck_card_printing(deck.id, "card-a", Some("p2")).unwrap(); let before = read_doc(&c.conn).unwrap(); assert_eq!(before.version, 2); assert_eq!(before.cards["card-a"].printings.len(), 2); let parsed = LibraryDoc::from_json(&before.to_json().unwrap()).unwrap(); let other = catalog(tmp.path(), "other"); apply_doc(&other.conn, &parsed).unwrap(); assert_eq!(read_doc(&other.conn).unwrap(), before); assert_eq!(other.list_library_copies("card-a").unwrap().len(), 2); // Applying a document without them clears them. let mut without = before.clone(); without.cards.get_mut("card-a").unwrap().printings.clear(); apply_doc(&other.conn, &without).unwrap(); assert!(other.list_library_copies("card-a").unwrap().is_empty()); } #[test] fn applying_a_document_removes_what_it_no_longer_contains() { let tmp = tempfile::tempdir().unwrap(); let c = catalog(tmp.path(), "lib"); let keep = c.create_deck("Keep", None).unwrap(); let drop = c.create_deck("Drop", None).unwrap(); c.set_deck_card(keep.id, "card-a", 1, false, false).unwrap(); c.set_deck_card(keep.id, "card-b", 1, false, false).unwrap(); c.set_owned("card-a", 3).unwrap(); let mut d = read_doc(&c.conn).unwrap(); let drop_uuid = d .decks .iter() .find(|(_, v)| v.name == "Drop") .map(|(k, _)| k.clone()) .unwrap(); d.decks.remove(&drop_uuid); let keep_uuid = d.decks.keys().next().unwrap().clone(); d.decks.get_mut(&keep_uuid).unwrap().cards.remove("card-b"); d.cards.remove("card-a"); apply_doc(&c.conn, &d).unwrap(); assert_eq!(c.list_decks().unwrap().len(), 1); assert_eq!(c.deck_contents(drop.id).unwrap().len(), 0); assert_eq!(read_doc(&c.conn).unwrap(), d); } #[test] fn every_deck_gets_a_uuid_whether_created_or_duplicated() { let tmp = tempfile::tempdir().unwrap(); let c = catalog(tmp.path(), "lib"); let a = c.create_deck("Fub", None).unwrap(); c.duplicate_deck(a.id, "Fub copy").unwrap(); let d = read_doc(&c.conn).unwrap(); assert_eq!(d.decks.len(), 2, "both decks must appear in the document"); assert!( d.decks.keys().all(|u| u.len() == 32), "uuids should be 16 random bytes as hex" ); } #[test] fn a_document_from_a_newer_app_version_is_refused() { let text = r#"{"version": 99, "cards": {}, "decks": {}}"#; let err = LibraryDoc::from_json(text).unwrap_err(); assert!( err.to_string().contains("newer version"), "should explain the real problem, got: {err}" ); } #[test] fn converts_a_pre_document_library_file() { // A library from before this format existed: schema v4, no uuids. let tmp = tempfile::tempdir().unwrap(); let legacy = tmp.path().join("Decks-v1.db"); { let c = catalog(tmp.path(), "Decks-v1"); let deck = c.create_deck("Old Deck", None).unwrap(); c.set_deck_format(deck.id, "Modern").unwrap(); c.set_deck_card(deck.id, "card-a", 3, false, false).unwrap(); c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap(); c.set_owned("card-a", 7).unwrap(); c.set_location("card-a", "Binder A").unwrap(); } let d = convert_legacy_library(&legacy).unwrap(); assert_eq!(d.cards["card-a"].quantity, 7); assert_eq!(d.cards["card-a"].location, "Binder A"); let deck = d.decks.values().next().expect("the deck should convert"); assert_eq!(deck.name, "Old Deck"); assert_eq!(deck.format, "Modern"); assert_eq!(deck.cards["card-a"].quantity, 3); assert!(deck.cards["card-b"].maybeboard); } }