use rusqlite::{params, Connection};
use uuid::Uuid;
use anyhow::Result;
use crate::errors::TrexError;
pub struct Database {
conn: Connection,
}
impl Database {
pub fn open() -> Result<Self> {
let data_dir = directories::ProjectDirs::from("", "", "telarex")
.ok_or_else(|| anyhow::anyhow!("could not determine data directory"))?
.data_dir()
.to_path_buf();
std::fs::create_dir_all(&data_dir)?;
let path = data_dir.join("telarex.db");
let conn = Connection::open(path)?;
let db = Self { conn };
db.initialize()?;
Ok(db)
}
fn initialize(&self) -> Result<()> {
self.conn.execute_batch(
"PRAGMA foreign_keys = ON;
BEGIN;
CREATE TABLE IF NOT EXISTS lodges (
uuid TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_owner INTEGER NOT NULL,
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_id TEXT NOT NULL,
username TEXT NOT NULL,
lodge_id TEXT NOT NULL,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lodge_id, peer_id),
FOREIGN KEY(lodge_id) REFERENCES lodges(uuid) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recent_projects (
path TEXT PRIMARY KEY,
last_opened DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS network_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS access_control (
lodge_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
authorized INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(lodge_id, peer_id),
FOREIGN KEY(lodge_id) REFERENCES lodges(uuid) ON DELETE CASCADE
);
COMMIT;"
)?;
Ok(())
}
pub fn log_error(&self, error: &TrexError) -> Result<()> {
let level_str = format!("{:?}", error.level);
self.conn.execute(
"INSERT INTO network_logs (code, level, message) VALUES (?1, ?2, ?3)",
params![error.code, level_str, error.message],
)?;
Ok(())
}
pub fn register_lodge(&self, id: Uuid, path: &str, name: &str, is_owner: bool) -> Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO lodges (uuid, path, name, is_owner, last_accessed)
VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)",
params![id.to_string(), path, name, if is_owner { 1 } else { 0 }],
)?;
Ok(())
}
pub fn add_recent_project(&self, path: &str) -> Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO recent_projects (path, last_opened) VALUES (?1, CURRENT_TIMESTAMP)",
params![path],
)?;
Ok(())
}
pub fn get_recent_projects(&self) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare("SELECT path FROM recent_projects ORDER BY last_opened DESC LIMIT 20")?;
let rows = stmt.query_map([], |row| row.get(0))?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn register_session(&self, lodge_id: Uuid, peer_id: &str, username: &str) -> Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO sessions (lodge_id, peer_id, username) VALUES (?1, ?2, ?3)",
params![lodge_id.to_string(), peer_id, username],
)?;
Ok(())
}
pub fn get_my_lodges(&self) -> Result<Vec<(Uuid, String, String)>> {
let mut stmt = self.conn.prepare("SELECT uuid, path, name FROM lodges WHERE is_owner = 1 ORDER BY last_accessed DESC")?;
let rows = stmt.query_map([], |row| {
let id_str: String = row.get(0)?;
Ok((
Uuid::parse_str(&id_str).unwrap_or_default(),
row.get(1)?,
row.get(2)?,
))
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn reset(&self) -> Result<()> {
self.conn.execute("DROP TABLE IF EXISTS lodges", [])?;
self.conn.execute("DROP TABLE IF EXISTS sessions", [])?;
self.conn.execute("DROP TABLE IF EXISTS recent_projects", [])?;
self.conn.execute("DROP TABLE IF EXISTS network_logs", [])?;
self.conn.execute("DROP TABLE IF EXISTS access_control", [])?;
self.initialize()?;
Ok(())
}
pub fn delete_lodge(&self, id: Uuid) -> Result<()> {
self.conn.execute("DELETE FROM lodges WHERE uuid = ?1", params![id.to_string()])?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn test_db() -> Database {
let conn = Connection::open_in_memory().unwrap();
let db = Database { conn };
db.initialize().unwrap();
db
}
#[test]
fn test_initialize_creates_tables() {
let db = test_db();
let tables: Vec<String> = db
.conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(tables.contains(&"access_control".to_string()));
assert!(tables.contains(&"lodges".to_string()));
assert!(tables.contains(&"network_logs".to_string()));
assert!(tables.contains(&"recent_projects".to_string()));
assert!(tables.contains(&"sessions".to_string()));
}
#[test]
fn test_register_and_retrieve_lodges() {
let db = test_db();
let id = Uuid::new_v4();
db.register_lodge(id, "/tmp/test", "Test Lodge", true).unwrap();
let lodges = db.get_my_lodges().unwrap();
assert_eq!(lodges.len(), 1);
assert_eq!(lodges[0].0, id);
assert_eq!(lodges[0].1, "/tmp/test");
assert_eq!(lodges[0].2, "Test Lodge");
}
#[test]
fn test_register_lodge_deduplicate() {
let db = test_db();
let id = Uuid::new_v4();
db.register_lodge(id, "/dup", "First", true).unwrap();
db.register_lodge(id, "/dup", "Second", true).unwrap();
let lodges = db.get_my_lodges().unwrap();
assert_eq!(lodges.len(), 1);
assert_eq!(lodges[0].2, "Second");
}
#[test]
fn test_delete_lodge_cascades() {
let db = test_db();
let id = Uuid::new_v4();
db.register_lodge(id, "/cascade", "Cascade Lodge", true).unwrap();
db.register_session(id, "peer1", "Alice").unwrap();
db.delete_lodge(id).unwrap();
let lodges = db.get_my_lodges().unwrap();
assert!(lodges.is_empty());
let _ = db.register_lodge(id, "/new", "New Lodge", true).unwrap();
}
#[test]
fn test_recent_projects_crud() {
let db = test_db();
let projects = db.get_recent_projects().unwrap();
assert!(projects.is_empty());
db.add_recent_project("/path/one").unwrap();
db.add_recent_project("/path/two").unwrap();
db.add_recent_project("/path/one").unwrap();
let projects = db.get_recent_projects().unwrap();
assert_eq!(projects.len(), 2);
}
#[test]
fn test_session_registration() {
let db = test_db();
let id = Uuid::new_v4();
db.register_lodge(id, "/session", "Session Lodge", true).unwrap();
db.register_session(id, "alice@device1", "Alice").unwrap();
db.register_session(id, "bob@device2", "Bob").unwrap();
let lodges = db.get_my_lodges().unwrap();
assert_eq!(lodges.len(), 1);
}
#[test]
fn test_reset_clears_everything() {
let db = test_db();
let id = Uuid::new_v4();
db.register_lodge(id, "/reset", "Reset Lodge", true).unwrap();
db.add_recent_project("/reset/path").unwrap();
db.reset().unwrap();
assert!(db.get_my_lodges().unwrap().is_empty());
assert!(db.get_recent_projects().unwrap().is_empty());
}
#[test]
fn test_log_error() {
let db = test_db();
let err = TrexError::network_failure("test");
db.log_error(&err).unwrap();
let count: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM network_logs", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
}
}