Files
mtgc/crates/mtgc-core/src/sync.rs
Garrett Mills 214a535588 Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.

A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.

UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.

Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.

Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.

reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00

1070 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, i64>,
}
/// 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<String>,
}
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
/// 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<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
#[serde(default)]
pub cards: BTreeMap<String, DeckCardEntry>,
}
/// 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<String, CardEntry>,
#[serde(default)]
pub decks: BTreeMap<String, DeckDoc>,
}
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<String> {
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<Self> {
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<String>,
}
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<V>(
base: &BTreeMap<String, V>,
ours: &BTreeMap<String, V>,
theirs: &BTreeMap<String, V>,
) -> BTreeSet<String> {
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<T: Clone + PartialEq>(
base: Option<&T>,
ours: Option<&T>,
theirs: Option<&T>,
on_conflict: &mut dyn FnMut(),
) -> Option<T> {
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<CardEntry> {
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::<i64>() - 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<T: Clone + PartialEq>(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<LibraryDoc> {
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<i64, String> = 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<Item = &'a String>) -> Result<String> {
Ok(serde_json::to_string(&keys.collect::<Vec<_>>())?)
}
/// 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<LibraryDoc> {
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<i64> {
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::<i64>(), 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);
}
}