diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b38fe2..832dd44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- POSIX: support for non-Cygwin Windows environments (e.g. Busybox).
+- Automatically migrate data from the legacy bincode-backed `db.zo` file
+ when upgrading to the sqlite backend (only performed if no
+ `db.sqlite3` exists).
### Fixed
diff --git a/Cargo.lock b/Cargo.lock
index 355185d..8c4e13f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -144,6 +144,15 @@ dependencies = [
"wait-timeout",
]
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "2.9.1"
@@ -1070,6 +1079,7 @@ dependencies = [
"anyhow",
"askama",
"assert_cmd",
+ "bincode",
"clap",
"clap_complete",
"clap_complete_fig",
diff --git a/Cargo.toml b/Cargo.toml
index 54bec44..2cc55b8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ fastrand = "2.0.0"
glob = "0.3.0"
ouroboros = "0.18.3"
serde = { version = "1.0.116", features = ["derive"] }
+bincode = "1.3.1"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.30.1", default-features = false, features = [
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 34709ed..18c4daf 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -6,11 +6,57 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
+use bincode::Options;
use crate::config;
pub use crate::db::dir::{Dir, Epoch, Rank};
pub use crate::db::stream::{Stream, StreamOptions};
+/// Attempt to read an old bincode-formatted database and write its entries into
+/// the provided SQLite connection.
+fn migrate_from_bincode(conn: &mut Connection, old_path: &Path) -> Result<()> {
+ // The on-disk format used by the legacy database is very similar to the
+ // struct definitions we already use. We simply serialise a version number
+ // followed by a `Vec
`.
+ const MAX_SIZE: u64 = 32 << 20; // 32 MiB
+
+ let data = fs::read(old_path)
+ .with_context(|| format!("could not read legacy database: {}", old_path.display()))?;
+
+ let deserializer = &mut bincode::options()
+ .with_fixint_encoding()
+ .with_limit(MAX_SIZE);
+
+ let version_size = deserializer.serialized_size(&Database::VERSION)? as usize;
+ if data.len() < version_size {
+ anyhow::bail!("legacy database is corrupted");
+ }
+ let (bytes_version, bytes_dirs) = data.split_at(version_size);
+
+ let version: u32 = deserializer.deserialize(bytes_version)?;
+ if version != Database::VERSION {
+ anyhow::bail!(
+ "unsupported legacy database version (got {}, expected {})",
+ version,
+ Database::VERSION
+ );
+ }
+
+ let dirs: Vec> = deserializer.deserialize(bytes_dirs)?;
+
+ let tx = conn.transaction()?;
+ for dir in dirs {
+ let path_s: String = dir.path.into_owned();
+ tx.execute(
+ "INSERT OR REPLACE INTO dirs (path, rank, last_accessed) VALUES (?1, ?2, ?3)",
+ params![&path_s, dir.rank, dir.last_accessed],
+ )?;
+ }
+ tx.commit()?;
+
+ Ok(())
+}
+
pub struct Database {
conn: Connection,
dirty: bool,
@@ -33,7 +79,9 @@ impl Database {
.with_context(|| format!("unable to create data directory: {}", data_dir.display()))?;
// Open or create sqlite database file.
- let conn = Connection::open(&path)
+ let existed = path.exists();
+
+ let mut conn = Connection::open(&path)
.with_context(|| format!("could not open database: {}", path.display()))?;
// Enable WAL for better concurrency and durability.
@@ -48,6 +96,24 @@ impl Database {
);",
)?;
+ // If the sqlite database didn't previously exist, attempt to migrate data
+ // from the legacy bincode-backed file. This keeps behaviour identical to
+ // older versions of zoxide while ensuring users transparently upgrade.
+ if !existed {
+ let old_path = data_dir.join("db.zo");
+ if old_path.exists() {
+ // Migration errors shouldn't prevent the program from running;
+ // just print a warning so users can investigate.
+ if let Err(e) = migrate_from_bincode(&mut conn, &old_path) {
+ eprintln!(
+ "warning: failed to migrate legacy database ({}): {}",
+ old_path.display(),
+ e
+ );
+ }
+ }
+ }
+
Ok(Database { conn, dirty: false })
}
@@ -324,4 +390,34 @@ mod tests {
db.save().unwrap();
}
}
+
+ #[test]
+ fn migrate_from_bincode() {
+ let data_dir = tempfile::tempdir().unwrap();
+ let old_path = data_dir.path().join("db.zo");
+
+ // prepare a legacy file with one entry
+ let dirs = vec![Dir {
+ path: "/foo".into(),
+ rank: 1.0,
+ last_accessed: 12345,
+ }];
+ let mut bytes = Vec::new();
+ let mut serializer = bincode::options().with_fixint_encoding();
+ serializer.serialize_into(&mut bytes, &Database::VERSION).unwrap();
+ serializer.serialize_into(&mut bytes, &dirs).unwrap();
+ fs::write(&old_path, &bytes).unwrap();
+
+ // opening should automatically migrate the data
+ let db = Database::open_dir(data_dir.path()).unwrap();
+ let dirs = db.dirs();
+ assert_eq!(dirs.len(), 1);
+ assert_eq!(dirs[0].path, "/foo");
+ assert!((dirs[0].rank - 1.0).abs() < f64::EPSILON);
+ assert_eq!(dirs[0].last_accessed, 12345);
+ // sqlite file should exist after opening
+ assert!(data_dir.path().join("db.sqlite3").exists());
+ // old file is left intact so future runs are no-ops
+ assert!(old_path.exists());
+ }
}