Rework library sync around a JSON document; add card filters

Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
2026-08-07 22:03:49 -05:00
parent 663af32562
commit 04e9c3cbbb
21 changed files with 3370 additions and 100 deletions

View File

@@ -17,6 +17,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0002_location.sql"),
include_str!("sql/library/0003_deck_format.sql"),
include_str!("sql/library/0004_deck_maybeboard.sql"),
include_str!("sql/library/0005_deck_uuid.sql"),
];
/// The attached alias under which the reference db is available at runtime.
@@ -114,6 +115,19 @@ pub fn inspect_reference_db(path: &Path) -> Result<i64> {
Ok(count)
}
/// Open (and migrate) just the library db, with no reference db attached.
///
/// Sync needs this: it only ever touches the user's own tables, and must work
/// on a device that hasn't downloaded card data yet — where [`open`] can't be
/// used at all, since ATTACHing a non-existent reference file would create an
/// empty one and make every later query fail.
pub fn open_library(library_path: &Path) -> Result<Connection> {
let conn = Connection::open(library_path)?;
conn.pragma_update(None, "foreign_keys", true)?;
migrate_library(&conn)?;
Ok(conn)
}
/// Open the library db at `library_path`, run migrations, and ATTACH the
/// reference db at `reference_path` under the [`REF_DB`] alias.
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {

View File

@@ -10,8 +10,11 @@ use crate::Catalog;
impl Catalog {
/// Create a deck. `commander_oracle_id` is optional.
pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result<Deck> {
// Every deck needs a `uuid` from the moment it exists — it's the
// identity sync matches on across devices (see migration 0005).
self.conn.execute(
"INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)",
"INSERT INTO decks(name, commander_oracle_id, uuid)
VALUES (?1, ?2, lower(hex(randomblob(16))))",
params![name, commander_oracle_id],
)?;
let id = self.conn.last_insert_rowid();
@@ -63,9 +66,11 @@ impl Catalog {
let src = self
.get_deck(id)?
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {id} not found")))?;
// A duplicate is a genuinely new deck, so it gets its own identity
// rather than inheriting the source's.
self.conn.execute(
"INSERT INTO decks(name, format, commander_oracle_id, notes)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO decks(name, format, commander_oracle_id, notes, uuid)
VALUES (?1, ?2, ?3, ?4, lower(hex(randomblob(16))))",
params![new_name, src.format, src.commander_oracle_id, src.notes],
)?;
let new_id = self.conn.last_insert_rowid();

View File

@@ -13,7 +13,73 @@ use crate::Catalog;
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`,
/// which only make sense against the owned library. Caller has already
/// written the `FROM ... WHERE <ownership predicate>` prefix.
fn push_card_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
fn push_card_filters(
sql: &mut String,
vals: &mut Vec<Value>,
filter: &LibraryFilter,
has_universes_beyond: bool,
) {
// The four card-origin flags all arrived in the same reference-schema
// revision, so one capability check covers them. Skipped entirely against
// an older db — referencing a missing column would make every search fail
// rather than merely ignoring one filter.
if has_universes_beyond {
if filter.exclude_universes_beyond {
sql.push_str(" AND oc.universes_beyond = 0");
}
if filter.exclude_digital_only {
sql.push_str(" AND oc.digital_only = 0");
}
if filter.exclude_funny {
sql.push_str(" AND oc.funny = 0");
}
// NULL power/toughness means "not a plain number" (or not a creature);
// such cards drop out rather than being compared as 0.
for (col, bound, op) in [
("power", filter.power_min, ">="),
("power", filter.power_max, "<="),
("toughness", filter.toughness_min, ">="),
("toughness", filter.toughness_max, "<="),
] {
if let Some(v) = bound {
sql.push_str(&format!(" AND oc.{col} IS NOT NULL AND oc.{col} {op} ?"));
vals.push(Value::Real(v));
}
}
}
// Cheapest known printing. Cards with no price at all read as 0 here, the
// same as they display, so an "under $5" filter includes them.
for (bound, op) in [(filter.price_min, ">="), (filter.price_max, "<=")] {
if let Some(v) = bound {
sql.push_str(&format!(
" AND COALESCE({price}, 0) {op} ?",
price = crate::db::price_subquery()
));
vals.push(Value::Real(v));
}
}
if let Some(slug) = filter.legal_in.as_ref().filter(|s| !s.is_empty()) {
if let Some(i) = crate::model::LEGALITY_FORMATS
.iter()
.position(|(key, _)| key == slug)
{
// 2 bits per format; 1 = legal, 3 = restricted (playable as a
// single copy, so it counts). 0 = not legal, 2 = banned.
sql.push_str(&format!(
" AND ((oc.legalities >> {}) & 3) IN (1, 3)",
i * 2
));
}
}
if filter.can_be_commander {
// Legendary creatures, plus anything whose rules text grants it (some
// planeswalkers, Grist). `Legendary%Creature` rather than a prefix
// match so "Legendary Artifact Creature" is included.
sql.push_str(
" AND (oc.type_line LIKE 'Legendary%Creature%'
OR LOWER(oc.oracle_text) LIKE '%can be your commander%')",
);
}
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
sql.push_str(" AND oc.name_lower LIKE ?");
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
@@ -109,7 +175,7 @@ impl Catalog {
horizontal = self.horizontal_col()
);
let mut vals: Vec<Value> = Vec::new();
push_card_filters(&mut sql, &mut vals, filter);
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond);
if let Some(deck_id) = filter.in_deck {
sql.push_str(
@@ -169,7 +235,7 @@ impl Catalog {
horizontal = self.horizontal_col()
);
let mut vals: Vec<Value> = Vec::new();
push_card_filters(&mut sql, &mut vals, filter);
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond);
sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?");
vals.push(Value::Integer(limit as i64));
@@ -211,6 +277,154 @@ mod tests {
assert_eq!(all.len(), 5);
}
#[test]
fn excluding_universes_beyond_hides_flagged_cards_only_when_asked() {
let fx = owned_fixture();
// Stand in for what the ingest derives: a card that exists only as
// Universes Beyond.
fx.catalog
.conn
.execute(
"UPDATE ref.oracle_cards SET universes_beyond = 1 WHERE oracle_id = 'serra-angel'",
[],
)
.unwrap();
let f = LibraryFilter {
exclude_universes_beyond: true,
..Default::default()
};
let got = names(&fx.catalog.search_library(&f).unwrap());
assert!(!got.contains(&"Serra Angel".to_string()), "flagged card is hidden");
assert!(got.contains(&"Lightning Bolt".to_string()), "others are kept");
// Off unless asked for — this must not quietly change existing searches.
let all = names(&fx.catalog.search_library(&LibraryFilter::default()).unwrap());
assert!(all.contains(&"Serra Angel".to_string()));
}
#[test]
fn price_range_treats_an_unknown_price_as_free() {
let fx = owned_fixture();
// Fixture cards have no printings, so no known price at all.
let cheap = names(
&fx.catalog
.search_library(&LibraryFilter {
price_max: Some(5.0),
..Default::default()
})
.unwrap(),
);
assert!(
cheap.contains(&"Sol Ring".to_string()),
"priceless cards read as $0, so a max filter keeps them"
);
let pricey = fx
.catalog
.search_library(&LibraryFilter {
price_min: Some(1.0),
..Default::default()
})
.unwrap();
assert!(pricey.is_empty(), "and a min filter drops them");
}
#[test]
fn legality_filter_accepts_legal_and_restricted_but_not_banned() {
let fx = owned_fixture();
let conn = &fx.catalog.conn;
// commander is index 6 of LEGALITY_FORMATS, so bits 12-13.
let set = |oid: &str, status: i64| {
conn.execute(
"UPDATE ref.oracle_cards SET legalities = ?2 WHERE oracle_id = ?1",
rusqlite::params![oid, status << 12],
)
.unwrap();
};
set("llanowar-elves", 1); // legal
set("sol-ring", 3); // restricted — still playable as one copy
set("lightning-bolt", 2); // banned
set("serra-angel", 0); // not legal
let got = names(
&fx.catalog
.search_library(&LibraryFilter {
legal_in: Some("commander".into()),
..Default::default()
})
.unwrap(),
);
assert!(got.contains(&"Llanowar Elves".to_string()));
assert!(got.contains(&"Sol Ring".to_string()));
assert!(!got.contains(&"Lightning Bolt".to_string()), "banned is excluded");
assert!(!got.contains(&"Serra Angel".to_string()), "not-legal is excluded");
}
#[test]
fn commander_filter_matches_legendary_creatures_and_granted_text() {
let fx = owned_fixture();
let conn = &fx.catalog.conn;
conn.execute(
"UPDATE ref.oracle_cards SET type_line = 'Legendary Artifact Creature — Golem'
WHERE oracle_id = 'sol-ring'",
[],
)
.unwrap();
conn.execute(
"UPDATE ref.oracle_cards SET oracle_text = 'Grist can be your commander.'
WHERE oracle_id = 'lightning-bolt'",
[],
)
.unwrap();
let got = names(
&fx.catalog
.search_library(&LibraryFilter {
can_be_commander: true,
..Default::default()
})
.unwrap(),
);
assert!(
got.contains(&"Sol Ring".to_string()),
"\"Legendary Artifact Creature\" must count, not just a prefix match"
);
assert!(got.contains(&"Lightning Bolt".to_string()), "granted by rules text");
assert!(!got.contains(&"Serra Angel".to_string()), "plain creature is not");
}
#[test]
fn a_power_range_drops_cards_whose_power_isnt_a_number() {
let fx = owned_fixture();
let conn = &fx.catalog.conn;
conn.execute(
"UPDATE ref.oracle_cards SET power = 6 WHERE oracle_id = 'serra-angel'",
[],
)
.unwrap();
// A Tarmogoyf-alike: printed "*", so stored NULL.
conn.execute(
"UPDATE ref.oracle_cards SET power = NULL WHERE oracle_id = 'llanowar-elves'",
[],
)
.unwrap();
let got = names(
&fx.catalog
.search_library(&LibraryFilter {
power_min: Some(5.0),
..Default::default()
})
.unwrap(),
);
assert_eq!(
got,
vec!["Serra Angel".to_string()],
"a '*' power can't be compared, so it must not be treated as 0 or matched"
);
}
#[test]
fn commander_identity_subset() {
let fx = owned_fixture();

View File

@@ -456,6 +456,12 @@ struct ScryOracle {
/// some cards. Kept nullable — see `synergy::CardSuggestion::edhrec_rank`.
#[serde(default)]
edhrec_rank: Option<i64>,
/// Printed power/toughness. Strings because Magic allows non-numeric
/// values ("*", "1+*", "X"); those parse to None and stay unfilterable.
#[serde(default)]
power: Option<String>,
#[serde(default)]
toughness: Option<String>,
}
#[derive(Deserialize)]
@@ -463,6 +469,10 @@ struct ScryFace {
#[serde(default)]
type_line: Option<String>,
#[serde(default)]
power: Option<String>,
#[serde(default)]
toughness: Option<String>,
#[serde(default)]
colors: Option<Vec<String>>,
#[serde(default)]
oracle_text: Option<String>,
@@ -470,6 +480,16 @@ struct ScryFace {
image_uris: Option<ImageUris>,
}
/// Parse a printed power/toughness into a number, or `None` when it isn't one.
///
/// Magic prints values like `*`, `1+*` and `X` whose real value depends on game
/// state. Coercing those to 0 would quietly file every Tarmogoyf under "power
/// 0"; leaving them NULL drops them from range filters instead, which is the
/// honest answer to "power >= 5".
fn numeric_pt(raw: Option<&str>) -> Option<f64> {
raw?.trim().parse::<f64>().ok()
}
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
conn.execute_batch("BEGIN")?;
let mut count = 0usize;
@@ -478,8 +498,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
"INSERT OR IGNORE INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal,
image_uri,back_image_uri,default_scryfall_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
image_uri,back_image_uri,default_scryfall_id,power,toughness)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
@@ -601,6 +621,15 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
.and_then(|i| i.normal.clone())
.unwrap_or_default();
let default_scryfall_id = c.id.clone().unwrap_or_default();
// Fall back to the front face: a transforming creature carries its
// P/T per-face rather than at the top level.
let front = c.card_faces.as_ref().and_then(|f| f.first());
let power = numeric_pt(
c.power.as_deref().or_else(|| front.and_then(|f| f.power.as_deref())),
);
let toughness = numeric_pt(
c.toughness.as_deref().or_else(|| front.and_then(|f| f.toughness.as_deref())),
);
let changed = ins_card.execute(params![
oracle_id,
@@ -620,6 +649,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
image_uri,
back_image_uri,
default_scryfall_id,
power,
toughness,
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
@@ -690,6 +721,19 @@ struct ScryPrinting {
card_faces: Option<Vec<PrintFace>>,
#[serde(default)]
prices: Option<ScryPrices>,
/// Scryfall's per-printing markers. Carries `"universesbeyond"` on cards
/// from another IP — the only field that reliably identifies them; there is
/// no Universes Beyond `set_type`, and the security stamp is absent at low
/// rarities.
#[serde(default)]
promo_types: Vec<String>,
/// Where this printing can be played. Missing "paper" means it exists only
/// on Arena/MTGO and can never be part of a physical collection.
#[serde(default)]
games: Vec<String>,
/// Scryfall's set category; `"funny"` covers the Un-sets.
#[serde(default)]
set_type: String,
}
#[derive(Deserialize)]
@@ -716,8 +760,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
{
let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
)?;
stream_jsonl::<_, ScryPrinting, _>(reader, |p| {
if cancel.is_cancelled() {
@@ -754,6 +798,11 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
.and_then(|pr| pr.usd.as_ref())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let universes_beyond = p.promo_types.iter().any(|t| t == "universesbeyond");
// An empty `games` (some tokens/oddities) is treated as paper —
// better to leave a card visible than to hide a real one.
let digital = !p.games.is_empty() && !p.games.iter().any(|g| g == "paper");
let funny = p.set_type == "funny";
ins.execute(params![
p.id,
oracle_id,
@@ -764,11 +813,32 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
image,
back_image,
price_usd,
universes_beyond as i64,
digital as i64,
funny as i64,
])?;
count += 1;
Ok(())
})?;
}
// Roll the per-printing flags up to the card. `MIN(...) = 1` means *every*
// printing carries the flag, which is what distinguishes a card that only
// exists as another IP's character (Frodo) from an ordinary card that
// happens to have been reprinted into such a set (Lightning Bolt appears in
// Marvel Commander). Using "any printing" instead would hide staples — and
// the same reasoning applies to digital and funny printings, since paper
// cards do get Arena versions and Un-set reprints.
conn.execute_batch(
"UPDATE oracle_cards SET
universes_beyond = COALESCE((SELECT MIN(p.universes_beyond) FROM printings p
WHERE p.oracle_id = oracle_cards.oracle_id), 0),
digital_only = COALESCE((SELECT MIN(p.digital) FROM printings p
WHERE p.oracle_id = oracle_cards.oracle_id), 0),
funny = COALESCE((SELECT MIN(p.funny) FROM printings p
WHERE p.oracle_id = oracle_cards.oracle_id), 0)
WHERE EXISTS (SELECT 1 FROM printings p WHERE p.oracle_id = oracle_cards.oracle_id)",
)?;
conn.execute_batch("COMMIT")?;
Ok(count)
}
@@ -904,6 +974,56 @@ mod tests {
assert!(printings_w > oracle_w);
}
#[test]
fn only_cards_whose_every_printing_is_universes_beyond_are_flagged() {
// The distinction that matters: Frodo exists only as Lord of the Rings,
// while Lightning Bolt is an ordinary card that happens to have been
// reprinted into a Universes Beyond set. Flagging on "any printing is
// UB" would hide staples from anyone filtering them out.
let oracle = r#"[
{"oracle_id":"oid-frodo","name":"Frodo, Adventurous Hobbit","cmc":2.0,
"colors":["W"],"color_identity":["W"],"type_line":"Legendary Creature — Halfling Scout","keywords":[]},
{"oracle_id":"oid-bolt","name":"Lightning Bolt","cmc":1.0,
"colors":["R"],"color_identity":["R"],"type_line":"Instant","keywords":[]},
{"oracle_id":"oid-plain","name":"Serra Angel","cmc":5.0,
"colors":["W"],"color_identity":["W"],"type_line":"Creature — Angel","keywords":[]}
]"#;
let printings = r#"[
{"id":"p-frodo-1","oracle_id":"oid-frodo","set":"ltr","collector_number":"1",
"rarity":"rare","promo_types":["universesbeyond"]},
{"id":"p-frodo-2","oracle_id":"oid-frodo","set":"ltc","collector_number":"2",
"rarity":"rare","promo_types":["surgefoil","universesbeyond"]},
{"id":"p-bolt-1","oracle_id":"oid-bolt","set":"lea","collector_number":"161",
"rarity":"common"},
{"id":"p-bolt-2","oracle_id":"oid-bolt","set":"msc","collector_number":"99",
"rarity":"rare","promo_types":["universesbeyond"]},
{"id":"p-plain-1","oracle_id":"oid-plain","set":"m19","collector_number":"34",
"rarity":"uncommon"}
]"#;
let dir = tempfile::tempdir().unwrap();
let conn = open_reference_for_build(&dir.path().join("reference.db")).unwrap();
load_oracle_cards(&conn, jsonl(oracle).as_bytes(), &CancelToken::new()).unwrap();
load_printings(&conn, jsonl(printings).as_bytes(), &CancelToken::new()).unwrap();
let flagged = |oid: &str| -> i64 {
conn.query_row(
"SELECT universes_beyond FROM oracle_cards WHERE oracle_id = ?1",
[oid],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(flagged("oid-frodo"), 1, "every printing is UB");
assert_eq!(
flagged("oid-bolt"),
0,
"a normal card reprinted into a UB set must not be flagged"
);
assert_eq!(flagged("oid-plain"), 0, "no UB printings at all");
}
#[test]
fn oracle_and_printing_load_from_fixture_json() {
// Minimal Scryfall-shaped fixtures exercising the insert paths.

View File

@@ -14,6 +14,7 @@ pub mod ingest;
pub mod library;
pub mod model;
pub mod search;
pub mod sync;
pub mod synergy;
use std::path::Path;
@@ -43,6 +44,10 @@ pub struct Catalog {
/// `default_scryfall_id` siblings) exist in the attached reference db.
/// Cached at open time — see [`Catalog::image_expr`].
pub(crate) has_oracle_images: bool,
/// Whether `oracle_cards.universes_beyond` exists in the attached reference
/// db. Cached at open time so the filter can be skipped (rather than
/// failing every query) against a db built before it shipped.
pub(crate) has_universes_beyond: bool,
}
impl Catalog {
@@ -52,7 +57,14 @@ impl Catalog {
let conn = db::open(library_path, reference_path)?;
let has_horizontal_column = db::has_reference_column(&conn, "oracle_cards", "horizontal")?;
let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?;
Ok(Self { conn, has_horizontal_column, has_oracle_images })
let has_universes_beyond =
db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?;
Ok(Self {
conn,
has_horizontal_column,
has_oracle_images,
has_universes_beyond,
})
}
/// SQL expression for `oracle_cards.horizontal`, for use in a `SELECT`

View File

@@ -287,6 +287,40 @@ pub struct LibraryFilter {
pub location: Option<String>,
/// Case-insensitive substring match against the card's oracle rules text.
pub oracle_text: Option<String>,
/// Hide cards that exist only as Universes Beyond (Lord of the Rings,
/// Warhammer 40,000, Marvel, ...). Ordinary cards reprinted into a UB set
/// are kept — see `oracle_cards.universes_beyond`.
///
/// Silently inert against a `reference.db` built before that column
/// existed, the same way the other reference-schema additions degrade.
#[serde(default)]
pub exclude_universes_beyond: bool,
/// Hide cards that exist only on Arena/MTGO (Alchemy rebalances and the
/// like) and so can never be in a physical collection.
#[serde(default)]
pub exclude_digital_only: bool,
/// Hide cards that exist only in "funny" sets (Unglued, Unhinged, Unstable,
/// Unfinity).
#[serde(default)]
pub exclude_funny: bool,
/// Cheapest known printing, in USD. Cards with no known price count as 0,
/// matching how they're displayed.
pub price_min: Option<f64>,
pub price_max: Option<f64>,
/// Only cards legal in this format (a [`LEGALITY_FORMATS`] slug, e.g.
/// "commander"). Excludes banned and not-legal; restricted still counts as
/// legal, since a restricted card is playable as a single copy.
pub legal_in: Option<String>,
/// Only cards that can head a Commander deck.
#[serde(default)]
pub can_be_commander: bool,
/// Printed power/toughness range. Cards whose printed value isn't a plain
/// number ("*", "1+*") are excluded from these, since they can't be
/// compared — see `oracle_cards.power`.
pub power_min: Option<f64>,
pub power_max: Option<f64>,
pub toughness_min: Option<f64>,
pub toughness_max: Option<f64>,
}
#[cfg(test)]

View File

@@ -0,0 +1,21 @@
-- v5 — a stable, device-independent identity for decks.
--
-- Sync merges two copies of the library by matching entities across them.
-- `library_cards` and `deck_cards` already have global keys (`oracle_id`, and
-- `(deck, oracle_id)`), but `decks.id` is per-device autoincrement: two devices
-- both hand out id 3 to different decks. Matching on `name` instead would work
-- until someone renames a deck, at which point the merge sees a delete plus an
-- unrelated create and the deck's contents are stranded.
--
-- This is the *only* schema sync needs. Change tracking is not stored here:
-- the merge derives what changed (and what was deleted) by comparing against
-- the last-synced document, so there are no timestamp columns, no tombstones,
-- and no triggers.
ALTER TABLE decks ADD COLUMN uuid TEXT;
-- Existing decks predate the column. `randomblob` is SQLite's own CSPRNG, so
-- this needs no help from Rust and no new dependency.
UPDATE decks SET uuid = lower(hex(randomblob(16))) WHERE uuid IS NULL;
CREATE UNIQUE INDEX idx_decks_uuid ON decks(uuid);

View File

@@ -24,7 +24,27 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
-- absent (falls back to `printings`).
image_uri TEXT NOT NULL DEFAULT '',
back_image_uri TEXT NOT NULL DEFAULT '',
default_scryfall_id TEXT NOT NULL DEFAULT ''
default_scryfall_id TEXT NOT NULL DEFAULT '',
-- Boolean: this card exists *only* as Universes Beyond (Lord of the Rings,
-- Warhammer 40,000, Marvel, ...). Derived after the printings pass from
-- `printings.universes_beyond`, deliberately as "every printing is UB"
-- rather than "some printing is UB" — ordinary cards get reprinted into UB
-- sets (Lightning Bolt is in Marvel Commander) and must not be caught by
-- a filter meant to exclude other IPs' characters.
universes_beyond INTEGER NOT NULL DEFAULT 0,
-- Boolean: exists only on Arena/MTGO (Alchemy rebalances and the like), so
-- it can never be part of a physical collection. Same every-printing
-- rollup as `universes_beyond`.
digital_only INTEGER NOT NULL DEFAULT 0,
-- Boolean: exists only in "funny" sets — Unglued, Unhinged, Unstable,
-- Unfinity. Not legal in any normal format.
funny INTEGER NOT NULL DEFAULT 0,
-- Printed power/toughness, NULL when the card isn't a creature *or* when
-- the value isn't a plain number ("*", "1+*", "X"). NULL therefore means
-- "can't be compared numerically", and such cards drop out of a
-- power/toughness range filter rather than being coerced to 0.
power REAL,
toughness REAL
);
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
@@ -62,7 +82,13 @@ CREATE TABLE IF NOT EXISTS printings (
rarity TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT '',
back_image_uri TEXT NOT NULL DEFAULT '', -- transform/MDFC/reversible back face
price_usd REAL NOT NULL DEFAULT 0 -- Scryfall "prices.usd", 0 if unknown
price_usd REAL NOT NULL DEFAULT 0, -- Scryfall "prices.usd", 0 if unknown
-- Boolean: Scryfall lists "universesbeyond" in this printing's promo_types.
universes_beyond INTEGER NOT NULL DEFAULT 0,
-- Boolean: this printing isn't available on paper (Scryfall `games`).
digital INTEGER NOT NULL DEFAULT 0,
-- Boolean: this printing comes from a "funny" set (Scryfall `set_type`).
funny INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);

View File

@@ -0,0 +1,789 @@
//! The library as a portable JSON document, and the merge that reconciles two
//! copies of it.
//!
//! The document is what the user actually keeps: it lives in whatever folder
//! they sync, and it is the only file they ever see. The SQLite database is an
//! internal working copy that can always be rebuilt by merging a document into
//! an empty one — which is why "first run", "cache was cleared", and "the
//! document changed elsewhere" 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 read**, never the
//! result we last published. If a publish is lost — the sync tool dropped it,
//! the process died, the drive was unplugged — the remote is still what we last
//! read, so the next merge sees our edits as ours and republishes them.
//! Recording the merged 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.
//!
//! # 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;
/// Bumped only for a breaking change to the document shape. Readers reject
/// anything newer rather than silently misreading it.
pub const DOC_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,
}
/// 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,
}
/// 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.
///
/// `library_card_copies` is deliberately absent — that table is unused by any
/// code in the project.
#[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: DOC_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()
}
}
/// 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_entry(b, o, t, &mut || {
report.conflicts.push(format!("owned quantity for card {id}"));
}) {
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, 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())
}
}
}
}
}
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)?,
},
);
}
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 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,
},
);
}
}
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],
)?;
}
// 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)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity,
is_commander = excluded.is_commander,
is_maybeboard = excluded.is_maybeboard",
rusqlite::params![
deck_id,
oracle_id,
card.quantity,
card.commander as i64,
card.maybeboard as i64
],
)?;
}
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(())
}
/// 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, location: String::new() },
);
}
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());
}
// --- 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 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);
}
}