use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; use chrono::Utc; use uuid::Uuid; use std::collections::HashMap; use crate::parser::CombatIndicator; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Profile { pub id: String, pub name: String, pub created_at: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GuideRow { pub gid: String, pub name: String, pub data: String, pub last_synced_at: Option, } pub fn get_db_path() -> String { let data_dir = dirs_next::data_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("toughligui"); std::fs::create_dir_all(&data_dir).ok(); data_dir.join("toughligui.db").to_string_lossy().to_string() } pub fn open() -> Result { let path = get_db_path(); let conn = Connection::open(&path)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; Ok(conn) } pub fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch(" CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS guides ( gid TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL, last_synced_at TEXT ); CREATE TABLE IF NOT EXISTS quest_completions ( profile_id TEXT NOT NULL, quest_name TEXT NOT NULL, completed_at TEXT NOT NULL, PRIMARY KEY (profile_id, quest_name), FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS quest_step_progress ( profile_id TEXT NOT NULL, quest_name TEXT NOT NULL, step_index INTEGER NOT NULL, PRIMARY KEY (profile_id, quest_name, step_index), FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS resource_inventory ( profile_id TEXT NOT NULL, resource_name TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (profile_id, resource_name), FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS quest_previews ( quest_url TEXT PRIMARY KEY, indicators_json TEXT NOT NULL, cached_at TEXT NOT NULL DEFAULT (datetime('now')) ); ")?; Ok(()) } pub fn get_cached_previews(conn: &Connection, urls: &[String]) -> HashMap> { if urls.is_empty() { return HashMap::new(); } // Construit les placeholders : (?1, ?2, …) let placeholders: String = (1..=urls.len()) .map(|i| format!("?{}", i)) .collect::>() .join(", "); let sql = format!( "SELECT quest_url, indicators_json FROM quest_previews WHERE quest_url IN ({})", placeholders ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, Err(_) => return HashMap::new(), }; // rusqlite attend &dyn ToSql — on construit un vecteur de références let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls .iter() .map(|u| u as &dyn rusqlite::types::ToSql) .collect(); let rows = match stmt.query_map(params_vec.as_slice(), |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) { Ok(r) => r, Err(_) => return HashMap::new(), }; let mut map = HashMap::new(); for row in rows.flatten() { let (url, json) = row; if let Ok(indicators) = serde_json::from_str::>(&json) { map.insert(url, indicators); } } map } pub fn upsert_preview(conn: &Connection, url: &str, indicators: &[CombatIndicator]) -> Result<()> { let json = serde_json::to_string(indicators).unwrap_or_else(|_| "[]".to_string()); let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO quest_previews (quest_url, indicators_json, cached_at) VALUES (?1, ?2, ?3) ON CONFLICT(quest_url) DO UPDATE SET indicators_json=excluded.indicators_json, cached_at=excluded.cached_at", params![url, json, now], )?; Ok(()) } /// Retourne l'ensemble des URLs déjà présentes dans quest_previews parmi celles fournies. pub fn get_cached_urls(conn: &Connection, urls: &[String]) -> std::collections::HashSet { if urls.is_empty() { return std::collections::HashSet::new(); } let placeholders: String = (1..=urls.len()) .map(|i| format!("?{}", i)) .collect::>() .join(", "); let sql = format!( "SELECT quest_url FROM quest_previews WHERE quest_url IN ({})", placeholders ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, Err(_) => return std::collections::HashSet::new(), }; let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls .iter() .map(|u| u as &dyn rusqlite::types::ToSql) .collect(); let rows = match stmt.query_map(params_vec.as_slice(), |row| row.get::<_, String>(0)) { Ok(r) => r, Err(_) => return std::collections::HashSet::new(), }; rows.flatten().collect() } /// Charge un guide depuis la DB à partir de son gid. pub fn get_guide(conn: &Connection, gid: &str) -> Result> { let result = conn.query_row( "SELECT data FROM guides WHERE gid = ?1", params![gid], |row| row.get::<_, String>(0), ); match result { Ok(json) => { let data = serde_json::from_str(&json) .map_err(|e| rusqlite::Error::FromSqlConversionFailure( 0, rusqlite::types::Type::Text, Box::new(e), ))?; Ok(Some(data)) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } pub fn get_profiles(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT id, name, created_at FROM profiles ORDER BY created_at ASC")?; let rows = stmt.query_map([], |row| { Ok(Profile { id: row.get(0)?, name: row.get(1)?, created_at: row.get(2)?, }) })?; rows.collect() } pub fn create_profile(conn: &Connection, name: &str) -> Result { let id = Uuid::new_v4().to_string(); let created_at = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO profiles (id, name, created_at) VALUES (?1, ?2, ?3)", params![id, name, created_at], )?; Ok(Profile { id, name: name.to_string(), created_at }) } pub fn delete_profile(conn: &Connection, profile_id: &str) -> Result<()> { conn.execute("DELETE FROM profiles WHERE id = ?1", params![profile_id])?; Ok(()) } pub fn get_completed_quests(conn: &Connection, profile_id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT quest_name FROM quest_completions WHERE profile_id = ?1" )?; let rows = stmt.query_map(params![profile_id], |row| row.get(0))?; rows.collect() } pub fn toggle_quest(conn: &Connection, profile_id: &str, quest_name: &str) -> Result { let exists: bool = conn.query_row( "SELECT COUNT(*) FROM quest_completions WHERE profile_id = ?1 AND quest_name = ?2", params![profile_id, quest_name], |row| row.get::<_, i64>(0), ).map(|c| c > 0)?; if exists { conn.execute( "DELETE FROM quest_completions WHERE profile_id = ?1 AND quest_name = ?2", params![profile_id, quest_name], )?; Ok(false) } else { let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO quest_completions (profile_id, quest_name, completed_at) VALUES (?1, ?2, ?3)", params![profile_id, quest_name, now], )?; Ok(true) } } pub fn upsert_guide(conn: &Connection, gid: &str, name: &str, data: &str) -> Result<()> { let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO guides (gid, name, data, last_synced_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(gid) DO UPDATE SET name=excluded.name, data=excluded.data, last_synced_at=excluded.last_synced_at", params![gid, name, data, now], )?; Ok(()) } pub fn get_guides(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT gid, name, data, last_synced_at FROM guides ORDER BY rowid ASC")?; let rows = stmt.query_map([], |row| { Ok(GuideRow { gid: row.get(0)?, name: row.get(1)?, data: row.get(2)?, last_synced_at: row.get(3)?, }) })?; rows.collect() } pub fn get_completed_steps(conn: &Connection, profile_id: &str, quest_name: &str) -> Result> { let mut stmt = conn.prepare( "SELECT step_index FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2" )?; let rows = stmt.query_map(params![profile_id, quest_name], |row| row.get(0))?; rows.collect() } pub fn toggle_quest_step(conn: &Connection, profile_id: &str, quest_name: &str, step_index: i64) -> Result { let exists: bool = conn.query_row( "SELECT COUNT(*) FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3", params![profile_id, quest_name, step_index], |row| row.get::<_, i64>(0), ).map(|c| c > 0)?; if exists { conn.execute( "DELETE FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3", params![profile_id, quest_name, step_index], )?; Ok(false) } else { conn.execute( "INSERT INTO quest_step_progress (profile_id, quest_name, step_index) VALUES (?1, ?2, ?3)", params![profile_id, quest_name, step_index], )?; Ok(true) } } pub fn get_resource_inventory(conn: &Connection, profile_id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT resource_name, quantity FROM resource_inventory WHERE profile_id = ?1" )?; let rows = stmt.query_map(params![profile_id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) })?; rows.collect() } pub fn set_resource_quantity(conn: &Connection, profile_id: &str, resource_name: &str, quantity: i64) -> Result<()> { if quantity <= 0 { conn.execute( "DELETE FROM resource_inventory WHERE profile_id = ?1 AND resource_name = ?2", params![profile_id, resource_name], )?; } else { conn.execute( "INSERT INTO resource_inventory (profile_id, resource_name, quantity) VALUES (?1, ?2, ?3) ON CONFLICT(profile_id, resource_name) DO UPDATE SET quantity=excluded.quantity", params![profile_id, resource_name, quantity], )?; } Ok(()) } pub fn get_setting(conn: &Connection, key: &str) -> Option { conn.query_row( "SELECT value FROM settings WHERE key = ?1", params![key], |row| row.get(0), ).ok() } pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> { conn.execute( "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", params![key, value], )?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::parser::CombatIndicator; fn test_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); migrate(&conn).unwrap(); conn } fn make_indicator(t: &str, c: &str) -> CombatIndicator { CombatIndicator { combat_type: t.to_string(), count: c.to_string(), label: None, evitable: false, } } // ── migrate ────────────────────────────────────────────────────────────── #[test] fn test_migrate_creates_all_tables() { let conn = test_db(); let expected_tables = [ "profiles", "guides", "quest_completions", "settings", "quest_step_progress", "resource_inventory", "quest_previews", ]; for table in &expected_tables { let count: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", params![table], |row| row.get(0), ) .unwrap(); assert_eq!(count, 1, "Table '{}' manquante après migration", table); } } #[test] fn test_migrate_idempotent() { let conn = test_db(); // Un second appel ne doit pas paniquer ni retourner d'erreur (IF NOT EXISTS) migrate(&conn).expect("Le second appel à migrate() ne doit pas échouer"); } // ── settings ───────────────────────────────────────────────────────────── #[test] fn test_set_and_get_setting_roundtrip() { let conn = test_db(); set_setting(&conn, "theme", "dark").unwrap(); let value = get_setting(&conn, "theme"); assert_eq!(value, Some("dark".to_string())); } #[test] fn test_get_setting_missing_key_returns_none() { let conn = test_db(); let value = get_setting(&conn, "cle_inexistante"); assert!(value.is_none()); } #[test] fn test_set_setting_overwrites_existing_value() { let conn = test_db(); set_setting(&conn, "theme", "light").unwrap(); set_setting(&conn, "theme", "dark").unwrap(); let value = get_setting(&conn, "theme"); assert_eq!(value, Some("dark".to_string())); } // ── upsert_preview / get_cached_previews ───────────────────────────────── #[test] fn test_upsert_and_get_cached_previews_roundtrip() { let conn = test_db(); let url = "https://example.com/quete/test".to_string(); let indicators = vec![ make_indicator("Monstre", "3"), make_indicator("Boss", "1"), ]; upsert_preview(&conn, &url, &indicators).unwrap(); let result = get_cached_previews(&conn, &[url.clone()]); assert!(result.contains_key(&url), "L'URL doit être présente dans le résultat"); let retrieved = &result[&url]; assert_eq!(retrieved.len(), 2); assert_eq!(retrieved[0].combat_type, "Monstre"); assert_eq!(retrieved[0].count, "3"); assert_eq!(retrieved[1].combat_type, "Boss"); assert_eq!(retrieved[1].count, "1"); } #[test] fn test_upsert_preview_idempotent() { let conn = test_db(); let url = "https://example.com/quete/idempotent".to_string(); let indicators_v1 = vec![make_indicator("Monstre", "2")]; let indicators_v2 = vec![make_indicator("Boss", "5")]; upsert_preview(&conn, &url, &indicators_v1).unwrap(); // Deuxième upsert sur la même URL : pas d'erreur de contrainte UNIQUE upsert_preview(&conn, &url, &indicators_v2).unwrap(); let result = get_cached_previews(&conn, &[url.clone()]); let retrieved = &result[&url]; // Seul le dernier upsert doit être présent assert_eq!(retrieved.len(), 1); assert_eq!(retrieved[0].combat_type, "Boss"); assert_eq!(retrieved[0].count, "5"); } // ── get_cached_previews — cas limites ──────────────────────────────────── #[test] fn test_get_cached_previews_partial_hit() { let conn = test_db(); let url_cached = "https://example.com/quete/en-cache".to_string(); let url_missing = "https://example.com/quete/absente".to_string(); upsert_preview(&conn, &url_cached, &[make_indicator("Monstre", "1")]).unwrap(); let result = get_cached_previews(&conn, &[url_cached.clone(), url_missing.clone()]); assert!(result.contains_key(&url_cached), "L'URL en cache doit être retournée"); assert!(!result.contains_key(&url_missing), "L'URL absente ne doit pas figurer dans le résultat"); assert_eq!(result.len(), 1); } #[test] fn test_get_cached_previews_empty_input() { let conn = test_db(); let result = get_cached_previews(&conn, &[]); assert!(result.is_empty(), "Une liste vide d'URLs doit retourner un HashMap vide"); } // ── get_cached_urls ─────────────────────────────────────────────────────── #[test] fn test_get_cached_urls_returns_only_cached() { let conn = test_db(); let cached: Vec = (1..=3) .map(|i| format!("https://example.com/quete/{}", i)) .collect(); let extra: Vec = (4..=5) .map(|i| format!("https://example.com/quete/{}", i)) .collect(); for url in &cached { upsert_preview(&conn, url, &[make_indicator("Monstre", "1")]).unwrap(); } let all_urls: Vec = cached.iter().chain(extra.iter()).cloned().collect(); let result = get_cached_urls(&conn, &all_urls); assert_eq!(result.len(), 3, "Seules les 3 URLs en cache doivent être retournées"); for url in &cached { assert!(result.contains(url), "URL '{}' attendue dans le HashSet", url); } for url in &extra { assert!(!result.contains(url), "URL '{}' ne doit pas être dans le HashSet", url); } } #[test] fn test_get_cached_urls_empty_input() { let conn = test_db(); let result = get_cached_urls(&conn, &[]); assert!(result.is_empty(), "Une liste vide doit retourner un HashSet vide"); } }