From 86f83b7a450c5546306a4b86044067c9502752a2 Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 13 May 2026 09:21:45 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20lumotia-rebrand=20=E2=80=94=20data=20d?= =?UTF-8?q?ir=20migration=20shim=20+=20paths.rs=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the rebrand cascade per locked decision D1 (migrate in place). crates/core/src/paths.rs: - Hardcoded subdir strings renamed: magnotia/Magnotia -> lumotia/Lumotia across all four OS branches (Linux XDG + dot-legacy, macOS Application Support, Windows LOCALAPPDATA, fallback dot-dir). - Database filename: magnotia.db -> lumotia.db. - Test path fixtures renamed: /tmp/magnotia-test -> /tmp/lumotia-test. - New MigrationStatus enum (Migrated / TargetAlreadyExists / NoLegacyFound). - New migrate_legacy_data_dir() that probes the platform-correct legacy magnotia path, renames it to the lumotia equivalent via fs::rename, and also renames magnotia.db -> lumotia.db inside if found. Idempotent: safe to call on every boot. Refuses to overwrite an existing lumotia dir to protect user data. - Four new unit tests covering all branches via the test-friendly migrate_legacy_data_dir_inner that takes an explicit legacy path. crates/storage/src/database.rs: - New migrate_legacy_setting_keys(pool) that renames any settings rows with key matching magnotia_* to lumotia_*. Single SQL UPDATE with NOT EXISTS guard so it leaves rows alone if a lumotia_ row already exists. Re-exported from lib.rs. src-tauri/src/lib.rs: - Calls migrate_legacy_data_dir(&app_data_dir()) at the start of setup() BEFORE database_path() resolves the now-renamed dir. Logs migration outcome to lumotia_startup tracing target. - Calls migrate_legacy_setting_keys(&db) immediately after init_db returns. Logs only when rows are actually renamed. src-tauri/src/{lib,commands/{diagnostics,rituals,transcripts}}.rs + crates/storage/src/database.rs: - All in-code references to magnotia_preferences, magnotia_history (in comments), and magnotia_morning_triage_last_shown renamed to lumotia_*. cargo build --workspace passes. cargo test --workspace: 334 pass, 0 fail (up from 330; +4 paths::tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/paths.rs | 269 ++++++++++++++++++++++++-- crates/storage/src/database.rs | 27 ++- crates/storage/src/lib.rs | 5 +- src-tauri/src/commands/diagnostics.rs | 2 +- src-tauri/src/commands/rituals.rs | 4 +- src-tauri/src/commands/transcripts.rs | 2 +- src-tauri/src/lib.rs | 52 ++++- 7 files changed, 335 insertions(+), 26 deletions(-) diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs index 2f0ab1f..7d27a3f 100644 --- a/crates/core/src/paths.rs +++ b/crates/core/src/paths.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::types::ModelId; @@ -19,7 +19,7 @@ impl AppPaths { } pub fn database_path(&self) -> PathBuf { - self.app_data_dir.join("magnotia.db") + self.app_data_dir.join("lumotia.db") } pub fn recordings_dir(&self) -> PathBuf { @@ -67,7 +67,7 @@ fn resolve_app_data_dir() -> PathBuf { #[cfg(target_os = "windows")] { let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()); - return PathBuf::from(local_app_data).join("magnotia"); + return PathBuf::from(local_app_data).join("lumotia"); } #[cfg(target_os = "macos")] @@ -76,56 +76,293 @@ fn resolve_app_data_dir() -> PathBuf { return PathBuf::from(home) .join("Library") .join("Application Support") - .join("Magnotia"); + .join("Lumotia"); } #[cfg(target_os = "linux")] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - let legacy = PathBuf::from(&home).join(".magnotia"); - if legacy.exists() { - return legacy; + let legacy_dot = PathBuf::from(&home).join(".lumotia"); + if legacy_dot.exists() { + return legacy_dot; } if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { - return PathBuf::from(xdg).join("magnotia"); + return PathBuf::from(xdg).join("lumotia"); } } PathBuf::from(home) .join(".local") .join("share") - .join("magnotia") + .join("lumotia") } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join(".magnotia") + PathBuf::from(home).join(".lumotia") } } +/// Outcome of attempting to migrate an existing magnotia-era data directory +/// to its lumotia equivalent on first launch after the rebrand. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MigrationStatus { + /// Renamed legacy dir to the new path. Includes optional db-file rename + /// if `magnotia.db` was found inside. + Migrated { + from: PathBuf, + to: PathBuf, + renamed_db: bool, + }, + /// New path already exists. Did not touch legacy (if any) to avoid + /// destroying user data on the new path. + TargetAlreadyExists { target: PathBuf }, + /// No legacy data dir on disk. Nothing to do. + NoLegacyFound, +} + +/// Probe the legacy magnotia data dir paths on the current platform and +/// return the first one that exists, if any. Mirrors the search order +/// inside `resolve_app_data_dir()` but for the old name. +fn legacy_magnotia_data_dir() -> Option { + #[cfg(target_os = "windows")] + { + let local_app_data = std::env::var("LOCALAPPDATA").ok()?; + let candidate = PathBuf::from(local_app_data).join("magnotia"); + return candidate.exists().then_some(candidate); + } + + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").ok()?; + let candidate = PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("Magnotia"); + return candidate.exists().then_some(candidate); + } + + #[cfg(target_os = "linux")] + { + let home = std::env::var("HOME").ok()?; + let dot_legacy = PathBuf::from(&home).join(".magnotia"); + if dot_legacy.exists() { + return Some(dot_legacy); + } + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + let xdg_legacy = PathBuf::from(xdg).join("magnotia"); + if xdg_legacy.exists() { + return Some(xdg_legacy); + } + } + } + let xdg_default = PathBuf::from(home) + .join(".local") + .join("share") + .join("magnotia"); + xdg_default.exists().then_some(xdg_default) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + let home = std::env::var("HOME").ok()?; + let candidate = PathBuf::from(home).join(".magnotia"); + candidate.exists().then_some(candidate) + } +} + +/// Migrate a legacy magnotia data directory to the new lumotia path on +/// first launch. Idempotent: safe to call on every boot. +/// +/// Rules: +/// * If the new path already exists, do nothing and return +/// `TargetAlreadyExists`. We do not destroy lumotia data, even if a +/// stale legacy dir is also present. +/// * If only the legacy path exists, rename it to the new path and +/// rename `magnotia.db` -> `lumotia.db` inside it if found. +/// * If neither exists, return `NoLegacyFound` — the first launch on a +/// clean system will create the new path itself. +pub fn migrate_legacy_data_dir(new_path: &Path) -> Result { + migrate_legacy_data_dir_inner(new_path, legacy_magnotia_data_dir()) +} + +/// Test-friendly inner shape: takes the legacy path explicitly so tests +/// don't depend on platform-specific HOME / LOCALAPPDATA env vars. +fn migrate_legacy_data_dir_inner( + new_path: &Path, + legacy: Option, +) -> Result { + if new_path.exists() { + return Ok(MigrationStatus::TargetAlreadyExists { + target: new_path.to_path_buf(), + }); + } + + let Some(from) = legacy else { + return Ok(MigrationStatus::NoLegacyFound); + }; + + if let Some(parent) = new_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::rename(&from, new_path)?; + + let renamed_db = rename_db_file_if_present(new_path)?; + + Ok(MigrationStatus::Migrated { + from, + to: new_path.to_path_buf(), + renamed_db, + }) +} + +fn rename_db_file_if_present(dir: &Path) -> Result { + let legacy_db = dir.join("magnotia.db"); + if !legacy_db.exists() { + return Ok(false); + } + let new_db = dir.join("lumotia.db"); + if new_db.exists() { + return Ok(false); + } + std::fs::rename(&legacy_db, &new_db)?; + Ok(true) +} + #[cfg(test)] mod tests { - use super::AppPaths; + use super::*; use crate::types::ModelId; - use std::path::PathBuf; #[test] fn derives_all_paths_from_one_base() { let paths = AppPaths { - app_data_dir: PathBuf::from("/tmp/magnotia-test"), + app_data_dir: PathBuf::from("/tmp/lumotia-test"), }; assert_eq!( paths.database_path(), - PathBuf::from("/tmp/magnotia-test/magnotia.db") + PathBuf::from("/tmp/lumotia-test/lumotia.db") ); assert_eq!( paths.speech_model_dir(&ModelId::new("whisper-base-en")), - PathBuf::from("/tmp/magnotia-test/models/whisper-base-en") + PathBuf::from("/tmp/lumotia-test/models/whisper-base-en") ); assert_eq!( paths.llm_models_dir(), - PathBuf::from("/tmp/magnotia-test/models/llm") + PathBuf::from("/tmp/lumotia-test/models/llm") ); } + + fn unique_tmp(base: &str) -> PathBuf { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("lumotia-paths-test-{base}-{pid}-{nanos}")) + } + + #[test] + fn migrate_with_legacy_present_renames_dir_and_db() { + let root = unique_tmp("legacy-present"); + let legacy = root.join("magnotia"); + let new_path = root.join("lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap(); + std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); + + let result = + migrate_legacy_data_dir_inner(&new_path, Some(legacy.clone())).expect("migrate ok"); + + match result { + MigrationStatus::Migrated { + from, + to, + renamed_db, + } => { + assert_eq!(from, legacy); + assert_eq!(to, new_path); + assert!(renamed_db, "expected db file to be renamed"); + } + other => panic!("expected Migrated, got {other:?}"), + } + assert!(!legacy.exists(), "legacy dir should be gone"); + assert!(new_path.exists(), "new dir should exist"); + assert!(new_path.join("lumotia.db").exists(), "db at new name"); + assert!(!new_path.join("magnotia.db").exists(), "old db gone"); + assert!( + new_path.join("recordings.placeholder").exists(), + "other files preserved" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_with_both_present_returns_target_exists_and_preserves_lumotia() { + let root = unique_tmp("both-present"); + let legacy = root.join("magnotia"); + let new_path = root.join("lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::create_dir_all(&new_path).unwrap(); + std::fs::write(new_path.join("lumotia.db"), b"new-data").unwrap(); + std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap(); + + let result = + migrate_legacy_data_dir_inner(&new_path, Some(legacy.clone())).expect("migrate ok"); + + assert_eq!( + result, + MigrationStatus::TargetAlreadyExists { + target: new_path.clone() + } + ); + assert!(legacy.exists(), "legacy dir preserved"); + assert!(new_path.exists(), "new dir preserved"); + assert_eq!( + std::fs::read(new_path.join("lumotia.db")).unwrap(), + b"new-data".to_vec(), + "lumotia.db not overwritten" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_with_neither_present_returns_no_legacy() { + let root = unique_tmp("neither-present"); + let new_path = root.join("lumotia"); + + let result = migrate_legacy_data_dir_inner(&new_path, None).expect("migrate ok"); + + assert_eq!(result, MigrationStatus::NoLegacyFound); + assert!(!new_path.exists(), "no dir created"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_with_legacy_present_but_no_db_inside() { + let root = unique_tmp("legacy-no-db"); + let legacy = root.join("magnotia"); + let new_path = root.join("lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); + + let result = + migrate_legacy_data_dir_inner(&new_path, Some(legacy.clone())).expect("migrate ok"); + + match result { + MigrationStatus::Migrated { renamed_db, .. } => { + assert!(!renamed_db, "no db to rename"); + } + other => panic!("expected Migrated, got {other:?}"), + } + assert!(new_path.exists()); + assert!(new_path.join("recordings.placeholder").exists()); + + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 5aa77f3..7a9cfcd 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -905,6 +905,31 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result> Ok(row.map(|r| r.get("value"))) } +/// One-shot key rename for settings rows carried over from the magnotia era +/// (`magnotia_preferences`, `magnotia_morning_triage_last_shown`, etc.). +/// +/// Idempotent: rows already on the new key are left alone; rows on the old +/// key are renamed only if the new key does not already exist. Returns the +/// number of rows actually renamed so the caller can log on first launch. +pub async fn migrate_legacy_setting_keys(pool: &SqlitePool) -> Result { + let result = sqlx::query( + "UPDATE settings \ + SET key = 'lumotia_' || substr(key, length('magnotia_') + 1) \ + WHERE key LIKE 'magnotia_%' \ + AND NOT EXISTS (\ + SELECT 1 FROM settings AS dst \ + WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ + )", + ) + .execute(pool) + .await + .map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys".into(), + source, + })?; + Ok(result.rows_affected()) +} + // --- Row types --- #[derive(Debug, Clone)] @@ -944,7 +969,7 @@ pub struct TranscriptRow { pub anti_hallucination: bool, pub created_at: String, // Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata - // that previously lived in the removed localStorage `magnotia_history` cache. + // that previously lived in the removed localStorage `lumotia_history` cache. pub starred: bool, pub manual_tags: String, pub template: String, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 5320b73..e22fe02 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -17,8 +17,9 @@ pub use database::{ insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, - prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled, - set_setting, set_task_energy, uncomplete_task, update_profile, update_task, update_transcript, + migrate_legacy_setting_keys, prune_error_log, record_feedback, search_transcripts, + set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task, update_profile, + update_task, update_transcript, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow, diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs index 3b24f01..89601fd 100644 --- a/src-tauri/src/commands/diagnostics.rs +++ b/src-tauri/src/commands/diagnostics.rs @@ -335,7 +335,7 @@ async fn generate_diagnostic_report_inner( if opts.include_settings { out.push_str("## Settings (sanitised)\n\n"); - match lumotia_storage::get_setting(&state.db, "magnotia_preferences").await { + match lumotia_storage::get_setting(&state.db, "lumotia_preferences").await { Ok(Some(json)) => { out.push_str("```json\n"); out.push_str(&sanitise_preferences_json(&json)); diff --git a/src-tauri/src/commands/rituals.rs b/src-tauri/src/commands/rituals.rs index e9dab49..6de35af 100644 --- a/src-tauri/src/commands/rituals.rs +++ b/src-tauri/src/commands/rituals.rs @@ -8,13 +8,13 @@ //! //! Stored under the existing SQLite settings table via //! `lumotia_storage::{get_setting, set_setting}` — same bag as -//! `magnotia_preferences`. +//! `lumotia_preferences`. use lumotia_storage::{get_setting, set_setting}; use crate::AppState; -const LAST_TRIAGE_KEY: &str = "magnotia_morning_triage_last_shown"; +const LAST_TRIAGE_KEY: &str = "lumotia_morning_triage_last_shown"; /// Returns the YYYY-MM-DD date string stored on the last successful /// morning triage dismissal, or `None` if the user has never been diff --git a/src-tauri/src/commands/transcripts.rs b/src-tauri/src/commands/transcripts.rs index 271996f..2107a8d 100644 --- a/src-tauri/src/commands/transcripts.rs +++ b/src-tauri/src/commands/transcripts.rs @@ -28,7 +28,7 @@ use crate::AppState; /// /// Task 2.5 — `starred`, `manualTags`, `template`, `language`, `segmentsJson` /// were added to back the viewer metadata that previously lived only in the -/// removed `magnotia_history` localStorage cache. +/// removed `lumotia_history` localStorage cache. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TranscriptDto { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8caf839..edcd134 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,9 +12,13 @@ use sqlx::SqlitePool; use tauri::Manager; use tracing_subscriber::EnvFilter; +use lumotia_core::paths::{migrate_legacy_data_dir, MigrationStatus}; use lumotia_core::types::EngineName; use lumotia_llm::LlmEngine; -use lumotia_storage::{database_path, get_setting, init as init_db, prune_error_log, set_setting}; +use lumotia_storage::{ + app_data_dir, database_path, get_setting, init as init_db, migrate_legacy_setting_keys, + prune_error_log, set_setting, +}; /// How long to retain `error_log` rows. Pruned once on startup. /// 90 days is long enough to triage "this happened a few weeks ago" @@ -97,7 +101,7 @@ async fn save_preferences( state: tauri::State<'_, AppState>, preferences: String, ) -> Result<(), String> { - set_setting(&state.db, "magnotia_preferences", &preferences) + set_setting(&state.db, "lumotia_preferences", &preferences) .await .map_err(|e| e.to_string()) } @@ -220,6 +224,29 @@ pub fn run() { builder .setup(|app| { + // One-shot legacy data-dir migration: rename ~/.local/share/magnotia + // (and macOS/Windows equivalents) to the lumotia path on first + // launch after the rebrand. Safe to call every boot — idempotent. + let t_migrate = Instant::now(); + let new_data_dir = app_data_dir(); + match migrate_legacy_data_dir(&new_data_dir) { + Ok(MigrationStatus::Migrated { from, to, renamed_db }) => tracing::info!( + target: "lumotia_startup", + elapsed_ms = t_migrate.elapsed().as_millis(), + from = %from.display(), + to = %to.display(), + renamed_db, + "migrated legacy magnotia data dir to lumotia" + ), + Ok(MigrationStatus::TargetAlreadyExists { .. }) => {} + Ok(MigrationStatus::NoLegacyFound) => {} + Err(e) => tracing::warn!( + target: "lumotia_startup", + error = %e, + "legacy data dir migration failed — continuing with new path" + ), + } + // Initialise database and startup settings in one runtime entry. let db_path = database_path(); let (db, init_script) = tauri::async_runtime::block_on(async { @@ -229,6 +256,25 @@ pub fn run() { .map_err(|e| Box::new(e) as Box)?; tracing::info!(target: "lumotia_startup", elapsed_ms = t0.elapsed().as_millis(), "DB init complete"); + // One-shot settings-key migration: rename any leftover + // `magnotia_*` rows from the magnotia era to `lumotia_*`. + // Idempotent; logs only when rows are actually renamed. + let t_keys = Instant::now(); + match migrate_legacy_setting_keys(&db).await { + Ok(0) => {} + Ok(n) => tracing::info!( + target: "lumotia_startup", + rows_renamed = n, + elapsed_ms = t_keys.elapsed().as_millis(), + "renamed legacy magnotia_* settings keys to lumotia_*" + ), + Err(e) => tracing::warn!( + target: "lumotia_startup", + error = %e, + "settings-key migration failed — continuing" + ), + } + // Prune old `error_log` rows so the table doesn't grow unbounded // across months of dogfooding. Best-effort — a prune failure is // not worth blocking startup over. @@ -247,7 +293,7 @@ pub fn run() { // Load saved preferences for webview injection. let t1 = Instant::now(); - let prefs_json = get_setting(&db, "magnotia_preferences").await.unwrap_or(None); + let prefs_json = get_setting(&db, "lumotia_preferences").await.unwrap_or(None); tracing::info!(target: "lumotia_startup", elapsed_ms = t1.elapsed().as_millis(), "preferences load complete"); let init_script = build_preferences_script(prefs_json);