New Catalog::rename_deck / duplicate_deck (duplicating format, commander, notes, and every deck_cards row under a new name), with tests covering the UNIQUE(name) collision cases the UI needs to surface as friendly errors rather than raw SQL messages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
373 lines
14 KiB
Rust
373 lines
14 KiB
Rust
//! Deck operations and deck membership lookups.
|
|
|
|
use rusqlite::{params, OptionalExtension};
|
|
|
|
use crate::db::REF_DB;
|
|
use crate::error::Result;
|
|
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
|
|
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> {
|
|
self.conn.execute(
|
|
"INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)",
|
|
params![name, commander_oracle_id],
|
|
)?;
|
|
let id = self.conn.last_insert_rowid();
|
|
self.get_deck(id)
|
|
.map(|d| d.expect("just-inserted deck must exist"))
|
|
}
|
|
|
|
pub fn get_deck(&self, id: i64) -> Result<Option<Deck>> {
|
|
Ok(self
|
|
.conn
|
|
.query_row(
|
|
"SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1",
|
|
params![id],
|
|
Self::map_deck,
|
|
)
|
|
.optional()?)
|
|
}
|
|
|
|
pub fn list_decks(&self) -> Result<Vec<Deck>> {
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT id, name, format, commander_oracle_id, notes
|
|
FROM decks ORDER BY name COLLATE NOCASE",
|
|
)?;
|
|
let rows = stmt
|
|
.query_map([], Self::map_deck)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
pub fn delete_deck(&self, id: i64) -> Result<()> {
|
|
// ON DELETE CASCADE clears deck_cards.
|
|
self.conn
|
|
.execute("DELETE FROM decks WHERE id = ?1", params![id])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Rename a deck. Fails if another deck already has that name (`name` is
|
|
/// UNIQUE).
|
|
pub fn rename_deck(&self, id: i64, name: &str) -> Result<()> {
|
|
self.conn
|
|
.execute("UPDATE decks SET name = ?1 WHERE id = ?2", params![name, id])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Duplicate a deck under a new name: same format/commander/notes, plus a
|
|
/// copy of every deck_cards row (quantity + commander flag). Fails if
|
|
/// `new_name` is already taken.
|
|
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
|
|
let src = self
|
|
.get_deck(id)?
|
|
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {id} not found")))?;
|
|
self.conn.execute(
|
|
"INSERT INTO decks(name, format, commander_oracle_id, notes)
|
|
VALUES (?1, ?2, ?3, ?4)",
|
|
params![new_name, src.format, src.commander_oracle_id, src.notes],
|
|
)?;
|
|
let new_id = self.conn.last_insert_rowid();
|
|
self.conn.execute(
|
|
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
|
|
SELECT ?1, oracle_id, quantity, is_commander FROM deck_cards WHERE deck_id = ?2",
|
|
params![new_id, id],
|
|
)?;
|
|
self.get_deck(new_id)
|
|
.map(|d| d.expect("just-inserted deck must exist"))
|
|
}
|
|
|
|
/// Set how many copies of a card belong to a deck. `quantity <= 0` removes
|
|
/// the card from the deck. Upserts on (deck_id, oracle_id).
|
|
pub fn set_deck_card(
|
|
&self,
|
|
deck_id: i64,
|
|
oracle_id: &str,
|
|
quantity: i64,
|
|
is_commander: bool,
|
|
) -> Result<()> {
|
|
if quantity <= 0 {
|
|
self.conn.execute(
|
|
"DELETE FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2",
|
|
params![deck_id, oracle_id],
|
|
)?;
|
|
return Ok(());
|
|
}
|
|
self.conn.execute(
|
|
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
|
|
VALUES (?1, ?2, ?3, ?4)
|
|
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
|
quantity = excluded.quantity,
|
|
is_commander = excluded.is_commander",
|
|
params![deck_id, oracle_id, quantity, is_commander as i64],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// All cards in a deck — owned or not — with their in-deck quantity and how
|
|
/// many copies are owned (0 if the card isn't in the library). Callers split
|
|
/// this into "in your library" vs "not owned" groups.
|
|
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
|
|
let mut stmt = self.conn.prepare(&format!(
|
|
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
|
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
|
oc.type_line, {image}, {back}
|
|
FROM deck_cards dc
|
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
|
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
|
WHERE dc.deck_id = ?1
|
|
ORDER BY oc.name COLLATE NOCASE",
|
|
image = crate::db::image_subquery("image_uri"),
|
|
back = crate::db::image_subquery("back_image_uri")
|
|
))?;
|
|
let rows = stmt
|
|
.query_map(params![deck_id], |r| {
|
|
Ok(DeckEntry {
|
|
quantity: r.get(0)?,
|
|
is_commander: r.get::<_, i64>(1)? != 0,
|
|
quantity_owned: r.get(2)?,
|
|
card: CardRef {
|
|
oracle_id: r.get(3)?,
|
|
name: r.get(4)?,
|
|
mana_value: r.get(5)?,
|
|
colors: ColorSet(r.get::<_, i64>(6)? as u8),
|
|
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
|
|
type_line: r.get(8)?,
|
|
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
|
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
|
},
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// The decks a given card is currently assigned to.
|
|
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes
|
|
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
|
|
WHERE dc.oracle_id = ?1
|
|
ORDER BY d.name COLLATE NOCASE",
|
|
)?;
|
|
let rows = stmt
|
|
.query_map(params![oracle_id], Self::map_deck)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
fn map_deck(r: &rusqlite::Row) -> rusqlite::Result<Deck> {
|
|
Ok(Deck {
|
|
id: r.get(0)?,
|
|
name: r.get(1)?,
|
|
format: r.get(2)?,
|
|
commander_oracle_id: r.get(3)?,
|
|
notes: r.get(4)?,
|
|
})
|
|
}
|
|
|
|
/// Set the deck's format (e.g. "Commander"). Empty string clears it.
|
|
pub fn set_deck_format(&self, deck_id: i64, format: &str) -> Result<()> {
|
|
self.conn.execute(
|
|
"UPDATE decks SET format = ?1 WHERE id = ?2",
|
|
params![format, deck_id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
|
|
/// the corresponding deck_cards row as the commander when present.
|
|
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
|
self.conn.execute(
|
|
"UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2",
|
|
params![oracle_id, deck_id],
|
|
)?;
|
|
// Reset any existing commander flag, then set it on the chosen card.
|
|
self.conn.execute(
|
|
"UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1",
|
|
params![deck_id],
|
|
)?;
|
|
if let Some(oid) = oracle_id {
|
|
self.conn.execute(
|
|
"UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2",
|
|
params![deck_id, oid],
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::test_support;
|
|
|
|
#[test]
|
|
fn deck_membership_visible_from_library() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
|
|
cat.add_owned("llanowar-elves", 4).unwrap();
|
|
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
|
|
cat.set_deck_card(elves.id, "llanowar-elves", 4, false)
|
|
.unwrap();
|
|
|
|
// card_decks reports membership.
|
|
let decks = cat.card_decks("llanowar-elves").unwrap();
|
|
assert_eq!(decks.len(), 1);
|
|
assert_eq!(decks[0].name, "Elfball");
|
|
|
|
// list_library surfaces the deck name on the card row.
|
|
let lib = cat.list_library().unwrap();
|
|
let row = lib
|
|
.iter()
|
|
.find(|c| c.card.oracle_id == "llanowar-elves")
|
|
.unwrap();
|
|
assert_eq!(row.decks, vec!["Elfball".to_string()]);
|
|
assert_eq!(row.quantity_assigned, 4);
|
|
assert!(!row.is_over_assigned());
|
|
}
|
|
|
|
#[test]
|
|
fn over_assignment_detected_across_decks() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
cat.add_owned("sol-ring", 1).unwrap(); // own one physical copy
|
|
|
|
let a = cat.create_deck("Deck A", None).unwrap();
|
|
let b = cat.create_deck("Deck B", None).unwrap();
|
|
cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap();
|
|
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap();
|
|
|
|
let lib = cat.list_library().unwrap();
|
|
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
|
assert_eq!(sol.quantity_assigned, 2);
|
|
assert_eq!(sol.quantity_owned, 1);
|
|
assert!(sol.is_over_assigned());
|
|
assert_eq!(sol.decks.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn format_and_commander_metadata() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
let d = cat.create_deck("EDH", None).unwrap();
|
|
assert_eq!(d.format, "");
|
|
|
|
cat.set_deck_format(d.id, "Commander").unwrap();
|
|
cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap();
|
|
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
|
|
|
|
let d = cat.get_deck(d.id).unwrap().unwrap();
|
|
assert_eq!(d.format, "Commander");
|
|
assert_eq!(d.commander_oracle_id.as_deref(), Some("llanowar-elves"));
|
|
|
|
// The card is flagged as commander in the deck.
|
|
let commander = cat
|
|
.deck_contents(d.id)
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|e| e.card.oracle_id == "llanowar-elves")
|
|
.unwrap();
|
|
assert!(commander.is_commander);
|
|
|
|
// Clearing works.
|
|
cat.set_deck_commander(d.id, None).unwrap();
|
|
assert_eq!(
|
|
cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deck_contents_include_owned_and_unowned() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
cat.add_owned("llanowar-elves", 4).unwrap(); // owned
|
|
// serra-angel intentionally NOT owned.
|
|
|
|
let d = cat.create_deck("Mixed", None).unwrap();
|
|
cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap();
|
|
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap();
|
|
|
|
let contents = cat.deck_contents(d.id).unwrap();
|
|
assert_eq!(contents.len(), 2);
|
|
|
|
let owned: Vec<_> = contents.iter().filter(|e| e.quantity_owned > 0).collect();
|
|
let unowned: Vec<_> = contents.iter().filter(|e| e.quantity_owned == 0).collect();
|
|
assert_eq!(owned.len(), 1);
|
|
assert_eq!(owned[0].card.name, "Llanowar Elves");
|
|
assert_eq!(owned[0].quantity, 4);
|
|
assert_eq!(unowned.len(), 1);
|
|
assert_eq!(unowned[0].card.name, "Serra Angel");
|
|
assert!(unowned[0].is_commander);
|
|
}
|
|
|
|
#[test]
|
|
fn setting_zero_removes_and_cascade_on_delete() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
let d = cat.create_deck("Temp", None).unwrap();
|
|
cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap();
|
|
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap();
|
|
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
|
|
|
|
cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap();
|
|
cat.delete_deck(d.id).unwrap();
|
|
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn rename_deck_updates_name_and_rejects_duplicates() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
let a = cat.create_deck("Deck A", None).unwrap();
|
|
cat.create_deck("Deck B", None).unwrap();
|
|
|
|
cat.rename_deck(a.id, "Deck A Renamed").unwrap();
|
|
assert_eq!(cat.get_deck(a.id).unwrap().unwrap().name, "Deck A Renamed");
|
|
|
|
// decks.name is UNIQUE.
|
|
assert!(cat.rename_deck(a.id, "Deck B").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_deck_copies_metadata_and_cards() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
cat.add_owned("llanowar-elves", 2).unwrap();
|
|
|
|
let src = cat.create_deck("Original", Some("llanowar-elves")).unwrap();
|
|
cat.set_deck_format(src.id, "Commander").unwrap();
|
|
cat.set_deck_card(src.id, "llanowar-elves", 2, true).unwrap();
|
|
cat.set_deck_card(src.id, "lightning-bolt", 1, false).unwrap();
|
|
|
|
let copy = cat.duplicate_deck(src.id, "Original (copy)").unwrap();
|
|
assert_eq!(copy.name, "Original (copy)");
|
|
assert_eq!(copy.format, "Commander");
|
|
assert_eq!(copy.commander_oracle_id.as_deref(), Some("llanowar-elves"));
|
|
|
|
let mut contents: Vec<_> = cat
|
|
.deck_contents(copy.id)
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|e| (e.card.oracle_id, e.quantity, e.is_commander))
|
|
.collect();
|
|
contents.sort_by(|a, b| a.0.cmp(&b.0));
|
|
assert_eq!(
|
|
contents,
|
|
vec![
|
|
("lightning-bolt".to_string(), 1, false),
|
|
("llanowar-elves".to_string(), 2, true),
|
|
]
|
|
);
|
|
|
|
// The original deck is untouched.
|
|
let original_contents = cat.deck_contents(src.id).unwrap();
|
|
assert_eq!(original_contents.len(), 2);
|
|
|
|
// Duplicating into an already-taken name fails (UNIQUE).
|
|
assert!(cat.duplicate_deck(src.id, "Original").is_err());
|
|
}
|
|
}
|