tcg_db 0.1.0

Pokemon TCG game engine database and card metadata
Documentation
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};

use rusqlite::{params, Connection};
use serde_json::Value;
use thiserror::Error;

use crate::{validate_deck, DeckEntry, DeckList, DeckValidationError};

#[derive(Debug, Error)]
pub enum DeckImportError {
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("invalid deck json: {0}")]
    InvalidDeck(String),
    #[error("deck validation error: {0}")]
    Validation(#[from] DeckValidationError),
    #[error("database error: {0}")]
    Database(#[from] rusqlite::Error),
}

pub fn import_deck_json(conn: &mut Connection, path: &str) -> Result<String, DeckImportError> {
    let content = fs::read_to_string(path)?;
    let deck_json: Value = serde_json::from_str(&content)?;
    let deck = decklist_from_json(&deck_json)?;
    validate_deck(conn, &deck)?;

    let name = deck_json
        .get("name")
        .and_then(Value::as_str)
        .unwrap_or("Imported Deck");
    let format = deck_json
        .get("format")
        .and_then(Value::as_str)
        .unwrap_or("Unknown");
    let deck_id = deck_json
        .get("deck_id")
        .and_then(Value::as_str)
        .map(|id| id.to_string())
        .unwrap_or_else(|| slugify(name));
    let created_at = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string());

    let tx = conn.transaction()?;
    tx.execute(
        "INSERT OR REPLACE INTO decks (deck_id, name, format, created_at) VALUES (?1, ?2, ?3, ?4)",
        params![deck_id, name, format, created_at],
    )?;
    tx.execute("DELETE FROM deck_cards WHERE deck_id = ?1", params![deck_id])?;
    for entry in deck.entries {
        tx.execute(
            "INSERT INTO deck_cards (deck_id, card_def_id, count) VALUES (?1, ?2, ?3)",
            params![deck_id, entry.card_def_id, entry.count],
        )?;
    }
    tx.commit()?;

    Ok(deck_id)
}

#[allow(dead_code)]
pub fn load_decklist(conn: &Connection, deck_id: &str) -> Result<DeckList, DeckImportError> {
    let mut stmt = conn.prepare(
        "SELECT card_def_id, count FROM deck_cards WHERE deck_id = ?1 ORDER BY card_def_id",
    )?;
    let rows = stmt.query_map(params![deck_id], |row| {
        Ok(DeckEntry {
            card_def_id: row.get(0)?,
            count: row.get::<_, i64>(1)? as u32,
        })
    })?;
    let mut entries = Vec::new();
    for row in rows {
        entries.push(row?);
    }
    if entries.is_empty() {
        return Err(DeckImportError::InvalidDeck(format!(
            "no cards found for deck_id {deck_id}"
        )));
    }
    Ok(DeckList { entries })
}

fn decklist_from_json(deck_json: &Value) -> Result<DeckList, DeckImportError> {
    let mut entries = Vec::new();
    if let Some(cards) = deck_json.get("cards").and_then(Value::as_array) {
        for card_entry in cards {
            let def_id = card_entry
                .get("def_id")
                .and_then(Value::as_str)
                .ok_or_else(|| DeckImportError::InvalidDeck("missing def_id".to_string()))?;
            let count = card_entry
                .get("count")
                .and_then(Value::as_u64)
                .unwrap_or(1) as u32;
            entries.push(DeckEntry {
                card_def_id: def_id.to_string(),
                count,
            });
        }
    }

    if let Some(energy) = deck_json.get("energy").and_then(Value::as_array) {
        for energy_entry in energy {
            let energy_type = energy_entry
                .get("type")
                .and_then(Value::as_str)
                .ok_or_else(|| DeckImportError::InvalidDeck("missing energy type".to_string()))?;
            let count = energy_entry
                .get("count")
                .and_then(Value::as_u64)
                .unwrap_or(0) as u32;
            let def_id = format!("ENERGY-{}", energy_type.to_uppercase());
            entries.push(DeckEntry { card_def_id: def_id, count });
        }
    }

    Ok(DeckList { entries })
}

fn slugify(name: &str) -> String {
    let mut out = String::new();
    let mut last_dash = false;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            last_dash = false;
        } else if !last_dash {
            out.push('-');
            last_dash = true;
        }
    }
    let trimmed = out.trim_matches('-');
    if trimmed.is_empty() {
        "deck-imported".to_string()
    } else {
        trimmed.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{compile_card, run_migrations};
    use rusqlite::Connection;
    use serde_json::{json, Value};

    #[test]
    fn test_import_deck_json() {
        let mut conn = Connection::open_in_memory().expect("db");
        run_migrations(&conn).expect("migrations");
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
        let basic_path = format!("{manifest_dir}/../examples/basic_pokemon.json");
        let basic_data = fs::read_to_string(&basic_path).expect("basic example");
        let basic: Value = serde_json::from_str(&basic_data).expect("parse basic");
        let energy = json!({
            "set": "ENERGY",
            "number": "WATER",
            "name": "Water Energy",
            "supertype": "Energy",
            "energy_kind": "Basic",
            "effect_ast": {"op": "NoOp"},
            "tags": []
        });
        let basic_id = compile_card(&conn, &basic).expect("basic");
        let _ = compile_card(&conn, &energy).expect("energy");
        let deck_json = json!({
            "name": "Test Deck",
            "format": "EX",
            "cards": [
                {"def_id": basic_id, "count": 1}
            ],
            "energy": [
                {"type": "Water", "count": 59}
            ]
        });
        let mut temp_path = std::env::temp_dir();
        let suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis())
            .unwrap_or(0);
        temp_path.push(format!("tcg-deck-{suffix}.json"));
        fs::write(&temp_path, deck_json.to_string()).expect("write");
        let deck_id = import_deck_json(&mut conn, temp_path.to_str().unwrap()).expect("import");
        let deck = load_decklist(&conn, &deck_id).expect("load");
        let total: u32 = deck.entries.iter().map(|entry| entry.count).sum();
        assert_eq!(total, 60);
        let name: String = conn
            .query_row(
                "SELECT name FROM decks WHERE deck_id = ?1",
                params![deck_id],
                |row| row.get(0),
            )
            .expect("query");
        assert_eq!(name, "Test Deck");
    }
}