diff --git a/Cargo.lock b/Cargo.lock index 1cf2e84..6b084a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2892,6 +2892,7 @@ dependencies = [ "lumotia-core", "serde", "sqlx", + "tempfile", "thiserror 1.0.69", "tokio", "uuid", diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs index 0e3540d..0cdbb7f 100644 --- a/crates/core/src/paths.rs +++ b/crates/core/src/paths.rs @@ -115,11 +115,7 @@ fn resolve_app_data_dir() -> PathBuf { /// relying on the panic that backs the infallible `resolve_app_data_dir`. pub fn resolve_app_data_dir_strict() -> Result { let candidates = target_data_dir_candidates(); - let existing: Vec = candidates - .iter() - .filter(|p| p.exists()) - .cloned() - .collect(); + let existing: Vec = candidates.iter().filter(|p| p.exists()).cloned().collect(); if existing.len() > 1 { return Err(TargetAmbiguityError { candidates: existing, @@ -382,17 +378,19 @@ fn legacy_and_target_paths() -> Vec<(PathBuf, PathBuf)> { /// target (same convention) and rename `magnotia.db` -> `lumotia.db` /// inside it if found. pub fn migrate_legacy_data_dir() -> Result, std::io::Error> { - migrate_legacy_data_dir_inner(legacy_and_target_paths()) + migrate_legacy_data_dir_with_pairs(legacy_and_target_paths()) } -/// Test-friendly inner shape: takes the list of (legacy, target) pairs -/// explicitly so tests don't depend on platform-specific HOME / -/// LOCALAPPDATA / XDG env vars. +/// Driver that takes the list of (legacy, target) pairs explicitly so +/// callers can substitute synthetic paths. Production path goes through +/// [`migrate_legacy_data_dir`], which resolves the pairs from +/// platform-specific HOME / LOCALAPPDATA / XDG env vars. Integration +/// tests in sibling crates call this directly with tempdir pairs. /// /// An empty input is shorthand for "no legacy on disk" and yields a /// single [`MigrationStatus::NoLegacyFound`] entry so callers can still /// rely on a non-empty result to drive their logging. -fn migrate_legacy_data_dir_inner( +pub fn migrate_legacy_data_dir_with_pairs( pairs: Vec<(PathBuf, PathBuf)>, ) -> Result, std::io::Error> { if pairs.is_empty() { @@ -517,8 +515,26 @@ pub fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error> } } else if file_type.is_dir() { copy_dir_recursive(&entry_path, &target_path)?; - } else { + } else if file_type.is_file() { std::fs::copy(&entry_path, &target_path)?; + } else { + // Anything that is neither a symlink, a directory, nor a + // regular file lands here: on Unix that's FIFOs, sockets, + // and character / block device nodes. `std::fs::copy()` on + // a FIFO would block forever waiting for a writer, and on + // a device node would either fail unpredictably or attempt + // to read until the device's end-of-stream. Both turn a + // legacy-dir leftover into a silent migration hang. We + // refuse to cross the boundary and surface the path so + // the user can clean it up manually. The migration is + // re-runnable once the offending node is removed. + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "refusing to copy non-regular filesystem object during migration: {}", + entry_path.display() + ), + )); } } Ok(()) @@ -582,10 +598,8 @@ mod tests { /// Helper: drive the migration with a single (legacy, target) pair /// and return the (only) status it produced. Keeps existing tests /// readable after the Option -> Vec API change. - fn migrate_one_pair_inner( - pair: (PathBuf, PathBuf), - ) -> Result { - let mut statuses = migrate_legacy_data_dir_inner(vec![pair])?; + fn migrate_one_pair_inner(pair: (PathBuf, PathBuf)) -> Result { + let mut statuses = migrate_legacy_data_dir_with_pairs(vec![pair])?; assert_eq!( statuses.len(), 1, @@ -603,8 +617,7 @@ mod tests { std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap(); std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); - let result = - migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); match result { MigrationStatus::Migrated { @@ -640,8 +653,7 @@ mod tests { 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_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); assert_eq!( result, @@ -662,7 +674,7 @@ mod tests { #[test] fn migrate_with_neither_present_returns_no_legacy() { - let result = migrate_legacy_data_dir_inner(Vec::new()).expect("migrate ok"); + let result = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("migrate ok"); assert_eq!(result, vec![MigrationStatus::NoLegacyFound]); } @@ -675,8 +687,7 @@ mod tests { std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); - let result = - migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); match result { MigrationStatus::Migrated { renamed_db, .. } => { @@ -700,8 +711,7 @@ mod tests { std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("magnotia.db"), b"data").unwrap(); - let result = - migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); + let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); assert!(matches!(result, MigrationStatus::Migrated { .. })); assert!(target.exists()); @@ -721,16 +731,15 @@ mod tests { std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap(); std::fs::create_dir_all(src.join("models/whisper-base-en")).unwrap(); std::fs::write(src.join("magnotia.db"), b"sqlite-bytes").unwrap(); - std::fs::write( - src.join("recordings/2026-05/clip-001.wav"), - b"wav-bytes", - ) - .unwrap(); + std::fs::write(src.join("recordings/2026-05/clip-001.wav"), b"wav-bytes").unwrap(); std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap(); copy_dir_recursive(&src, &dst).expect("copy ok"); - assert_eq!(std::fs::read(dst.join("magnotia.db")).unwrap(), b"sqlite-bytes"); + assert_eq!( + std::fs::read(dst.join("magnotia.db")).unwrap(), + b"sqlite-bytes" + ); assert_eq!( std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(), b"wav-bytes" @@ -806,7 +815,7 @@ mod tests { std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap(); std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap(); - let statuses = migrate_legacy_data_dir_inner(vec![ + let statuses = migrate_legacy_data_dir_with_pairs(vec![ (dot_legacy.clone(), dot_target.clone()), (xdg_legacy.clone(), xdg_target.clone()), ]) @@ -975,7 +984,7 @@ mod tests { let dst_link_entry = entries .iter() .find_map(|e| e.as_ref().ok()) - .filter(|e| e.file_name() == std::ffi::OsString::from("link")); + .filter(|e| e.file_name() == "link"); if let Some(e) = dst_link_entry { assert!( e.file_type().unwrap().is_symlink(), @@ -985,4 +994,171 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + + // ------------------------------------------------------------------ + // Adversarial probes: hostile filesystem objects inside the legacy + // tree that could turn a benign cross-device migration into a hang, + // a silent data loss, or an unhelpful panic. These exercise the + // copy_dir_recursive fall-through after the symlink and directory + // branches have been ruled out. Same-filesystem rename via + // `std::fs::rename` is atomic and bypasses these paths entirely; we + // only need to defend the EXDEV copy fallback. + // ------------------------------------------------------------------ + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_rejects_fifo_in_legacy_tree_without_hanging() { + // A FIFO inside the legacy tree must NOT cause copy_dir_recursive + // to block on std::fs::copy (open-for-read on a FIFO with no + // writer blocks indefinitely). The hardened branch surfaces an + // Unsupported error naming the offending path, leaving the + // partial destination on disk for the user to consult before + // retrying. + let root = unique_tmp("fifo-rejected"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("regular.txt"), b"normal-data").unwrap(); + + let fifo_path = src.join("debug-pipe"); + // mkfifo via the system binary keeps the test free of an extra + // libc dev-dependency. The migration only needs the FIFO to be + // present on disk so file_type().is_fifo() returns true. + let status = std::process::Command::new("mkfifo") + .arg(&fifo_path) + .status() + .expect("mkfifo invocation must run on a unix test host"); + assert!(status.success(), "mkfifo must succeed"); + + // Bound the test against the regression: if a future refactor + // re-introduces the std::fs::copy fall-through, the FIFO read + // would hang forever and stall CI. We run copy_dir_recursive + // on a worker thread and require it to return within a tight + // budget; a slow CI host gets 5 seconds, which is many orders + // of magnitude above the expected ~ms return. + let src_owned = src.clone(); + let dst_owned = dst.clone(); + let handle = std::thread::spawn(move || copy_dir_recursive(&src_owned, &dst_owned)); + let start = std::time::Instant::now(); + let result = loop { + if handle.is_finished() { + break handle.join().expect("worker thread must not panic"); + } + if start.elapsed() > std::time::Duration::from_secs(5) { + panic!( + "copy_dir_recursive hung on a FIFO inside the legacy tree; \ + suspected regression in the non-regular fall-through guard" + ); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + + let err = result.expect_err("FIFO inside legacy tree must surface an error"); + assert_eq!(err.kind(), std::io::ErrorKind::Unsupported); + let msg = err.to_string(); + assert!( + msg.contains("debug-pipe"), + "error must name the offending path: {msg}" + ); + + // The partial destination may or may not contain the regular + // file depending on read_dir iteration order; we don't assert + // either way. What matters is that the migration returns an + // error rather than blocking forever. + std::fs::remove_dir_all(&root).ok(); + } + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_surfaces_permission_error_on_unreadable_file() { + // A legacy file with mode 0000 inside the tree must cause + // copy_dir_recursive to fail loud with PermissionDenied, not + // silently skip the file (which would orphan user data). The + // user can chmod the file and retry. + use std::os::unix::fs::PermissionsExt; + + let root = unique_tmp("unreadable-file"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + let locked = src.join("locked.db"); + std::fs::write(&locked, b"sensitive").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + + // Belt-and-braces against test-running-as-root: root bypasses + // DAC permissions and would silently succeed, masking the + // regression. Skip the assertion in that case so the test is + // honest about what it proved. + let running_as_root = effective_uid() == 0; + + let result = copy_dir_recursive(&src, &dst); + + // Restore permissions before TempDir cleanup, regardless of + // the test outcome, so the tempdir teardown doesn't itself + // hit EACCES. + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).ok(); + + if running_as_root { + // Document the bypass; the test still has value in CI + // where the runner is non-root. + assert!( + result.is_ok() || result.is_err(), + "test result intentionally not asserted under euid 0" + ); + } else { + let err = result.expect_err("unreadable file must surface an error"); + assert_eq!( + err.kind(), + std::io::ErrorKind::PermissionDenied, + "expected PermissionDenied, got {err:?}" + ); + } + + std::fs::remove_dir_all(&root).ok(); + } + + /// Read the effective uid by inspecting a freshly-created file's + /// owner. Used by the permission-denied probe to skip its core + /// assertion when the test host runs as root (root bypasses DAC). + /// Direct over a libc dev-dependency for one helper. + #[cfg(unix)] + fn effective_uid() -> u32 { + use std::os::unix::fs::MetadataExt; + let tmp = std::env::temp_dir().join(format!("euid-probe-{}", std::process::id())); + std::fs::write(&tmp, b"x").unwrap(); + let uid = std::fs::metadata(&tmp).unwrap().uid(); + std::fs::remove_file(&tmp).ok(); + uid + } + + #[cfg(unix)] + #[test] + fn copy_dir_recursive_preserves_dangling_symlink_target() { + // A legacy symlink pointing at a since-deleted target must be + // recreated as a dangling symlink at the destination — NOT + // dereferenced (which would fail) and NOT skipped (which would + // silently drop a piece of the user's directory shape). + let root = unique_tmp("dangling-symlink"); + let src = root.join("legacy"); + let dst = root.join("new"); + std::fs::create_dir_all(&src).unwrap(); + std::os::unix::fs::symlink("/no/such/path/ever", src.join("orphan")).unwrap(); + + copy_dir_recursive(&src, &dst).expect("dangling symlink must not abort the copy"); + + let orphan = dst.join("orphan"); + let meta = std::fs::symlink_metadata(&orphan).expect("orphan must exist as a link"); + assert!( + meta.file_type().is_symlink(), + "dst/orphan must be a symlink, not a regular file or directory" + ); + let link_target = std::fs::read_link(&orphan).unwrap(); + assert_eq!( + link_target, + std::path::PathBuf::from("/no/such/path/ever"), + "symlink target must be preserved verbatim" + ); + + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 4442f32..12992e4 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -29,3 +29,12 @@ thiserror = "1" # UUIDs for profile + profile_terms ids (v7 random). uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +# Real-file tempdirs for integration tests that need an on-disk DB (the +# in-memory `sqlite::memory:` connection in src/database.rs unit tests +# doesn't cover the rename-then-reopen path the rebrand migration exercises). +tempfile = "3" +# `rt-multi-thread` is required for the `#[tokio::test(flavor = "multi_thread")]` +# variants that drive blocking lumotia_core::paths migrations from async tests. +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros"] } diff --git a/crates/storage/tests/legacy_db_migration.rs b/crates/storage/tests/legacy_db_migration.rs new file mode 100644 index 0000000..750c3ce --- /dev/null +++ b/crates/storage/tests/legacy_db_migration.rs @@ -0,0 +1,282 @@ +//! End-to-end migration test for the Magnotia -> Lumotia rebrand. +//! +//! The in-crate unit tests in `crates/core/src/paths.rs` prove that the +//! migration copies bytes and renames files correctly, but they don't +//! verify that the resulting `lumotia.db` is *openable* by +//! `lumotia-storage`. A byte-perfect copy with a torn SQLite header, +//! a stale WAL pointer, or a row count off by one would still pass +//! those tests. This integration test closes that gap by: +//! +//! 1. Seeding a real on-disk `magnotia/magnotia.db` via the public +//! `lumotia_storage::init` API (which runs every schema migration +//! head-to-tail). A real transcript row is inserted via +//! `insert_transcript`, then the pool is dropped to flush + close. +//! 2. Running the rebrand migration via +//! `lumotia_core::paths::migrate_legacy_data_dir_with_pairs` with +//! the synthesised (legacy, target) pair. +//! 3. Re-opening the migrated `lumotia/lumotia.db` via `init` +//! (which re-runs migrations — they must be no-ops against the +//! already-migrated schema) and querying for the inserted +//! transcript by id. +//! +//! If any of the three steps fails — rename, reopen, or query — the +//! migration is unsafe to ship even though the unit tests pass. + +use std::path::Path; + +use lumotia_core::paths::{migrate_legacy_data_dir_with_pairs, MigrationStatus}; +use lumotia_storage::{ + get_transcript, init, insert_transcript, list_transcripts, InsertTranscriptParams, + DEFAULT_PROFILE_ID, +}; +use tempfile::TempDir; + +const SEEDED_TRANSCRIPT_ID: &str = "t-rebrand-survivor"; +const SEEDED_TRANSCRIPT_TEXT: &str = + "Migration sentinel row. If this read fails, the rebrand orphaned user data."; +const SEEDED_TRANSCRIPT_TITLE: &str = "rebrand-survivor"; + +/// Drop the pool and yield long enough for sqlx's background reaper to +/// close its connections. The integration test re-opens the migrated DB +/// immediately afterwards, so any sqlx-held file descriptor on Windows +/// would block the rename. tokio's `yield_now` is a single scheduler +/// hop, not a sleep — enough to flush the pool's tokio task queue. +async fn drop_and_yield(pool: sqlx::SqlitePool) { + pool.close().await; + tokio::task::yield_now().await; +} + +/// Build the canonical seed-row params. Centralised so the assertions +/// in each test can reference the same expected values without drift. +fn seed_params() -> InsertTranscriptParams<'static> { + InsertTranscriptParams { + id: SEEDED_TRANSCRIPT_ID, + text: SEEDED_TRANSCRIPT_TEXT, + source: "microphone", + profile_id: DEFAULT_PROFILE_ID, + title: Some(SEEDED_TRANSCRIPT_TITLE), + audio_path: None, + duration: 2.5, + engine: Some("whisper"), + model_id: Some("whisper-tiny-en"), + inference_ms: Some(420), + sample_rate: Some(16_000), + audio_channels: Some(1), + format_mode: Some("plain"), + remove_fillers: false, + british_english: true, + anti_hallucination: false, + } +} + +/// Seed `/magnotia.db` with the live schema head and one +/// transcript row. Returns nothing; the asserts live in the test body. +async fn seed_legacy_db(legacy_dir: &Path) { + std::fs::create_dir_all(legacy_dir).expect("create legacy dir"); + let legacy_db = legacy_dir.join("magnotia.db"); + + let pool = init(&legacy_db).await.expect("init legacy magnotia.db"); + insert_transcript(&pool, &seed_params()) + .await + .expect("insert seed transcript into legacy db"); + drop_and_yield(pool).await; + + assert!( + legacy_db.exists(), + "legacy magnotia.db should be on disk after pool drop" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn legacy_db_survives_rebrand_and_remains_queryable() { + let tmp = TempDir::new().expect("tempdir"); + let legacy = tmp.path().join("magnotia"); + let target = tmp.path().join("lumotia"); + + seed_legacy_db(&legacy).await; + + // Bonus: drop a non-DB file inside the legacy tree to confirm the + // migration sweeps the whole directory, not just the .db file. + let companion = legacy + .join("recordings") + .join("2026-05-13") + .join("clip.wav"); + std::fs::create_dir_all(companion.parent().unwrap()).expect("nested dir"); + std::fs::write(&companion, b"fake-wav-bytes").expect("write companion file"); + + // Migrate. Blocking I/O is fine inside the multi-thread flavour. + let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) + .expect("migration must not error"); + + assert_eq!(statuses.len(), 1, "single pair must yield single status"); + match &statuses[0] { + MigrationStatus::Migrated { + from, + to, + renamed_db, + } => { + assert_eq!(from, &legacy); + assert_eq!(to, &target); + assert!(renamed_db, "magnotia.db must have been renamed"); + } + other => panic!("expected Migrated, got {other:?}"), + } + + assert!(!legacy.exists(), "legacy dir should be gone after rename"); + assert!(target.exists(), "target dir should exist after rename"); + let migrated_db = target.join("lumotia.db"); + assert!(migrated_db.exists(), "lumotia.db must be at new path"); + assert!( + !target.join("magnotia.db").exists(), + "old db filename must not survive at new path" + ); + let migrated_companion = target + .join("recordings") + .join("2026-05-13") + .join("clip.wav"); + assert!( + migrated_companion.exists(), + "non-DB files inside legacy must be carried along" + ); + assert_eq!( + std::fs::read(&migrated_companion).expect("read migrated companion"), + b"fake-wav-bytes", + "non-DB file contents must survive verbatim" + ); + + // Re-open via the same public API a fresh app boot would use. This + // also re-runs `run_migrations`, which must be idempotent against + // the already-migrated schema. + let pool = init(&migrated_db) + .await + .expect("init migrated lumotia.db must succeed"); + + let found = get_transcript(&pool, SEEDED_TRANSCRIPT_ID) + .await + .expect("get_transcript must not error against migrated db") + .expect("seeded transcript must survive the migration"); + + assert_eq!(found.id, SEEDED_TRANSCRIPT_ID); + assert_eq!(found.text, SEEDED_TRANSCRIPT_TEXT); + assert_eq!(found.title.as_deref(), Some(SEEDED_TRANSCRIPT_TITLE)); + + // And the list view sees exactly one row, ruling out duplicates or + // schema-rebuild-from-empty (which would yield zero). + let listed = list_transcripts(&pool, 100) + .await + .expect("list_transcripts must not error against migrated db"); + assert_eq!( + listed.len(), + 1, + "exactly one transcript should be visible post-migration" + ); + assert_eq!(listed[0].id, SEEDED_TRANSCRIPT_ID); + + drop_and_yield(pool).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn rerun_after_migration_is_idempotent_and_preserves_user_state() { + // First boot: migrate. Second boot: legacy preserved? Per the design + // the legacy DOES NOT preserve (rename moves it; see paths.rs + // rename_or_copy_tree). So the second run sees no legacy and yields + // NoLegacyFound. This test exists to nail down that contract — if a + // future change starts copying-rather-than-renaming, the test will + // catch the subsequent loss of idempotency. + let tmp = TempDir::new().expect("tempdir"); + let legacy = tmp.path().join("magnotia"); + let target = tmp.path().join("lumotia"); + + seed_legacy_db(&legacy).await; + + let first = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) + .expect("first migration"); + assert!(matches!(first[0], MigrationStatus::Migrated { .. })); + + // User immediately starts using the app: writes new data to the + // migrated DB. The follow-up reboot must NOT clobber this. + let pool = init(&target.join("lumotia.db")) + .await + .expect("post-migrate init"); + insert_transcript( + &pool, + &InsertTranscriptParams { + id: "t-post-migration", + text: "Written after the rebrand boot.", + ..seed_params() + }, + ) + .await + .expect("insert new row post-migration"); + drop_and_yield(pool).await; + + // Second boot: no legacy on disk, nothing to do. Note empty input + // yields a single NoLegacyFound entry by contract. + let second = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("second migration"); + assert_eq!(second, vec![MigrationStatus::NoLegacyFound]); + + // Open and confirm both rows present + user's post-migration write + // intact. + let pool = init(&target.join("lumotia.db")) + .await + .expect("reopen after second boot"); + let listed = list_transcripts(&pool, 100).await.expect("list"); + let ids: Vec<_> = listed.iter().map(|r| r.id.as_str()).collect(); + assert!( + ids.contains(&SEEDED_TRANSCRIPT_ID), + "pre-migration row should survive second boot: {ids:?}" + ); + assert!( + ids.contains(&"t-post-migration"), + "post-migration row should survive second boot: {ids:?}" + ); + drop_and_yield(pool).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn both_paths_present_refuses_to_overwrite_user_data() { + // Reproduces the "stale legacy + freshly-installed lumotia" scenario: + // user reinstalls after deleting their data dir manually, or runs an + // older + newer build side-by-side. The migration must NOT clobber + // the lumotia-side DB even if the legacy contains a richer one — + // user authority on what's authoritative. + let tmp = TempDir::new().expect("tempdir"); + let legacy = tmp.path().join("magnotia"); + let target = tmp.path().join("lumotia"); + + // Both DBs exist; the legacy has the seeded row, the target is + // freshly initialised with no rows. + seed_legacy_db(&legacy).await; + let pool = init(&target.join("lumotia.db")).await.expect("seed target"); + drop_and_yield(pool).await; + + let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) + .expect("migration must not error in both-exist case"); + + assert_eq!(statuses.len(), 1); + assert_eq!( + statuses[0], + MigrationStatus::TargetAlreadyExists { + target: target.clone() + } + ); + + // Critical: target's empty DB must be untouched. + let pool = init(&target.join("lumotia.db")) + .await + .expect("reopen target post-decision"); + let listed = list_transcripts(&pool, 100).await.expect("list"); + assert_eq!( + listed.len(), + 0, + "target's user-installed empty DB must not have been merged with legacy" + ); + drop_and_yield(pool).await; + + // And the legacy DB must still be on disk for manual recovery — + // we don't quietly delete user data the migration refused to copy. + assert!( + legacy.join("magnotia.db").exists(), + "legacy DB must be preserved as a backup when target exists" + ); +}