use std::path::Path;
use anyhow::{Context, Result};
use crate::service::colocated_storage::ensure_gitignored;
use crate::service::roots_registry::upsert_root;
pub(super) const DATA_FILES: &[&str] = &[
"index.redb",
"hnsw.usearch",
"hnsw.keys.json",
"chunks.json",
"schema_version.json",
];
pub(super) type MigrateResult = Result<usize>;
pub(super) fn do_migrate_with_pointer_removal(
pointer_path: &Path,
src_dir: &Path,
dst_dir: &Path,
root_path: &Path,
) -> MigrateResult {
std::fs::remove_file(pointer_path)
.with_context(|| format!("remove legacy pointer file {}", pointer_path.display()))?;
tracing::info!(
"migrate storage: removed legacy pointer file {}",
pointer_path.display()
);
move_data_files(src_dir, dst_dir, root_path)
}
pub(super) fn move_data_files(src_dir: &Path, dst_dir: &Path, root_path: &Path) -> MigrateResult {
std::fs::create_dir_all(dst_dir)
.with_context(|| format!("create colocated dir {}", dst_dir.display()))?;
let mut moved = 0usize;
for &file_name in DATA_FILES {
let from = src_dir.join(file_name);
if !from.exists() {
continue; }
let to = dst_dir.join(file_name);
if std::fs::rename(&from, &to).is_ok() {
moved += 1;
tracing::debug!(
"migrate storage: renamed {} → {}",
from.display(),
to.display()
);
} else {
copy_verify_then_remove(&from, &to)?;
moved += 1;
}
}
tracing::info!(
"migrate storage: moved {moved} file(s) from {} → {}",
src_dir.display(),
dst_dir.display()
);
upsert_root(root_path.to_path_buf()).context("register root in roots.toml")?;
if let Err(e) = ensure_gitignored(root_path) {
tracing::warn!(
"migrate storage: could not add .gitignore entry for {}: {e}",
root_path.display()
);
}
Ok(moved)
}
fn copy_verify_then_remove(from: &Path, to: &Path) -> Result<()> {
let src_len = std::fs::metadata(from)
.with_context(|| format!("stat source file {}", from.display()))?
.len();
std::fs::copy(from, to)
.with_context(|| format!("copy {} → {}", from.display(), to.display()))?;
let dst_len = std::fs::metadata(to)
.with_context(|| format!("stat dest file after copy {}", to.display()))?
.len();
if dst_len != src_len {
return Err(anyhow::anyhow!(
"size mismatch after copy: src={} ({} bytes) dst={} ({} bytes) — source NOT deleted",
from.display(),
src_len,
to.display(),
dst_len,
));
}
std::fs::remove_file(from)
.with_context(|| format!("remove source file {} after verified copy", from.display()))?;
tracing::debug!(
"migrate storage: copy-verify-remove {} → {} ({} bytes)",
from.display(),
to.display(),
dst_len
);
Ok(())
}
pub(super) fn try_remove_empty_src_dir(src_dir: &Path) {
if let Err(e) = std::fs::remove_dir(src_dir) {
tracing::debug!(
"migrate storage: could not remove source dir {} ({e}) — non-fatal",
src_dir.display()
);
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::commands::migrate_storage::classify::{classify_index, IndexMigrationClass};
use crate::service::persistence::data_dir;
use serial_test::serial;
use tempfile::tempdir;
fn write_file(dir: &Path, name: &str, content: &[u8]) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(name), content).unwrap();
}
#[test]
#[serial]
fn migrate_needs_migration_moves_files() {
let data_tmp = tempdir().unwrap();
unsafe { std::env::set_var("TRUSTY_DATA_DIR", data_tmp.path()) };
let root = tempdir().unwrap();
let src = data_dir().unwrap().join("indexes").join("mig-test");
write_file(&src, "index.redb", b"redb-content");
write_file(&src, "hnsw.usearch", b"hnsw-content");
write_file(&src, "schema_version.json", b"{}");
let dst = root.path().join(".trusty-search");
let moved = move_data_files(&src, &dst, root.path()).unwrap();
assert_eq!(moved, 3, "three files should move");
assert!(dst.join("index.redb").exists());
assert_eq!(
std::fs::read(dst.join("index.redb")).unwrap(),
b"redb-content"
);
assert!(dst.join("hnsw.usearch").exists());
assert!(dst.join("schema_version.json").exists());
let class = classify_index("mig-test", root.path());
assert_eq!(
class,
IndexMigrationClass::AlreadyColocated,
"post-migration classify must be AlreadyColocated (idempotency)"
);
if src.exists() {
assert!(
!src.join("index.redb").exists(),
"source index.redb must be gone"
);
}
unsafe { std::env::remove_var("TRUSTY_DATA_DIR") };
}
#[test]
#[serial]
fn migrate_legacy_pointer_file_removes_pointer_and_moves_data() {
let data_tmp = tempdir().unwrap();
unsafe { std::env::set_var("TRUSTY_DATA_DIR", data_tmp.path()) };
let root = tempdir().unwrap();
let pointer = root.path().join(".trusty-search");
std::fs::write(&pointer, b"index = \"ptr-idx\"").unwrap();
assert!(pointer.is_file());
let src = data_dir().unwrap().join("indexes").join("ptr-idx");
write_file(&src, "index.redb", b"ptr-redb");
write_file(&src, "hnsw.usearch", b"ptr-hnsw");
let dst = root.path().join(".trusty-search");
let moved = do_migrate_with_pointer_removal(&pointer, &src, &dst, root.path()).unwrap();
assert_eq!(moved, 2);
let dst_path = root.path().join(".trusty-search");
assert!(dst_path.is_dir(), ".trusty-search must now be a directory");
assert!(dst_path.join("index.redb").exists());
assert!(dst_path.join("hnsw.usearch").exists());
assert_eq!(
std::fs::read(dst_path.join("index.redb")).unwrap(),
b"ptr-redb"
);
let class = classify_index("ptr-idx", root.path());
assert_eq!(
class,
IndexMigrationClass::AlreadyColocated,
"post LegacyPointerFile migration must be AlreadyColocated"
);
unsafe { std::env::remove_var("TRUSTY_DATA_DIR") };
}
#[test]
fn migrate_data_safety_no_delete_on_verify_fail() {
let src_dir = tempdir().unwrap();
let dst_dir = tempdir().unwrap();
let from = src_dir.path().join("test.bin");
let to = dst_dir.path().join("test.bin");
std::fs::write(&from, b"source-data").unwrap();
std::fs::write(&to, b"wrong").unwrap();
let src2 = src_dir.path().join("real.bin");
let dst2 = dst_dir.path().join("real.bin");
std::fs::write(&src2, b"real-data").unwrap();
copy_verify_then_remove(&src2, &dst2).unwrap();
assert!(!src2.exists(), "source must be removed after verified copy");
assert!(dst2.exists());
let src3 = src_dir.path().join("safe.bin");
let dst3 = dst_dir.path().join("safe.bin");
std::fs::write(&src3, b"safety-check").unwrap();
copy_verify_then_remove(&src3, &dst3).unwrap();
assert!(!src3.exists(), "source removed after verified copy");
assert!(dst3.exists());
}
#[test]
#[serial]
fn migrate_idempotent_rerun() {
let data_tmp = tempdir().unwrap();
unsafe { std::env::set_var("TRUSTY_DATA_DIR", data_tmp.path()) };
let root = tempdir().unwrap();
let src = data_dir().unwrap().join("indexes").join("idem-test");
write_file(&src, "index.redb", b"idem-redb");
let dst = root.path().join(".trusty-search");
move_data_files(&src, &dst, root.path()).unwrap();
let class = classify_index("idem-test", root.path());
assert_eq!(
class,
IndexMigrationClass::AlreadyColocated,
"second run must classify AlreadyColocated"
);
assert_eq!(std::fs::read(dst.join("index.redb")).unwrap(), b"idem-redb");
unsafe { std::env::remove_var("TRUSTY_DATA_DIR") };
}
}