535 lines
18 KiB
Rust
535 lines
18 KiB
Rust
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<String>,
|
|
}
|
|
|
|
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<Connection> {
|
|
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<String, Vec<CombatIndicator>> {
|
|
if urls.is_empty() {
|
|
return HashMap::new();
|
|
}
|
|
|
|
// Construit les placeholders : (?1, ?2, …)
|
|
let placeholders: String = (1..=urls.len())
|
|
.map(|i| format!("?{}", i))
|
|
.collect::<Vec<_>>()
|
|
.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::<Vec<CombatIndicator>>(&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<String> {
|
|
if urls.is_empty() {
|
|
return std::collections::HashSet::new();
|
|
}
|
|
|
|
let placeholders: String = (1..=urls.len())
|
|
.map(|i| format!("?{}", i))
|
|
.collect::<Vec<_>>()
|
|
.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<Option<crate::parser::GuideData>> {
|
|
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<Vec<Profile>> {
|
|
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<Profile> {
|
|
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<Vec<String>> {
|
|
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<bool> {
|
|
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<Vec<GuideRow>> {
|
|
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<Vec<i64>> {
|
|
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<bool> {
|
|
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<Vec<(String, i64)>> {
|
|
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<String> {
|
|
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<String> = (1..=3)
|
|
.map(|i| format!("https://example.com/quete/{}", i))
|
|
.collect();
|
|
let extra: Vec<String> = (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<String> = 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");
|
|
}
|
|
}
|