use crate::llm::AppState; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::Manager; use tauri_plugin_store::StoreExt; // ponytail: the data-dir pref store + key are defined in lib.rs; mirror them // here so this module is self-contained for reads/writes. const PREFS_STORE: &str = "dm-pal-prefs.json"; const DATA_DIR_KEY: &str = "dataDir"; #[derive(Debug, Serialize)] pub struct DataDirInfo { /// Absolute path currently in use (may differ from `configured` if it was /// unset/fell back to the default). pub current: String, /// Absolute path that will be used after the next restart, if different. pub configured: Option, /// The OS-default location ($APPDATA/dm-toolkit). pub default: String, } /// Return the current data dir + the configured (pending-restart) value, so /// Settings can show "using X — restart to apply Y". #[tauri::command] pub fn get_data_dir( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let default = app .path() .app_data_dir() .map_err(|e| e.to_string())? .join("dm-toolkit"); let configured = app .store(PREFS_STORE) .map_err(|e| e.to_string())? .get(DATA_DIR_KEY) .and_then(|v| v.as_str().map(|s| s.to_string())); Ok(DataDirInfo { current: state.data_dir.display().to_string(), configured, default: default.display().to_string(), }) } /// Subdirs/files that make up the campaign data and must move together. const DATA_RELATIVE: &[&str] = &["lore/lore.db", "generations.db", "images"]; /// Recursively copy `src` to `dst`, but never overwrite an existing file — /// a re-locate must not clobber data already at the destination, and never /// deletes the source (the old location stays as a backup until the DM deletes it). /// When both src and dst are directories, recurse so new files inside an /// existing destination tree still get copied. fn copy_if_absent(src: &Path, dst: &Path) -> std::io::Result<()> { if !src.exists() { return Ok(()); } if src.is_dir() { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; copy_if_absent(&entry.path(), &dst.join(entry.file_name()))?; } return Ok(()); } // leaf file: skip if the destination already has it (no overwrite). if dst.exists() { return Ok(()); } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } std::fs::copy(src, dst)?; Ok(()) } /// Set the data dir to `new_dir`: create it, copy existing campaign data /// (lore.db, generations.db, images/) into it, and persist the preference so /// the next restart uses it. The currently-open SQLite connections stay on the /// old paths until restart — the caller must prompt the user to relaunch. #[tauri::command] pub fn set_data_dir( app: tauri::AppHandle, state: tauri::State<'_, AppState>, new_dir: String, ) -> Result { let new_path = PathBuf::from(new_dir.trim()); if !new_path.is_absolute() { return Err("Choose an absolute folder path".into()); } std::fs::create_dir_all(&new_path).map_err(|e| format!("can't create dir: {e}"))?; // Copy existing data into the new location (no overwrite; old data stays put). for rel in DATA_RELATIVE { let src = state.data_dir.join(rel); let dst = new_path.join(rel); if src.exists() { copy_if_absent(&src, &dst).map_err(|e| format!("migrate {rel}: {e}"))?; } } // Persist the preference for the next startup. let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?; store.set(DATA_DIR_KEY, serde_json::Value::String(new_path.display().to_string())); store.save().map_err(|e| e.to_string())?; Ok(DataDirInfo { current: state.data_dir.display().to_string(), configured: Some(new_path.display().to_string()), default: app .path() .app_data_dir() .map_err(|e| e.to_string())? .join("dm-toolkit") .display() .to_string(), }) } /// Clear the preference so the next restart uses the OS default. Does not /// delete any files — the DM re-points, then restarts. #[tauri::command] pub fn reset_data_dir(app: tauri::AppHandle) -> Result<(), String> { let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?; store.delete(DATA_DIR_KEY); store.save().map_err(|e| e.to_string())?; Ok(()) } // ─── SQLite viewer (read-only) ───────────────────────────────── #[derive(Debug, Deserialize)] pub struct SqlQueryReq { /// "lore" or "generations". pub db: String, pub sql: String, } #[derive(Debug, Serialize)] pub struct SqlQueryResult { pub columns: Vec, pub rows: Vec>, } /// Run a read-only SQL query against one of the two SQLite DBs so a DM can /// inspect the backend from Settings → Advanced. Only SELECT/PRAGMA/WITH. #[tauri::command] pub fn sql_query(state: tauri::State<'_, AppState>, req: SqlQueryReq) -> Result { let res = match req.db.as_str() { "lore" => state.rag.raw_query(&req.sql), "generations" => state.gen.raw_query(&req.sql), other => Err(anyhow::anyhow!("unknown db: {other}")), } .map_err(|e| e.to_string())?; Ok(SqlQueryResult { columns: res.0, rows: res.1 }) } #[cfg(test)] mod tests { use super::*; #[test] fn copy_if_absent_does_not_overwrite() { let tmp = std::env::temp_dir().join(format!("dm-pal-data-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let src = tmp.join("src"); let dst = tmp.join("dst"); std::fs::create_dir_all(src.join("images")).unwrap(); std::fs::write(src.join("images/a.png"), b"png").unwrap(); std::fs::create_dir_all(src.join("lore")).unwrap(); std::fs::write(src.join("lore/lore.db"), b"db").unwrap(); // first copy creates dst with the files copy_if_absent(&src, &dst).unwrap(); assert!(dst.join("images/a.png").exists()); assert!(dst.join("lore/lore.db").exists()); // mutate src; second copy must NOT overwrite existing dst files std::fs::write(src.join("images/a.png"), b"changed").unwrap(); copy_if_absent(&src, &dst).unwrap(); assert_eq!(std::fs::read(dst.join("images/a.png")).unwrap(), b"png"); // a new file in src does get copied (absent in dst) std::fs::write(src.join("images/b.png"), b"png2").unwrap(); copy_if_absent(&src, &dst).unwrap(); assert!(dst.join("images/b.png").exists()); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn copy_if_absent_missing_src_is_noop() { let tmp = std::env::temp_dir().join(format!("dm-pal-data-test2-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); copy_if_absent(&tmp.join("nope"), &tmp.join("dst")).unwrap(); assert!(!tmp.join("dst").exists()); let _ = std::fs::remove_dir_all(&tmp); } }