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()); + } }