diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs index 7d27a3f..2cbc919 100644 --- a/crates/core/src/paths.rs +++ b/crates/core/src/paths.rs @@ -122,25 +122,27 @@ pub enum MigrationStatus { 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 { +/// Probe the legacy magnotia data dir paths on the current platform. +/// Returns the matched legacy path AND its convention-preserving lumotia +/// target so the migration lands the same kind of dir it found (dot-home +/// stays dot-home, XDG stays XDG, macOS Application Support stays the +/// same). +fn legacy_and_target_paths() -> Option<(PathBuf, PathBuf)> { #[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); + let legacy = PathBuf::from(&local_app_data).join("magnotia"); + let target = PathBuf::from(local_app_data).join("lumotia"); + return legacy.exists().then_some((legacy, target)); } #[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); + let app_support = PathBuf::from(home).join("Library").join("Application Support"); + let legacy = app_support.join("Magnotia"); + let target = app_support.join("Lumotia"); + return legacy.exists().then_some((legacy, target)); } #[cfg(target_os = "linux")] @@ -148,72 +150,84 @@ fn legacy_magnotia_data_dir() -> Option { let home = std::env::var("HOME").ok()?; let dot_legacy = PathBuf::from(&home).join(".magnotia"); if dot_legacy.exists() { - return Some(dot_legacy); + return Some((dot_legacy, PathBuf::from(&home).join(".lumotia"))); } if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { - let xdg_legacy = PathBuf::from(xdg).join("magnotia"); + let xdg_legacy = PathBuf::from(&xdg).join("magnotia"); if xdg_legacy.exists() { - return Some(xdg_legacy); + return Some((xdg_legacy, PathBuf::from(xdg).join("lumotia"))); } } } - let xdg_default = PathBuf::from(home) + let xdg_default_legacy = PathBuf::from(&home) .join(".local") .join("share") .join("magnotia"); - xdg_default.exists().then_some(xdg_default) + if xdg_default_legacy.exists() { + let xdg_default_target = PathBuf::from(home) + .join(".local") + .join("share") + .join("lumotia"); + return Some((xdg_default_legacy, xdg_default_target)); + } + None } #[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) + let legacy = PathBuf::from(&home).join(".magnotia"); + let target = PathBuf::from(home).join(".lumotia"); + legacy.exists().then_some((legacy, target)) } } -/// Migrate a legacy magnotia data directory to the new lumotia path on -/// first launch. Idempotent: safe to call on every boot. +/// Migrate a legacy magnotia data directory to its convention-preserving +/// lumotia equivalent on first launch. Idempotent: safe to call on every +/// boot. /// /// Rules: -/// * If the new path already exists, do nothing and return +/// * If the resolved target 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 only the legacy path exists, rename it to the matching lumotia +/// target (same parent dir / same convention) 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()) +/// +/// Callers should treat `Err` as a hard startup failure; silently +/// continuing past a migration error orphans user data behind a fresh +/// empty lumotia dir. +pub fn migrate_legacy_data_dir() -> Result { + migrate_legacy_data_dir_inner(legacy_and_target_paths()) } -/// Test-friendly inner shape: takes the legacy path explicitly so tests -/// don't depend on platform-specific HOME / LOCALAPPDATA env vars. +/// Test-friendly inner shape: takes the (legacy, target) pair explicitly +/// so tests don't depend on platform-specific HOME / LOCALAPPDATA / XDG +/// env vars. fn migrate_legacy_data_dir_inner( - new_path: &Path, - legacy: Option, + pair: Option<(PathBuf, PathBuf)>, ) -> Result { - if new_path.exists() { - return Ok(MigrationStatus::TargetAlreadyExists { - target: new_path.to_path_buf(), - }); - } - - let Some(from) = legacy else { + let Some((from, to)) = pair else { return Ok(MigrationStatus::NoLegacyFound); }; - if let Some(parent) = new_path.parent() { + if to.exists() { + return Ok(MigrationStatus::TargetAlreadyExists { target: to }); + } + + if let Some(parent) = to.parent() { std::fs::create_dir_all(parent)?; } - std::fs::rename(&from, new_path)?; + std::fs::rename(&from, &to)?; - let renamed_db = rename_db_file_if_present(new_path)?; + let renamed_db = rename_db_file_if_present(&to)?; Ok(MigrationStatus::Migrated { from, - to: new_path.to_path_buf(), + to, renamed_db, }) } @@ -224,9 +238,6 @@ fn rename_db_file_if_present(dir: &Path) -> Result { 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) } @@ -268,13 +279,13 @@ mod tests { 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"); + let target = 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"); + let result = migrate_legacy_data_dir_inner(Some((legacy.clone(), target.clone()))) + .expect("migrate ok"); match result { MigrationStatus::Migrated { @@ -283,17 +294,17 @@ mod tests { renamed_db, } => { assert_eq!(from, legacy); - assert_eq!(to, new_path); + assert_eq!(to, target); 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!(target.exists(), "new dir should exist"); + assert!(target.join("lumotia.db").exists(), "db at new name"); + assert!(!target.join("magnotia.db").exists(), "old db gone"); assert!( - new_path.join("recordings.placeholder").exists(), + target.join("recordings.placeholder").exists(), "other files preserved" ); @@ -304,25 +315,25 @@ mod tests { 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"); + let target = 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::create_dir_all(&target).unwrap(); + std::fs::write(target.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"); + let result = migrate_legacy_data_dir_inner(Some((legacy.clone(), target.clone()))) + .expect("migrate ok"); assert_eq!( result, MigrationStatus::TargetAlreadyExists { - target: new_path.clone() + target: target.clone() } ); assert!(legacy.exists(), "legacy dir preserved"); - assert!(new_path.exists(), "new dir preserved"); + assert!(target.exists(), "new dir preserved"); assert_eq!( - std::fs::read(new_path.join("lumotia.db")).unwrap(), + std::fs::read(target.join("lumotia.db")).unwrap(), b"new-data".to_vec(), "lumotia.db not overwritten" ); @@ -332,27 +343,21 @@ mod tests { #[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"); + let result = migrate_legacy_data_dir_inner(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"); + let target = 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"); + let result = migrate_legacy_data_dir_inner(Some((legacy.clone(), target.clone()))) + .expect("migrate ok"); match result { MigrationStatus::Migrated { renamed_db, .. } => { @@ -360,8 +365,28 @@ mod tests { } other => panic!("expected Migrated, got {other:?}"), } - assert!(new_path.exists()); - assert!(new_path.join("recordings.placeholder").exists()); + assert!(target.exists()); + assert!(target.join("recordings.placeholder").exists()); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migrate_preserves_dot_home_to_dot_home_convention() { + // Regression for B2 from Phase 5 QC: dot-home legacy must land in + // dot-home target, not XDG target. + let root = unique_tmp("convention"); + let legacy = root.join(".magnotia"); + let target = root.join(".lumotia"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("magnotia.db"), b"data").unwrap(); + + let result = migrate_legacy_data_dir_inner(Some((legacy.clone(), target.clone()))) + .expect("migrate ok"); + + assert!(matches!(result, MigrationStatus::Migrated { .. })); + assert!(target.exists()); + assert!(target.join("lumotia.db").exists()); std::fs::remove_dir_all(&root).ok(); } diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 7a9cfcd..d3d9fab 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -908,11 +908,18 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result> /// 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( +/// Idempotent. Returns `(renamed, orphans_deleted)`: +/// * `renamed` — rows where only the magnotia key existed; the row's key +/// is updated to the lumotia equivalent. +/// * `orphans_deleted` — rows where BOTH keys existed; the lumotia row is +/// authoritative and the magnotia row is deleted to avoid silent debt. +pub async fn migrate_legacy_setting_keys(pool: &SqlitePool) -> Result<(u64, u64)> { + let mut tx = pool.begin().await.map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:begin".into(), + source, + })?; + + let renamed = sqlx::query( "UPDATE settings \ SET key = 'lumotia_' || substr(key, length('magnotia_') + 1) \ WHERE key LIKE 'magnotia_%' \ @@ -921,13 +928,36 @@ pub async fn migrate_legacy_setting_keys(pool: &SqlitePool) -> Result { WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ )", ) - .execute(pool) + .execute(&mut *tx) .await .map_err(|source| Error::Query { - operation: "migrate_legacy_setting_keys".into(), + operation: "migrate_legacy_setting_keys:rename".into(), + source, + })? + .rows_affected(); + + let deleted = sqlx::query( + "DELETE FROM settings \ + WHERE key LIKE 'magnotia_%' \ + AND EXISTS (\ + SELECT 1 FROM settings AS dst \ + WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ + )", + ) + .execute(&mut *tx) + .await + .map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:delete_orphans".into(), + source, + })? + .rows_affected(); + + tx.commit().await.map_err(|source| Error::Query { + operation: "migrate_legacy_setting_keys:commit".into(), source, })?; - Ok(result.rows_affected()) + + Ok((renamed, deleted)) } // --- Row types --- @@ -2740,4 +2770,94 @@ mod tests { .unwrap(); assert_eq!(remaining, vec!["recent".to_string()]); } + + #[tokio::test] + async fn migrate_legacy_setting_keys_renames_lone_magnotia_rows() { + let pool = test_pool().await; + set_setting(&pool, "magnotia_preferences", "blob-A") + .await + .unwrap(); + set_setting(&pool, "magnotia_morning_triage_last_shown", "2026-05-12") + .await + .unwrap(); + + let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(renamed, 2); + assert_eq!(deleted, 0); + assert_eq!( + get_setting(&pool, "lumotia_preferences") + .await + .unwrap() + .as_deref(), + Some("blob-A") + ); + assert_eq!( + get_setting(&pool, "magnotia_preferences").await.unwrap(), + None + ); + assert_eq!( + get_setting(&pool, "lumotia_morning_triage_last_shown") + .await + .unwrap() + .as_deref(), + Some("2026-05-12") + ); + } + + #[tokio::test] + async fn migrate_legacy_setting_keys_deletes_orphans_when_both_present() { + let pool = test_pool().await; + set_setting(&pool, "magnotia_preferences", "legacy-blob") + .await + .unwrap(); + set_setting(&pool, "lumotia_preferences", "current-blob") + .await + .unwrap(); + + let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(renamed, 0); + assert_eq!(deleted, 1); + assert_eq!( + get_setting(&pool, "lumotia_preferences") + .await + .unwrap() + .as_deref(), + Some("current-blob"), + "lumotia row preserved verbatim" + ); + assert_eq!( + get_setting(&pool, "magnotia_preferences").await.unwrap(), + None, + "orphan magnotia row deleted" + ); + } + + #[tokio::test] + async fn migrate_legacy_setting_keys_no_op_when_no_magnotia_rows() { + let pool = test_pool().await; + set_setting(&pool, "lumotia_preferences", "blob") + .await + .unwrap(); + + let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(renamed, 0); + assert_eq!(deleted, 0); + } + + #[tokio::test] + async fn migrate_legacy_setting_keys_is_idempotent() { + let pool = test_pool().await; + set_setting(&pool, "magnotia_preferences", "blob") + .await + .unwrap(); + + let first = migrate_legacy_setting_keys(&pool).await.unwrap(); + let second = migrate_legacy_setting_keys(&pool).await.unwrap(); + + assert_eq!(first, (1, 0)); + assert_eq!(second, (0, 0)); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index edcd134..bd20846 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,8 +16,8 @@ use lumotia_core::paths::{migrate_legacy_data_dir, MigrationStatus}; use lumotia_core::types::EngineName; use lumotia_llm::LlmEngine; use lumotia_storage::{ - app_data_dir, database_path, get_setting, init as init_db, migrate_legacy_setting_keys, - prune_error_log, set_setting, + 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. @@ -225,11 +225,13 @@ 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. + // (and macOS/Windows equivalents) to the convention-preserving + // lumotia path on first launch after the rebrand. Idempotent — + // safe to call on every boot. A migration error is fatal: silently + // continuing past it would orphan the user's transcripts and + // settings behind a fresh empty lumotia dir. let t_migrate = Instant::now(); - let new_data_dir = app_data_dir(); - match migrate_legacy_data_dir(&new_data_dir) { + match migrate_legacy_data_dir() { Ok(MigrationStatus::Migrated { from, to, renamed_db }) => tracing::info!( target: "lumotia_startup", elapsed_ms = t_migrate.elapsed().as_millis(), @@ -240,11 +242,14 @@ pub fn run() { ), Ok(MigrationStatus::TargetAlreadyExists { .. }) => {} Ok(MigrationStatus::NoLegacyFound) => {} - Err(e) => tracing::warn!( - target: "lumotia_startup", - error = %e, - "legacy data dir migration failed — continuing with new path" - ), + Err(e) => { + tracing::error!( + target: "lumotia_startup", + error = %e, + "legacy data dir migration failed — refusing to start (would orphan user data)" + ); + return Err(Box::new(e) as Box); + } } // Initialise database and startup settings in one runtime entry. @@ -258,15 +263,17 @@ pub fn run() { // One-shot settings-key migration: rename any leftover // `magnotia_*` rows from the magnotia era to `lumotia_*`. - // Idempotent; logs only when rows are actually renamed. + // Deletes any magnotia_ rows that are orphans of an existing + // lumotia_ row. Idempotent; logs only when rows actually move. let t_keys = Instant::now(); match migrate_legacy_setting_keys(&db).await { - Ok(0) => {} - Ok(n) => tracing::info!( + Ok((0, 0)) => {} + Ok((renamed, orphans_deleted)) => tracing::info!( target: "lumotia_startup", - rows_renamed = n, + rows_renamed = renamed, + orphans_deleted = orphans_deleted, elapsed_ms = t_keys.elapsed().as_millis(), - "renamed legacy magnotia_* settings keys to lumotia_*" + "migrated legacy magnotia_* settings keys to lumotia_*" ), Err(e) => tracing::warn!( target: "lumotia_startup",