Switch to plaintext DB (#1288)

This commit is contained in:
Ajeet D'Souza
2026-10-03 15:06:35 +05:30
committed by GitHub
parent 9a6d2d4cec
commit c479cc8294
4 changed files with 125 additions and 55 deletions
+5 -5
View File
@@ -405,11 +405,11 @@ Environment variables[^2] can be used for configuration. They must be set before
- Specifies the directory in which the database is stored.
- The default value varies across OSes:
| OS | Path | Example |
| ----------- | ---------------------------------------- | ------------------------------------------ |
| Linux / BSD | `$XDG_DATA_HOME` or `$HOME/.local/share` | `/home/alice/.local/share` |
| macOS | `$HOME/Library/Application Support` | `/Users/Alice/Library/Application Support` |
| Windows | `%LOCALAPPDATA%` | `C:\Users\Alice\AppData\Local` |
| OS | Path | Example |
| ----------- | ------------------------------------------------------ | ------------------------------------------------- |
| Linux / BSD | `$XDG_DATA_HOME/zoxide` or `$HOME/.local/share/zoxide` | `/home/alice/.local/share/zoxide` |
| macOS | `$HOME/Library/Application Support/zoxide` | `/Users/Alice/Library/Application Support/zoxide` |
| Windows | `%LOCALAPPDATA%\zoxide` | `C:\Users\Alice\AppData\Local\zoxide` |
- `_ZO_ECHO`
- When set to 1, `z` will print the matched directory before navigating to
+5 -5
View File
@@ -57,15 +57,15 @@ tab(|);
l l.
\fBOS|Path\fR
\fBLinux/BSD\fR|T{
\fB$XDG_DATA_HOME\fR or \fB$HOME/.local/share\fR, eg.
\fB/home/alice/.local/share\fR
\fB$XDG_DATA_HOME/zoxide\fR or \fB$HOME/.local/share/zoxide\fR, eg.
\fB/home/alice/.local/share/zoxide\fR
T}
\fBmacOS\fR|T{
\fB$HOME/Library/Application Support\fR, eg.
\fB/Users/Alice/Library/Application Support\fR
\fB$HOME/Library/Application Support/zoxide\fR, eg.
\fB/Users/Alice/Library/Application Support/zoxide\fR
T}
\fBWindows\fR|T{
\fB%LOCALAPPDATA%\fR, eg. \fBC:\\Users\\Alice\\AppData\\Local\fR
\fB%LOCALAPPDATA%\\zoxide\fR, eg. \fBC:\\Users\\Alice\\AppData\\Local\\zoxide\fR
T}
.TE
.TP
+2 -2
View File
@@ -1,11 +1,11 @@
use std::borrow::Cow;
use std::fmt::{self, Display, Formatter};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use crate::util::{DAY, HOUR, WEEK};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize)]
pub struct Dir<'a> {
#[serde(borrow)]
pub path: Cow<'a, str>,
+113 -43
View File
@@ -1,6 +1,8 @@
mod dir;
mod stream;
use std::borrow::Cow;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::{fs, io};
@@ -12,6 +14,13 @@ pub use crate::db::dir::{Dir, Epoch, Rank};
pub use crate::db::stream::{Stream, StreamOptions};
use crate::{config, util};
/// Width of the timestamp and rank columns.
const COL_WIDTH: usize = 10;
/// The smallest rank the database file can hold.
const MIN_RANK: Rank = 0.01;
/// The largest rank the database file can hold.
const MAX_RANK: Rank = 9_999_999.99;
#[self_referencing]
pub struct Database {
path: PathBuf,
@@ -23,8 +32,6 @@ pub struct Database {
}
impl Database {
const VERSION: u32 = 3;
pub fn open() -> Result<Self> {
let data_dir = config::data_dir()?;
Self::open_dir(data_dir)
@@ -32,23 +39,42 @@ impl Database {
pub fn open_dir(data_dir: impl AsRef<Path>) -> Result<Self> {
let data_dir = data_dir.as_ref();
let path = data_dir.join("db.zo");
let path = data_dir.join("db.txt");
let path = fs::canonicalize(&path).unwrap_or(path);
match fs::read(&path) {
Ok(bytes) => Self::try_new(path, bytes, |bytes| Self::deserialize(bytes), false),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
// Create data directory, but don't create any file yet. The file will be
// created later by [`Database::save`] if any data is modified.
fs::create_dir_all(data_dir).with_context(|| {
format!("unable to create data directory: {}", data_dir.display())
})?;
Ok(Self::new(path, Vec::new(), |_| Vec::new(), false))
Ok(bytes) => {
return Self::try_new(path.clone(), bytes, |bytes| Self::deserialize(bytes), false)
.with_context(|| format!("could not parse database: {}", path.display()));
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => {
Err(e).with_context(|| format!("could not read from database: {}", path.display()))
return Err(e)
.with_context(|| format!("could not read from database: {}", path.display()));
}
}
// Migrate the legacy bincode database, if there is one.
let path_legacy = data_dir.join("db.zo");
match fs::read(&path_legacy) {
Ok(bytes) => {
return Self::try_new(path, bytes, |bytes| Self::deserialize_legacy(bytes), true)
.with_context(|| {
format!("could not parse database: {}", path_legacy.display())
});
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e).with_context(|| {
format!("could not read from database: {}", path_legacy.display())
});
}
}
// Create the data directory, but don't create any file yet.
fs::create_dir_all(data_dir)
.with_context(|| format!("unable to create data directory: {}", data_dir.display()))?;
Ok(Self::new(path, Vec::new(), |_| Vec::new(), false))
}
pub fn save(&mut self) -> Result<()> {
@@ -189,46 +215,90 @@ impl Database {
}
fn serialize(dirs: &[Dir<'_>]) -> Result<Vec<u8>> {
(|| -> bincode::Result<_> {
// Preallocate buffer with combined size of sections.
let buffer_size =
bincode::serialized_size(&Self::VERSION)? + bincode::serialized_size(&dirs)?;
let mut buffer = Vec::with_capacity(buffer_size as usize);
// timestamp + tab + rank + tab + path + newline.
let size_hint: usize =
dirs.iter().map(|dir| COL_WIDTH + 1 + COL_WIDTH + 1 + dir.path.len() + 1).sum();
let mut buffer = Vec::with_capacity(size_hint);
// Serialize sections into buffer.
bincode::serialize_into(&mut buffer, &Self::VERSION)?;
bincode::serialize_into(&mut buffer, &dirs)?;
Ok(buffer)
})()
.context("could not serialize database")
for dir in dirs {
let rank = dir.rank.clamp(MIN_RANK, MAX_RANK);
writeln!(
buffer,
"{:0COL_WIDTH$}\t{rank:0COL_WIDTH$.2}\t{}",
dir.last_accessed, dir.path
)
.context("could not serialize database")?;
}
Ok(buffer)
}
fn deserialize(bytes: &[u8]) -> Result<Vec<Dir<'_>>> {
let mut dirs = Vec::new();
let mut errors = Vec::new();
for (idx, line) in bytes.split(|byte| *byte == b'\n').enumerate() {
match Self::deserialize_line(line) {
Ok(Some(dir)) => dirs.push(dir),
Ok(None) => {}
Err(e) => errors.push(format!("line {}: {e:#}", idx + 1)),
}
}
if !errors.is_empty() {
const MAX_ERRORS: usize = 8;
let total = errors.len();
if total > MAX_ERRORS {
errors.truncate(MAX_ERRORS);
errors.push(format!("... and {} more", total - MAX_ERRORS));
}
bail!("{}", errors.join("\n"));
}
Ok(dirs)
}
fn deserialize_line(line: &[u8]) -> Result<Option<Dir<'_>>> {
let line = str::from_utf8(line).context("invalid UTF-8")?;
let line = line.strip_suffix('\r').unwrap_or(line);
if line.trim().is_empty() {
return Ok(None);
}
const EXPECTED: &str = "expected: <timestamp> <rank> <path>";
let (last_accessed, line) =
line.trim_start().split_once(char::is_whitespace).context(EXPECTED)?;
let (rank, path) = line.trim_start().split_once(char::is_whitespace).context(EXPECTED)?;
let path = path.trim_start();
let last_accessed = last_accessed
.parse::<Epoch>()
.with_context(|| format!("could not parse timestamp: {last_accessed}"))?;
let rank = rank
.parse::<Rank>()
.with_context(|| format!("could not parse rank: {rank}"))?
.clamp(MIN_RANK, MAX_RANK);
if rank.is_nan() {
bail!("could not parse rank: {rank}");
}
if path.is_empty() {
bail!("{EXPECTED}");
}
Ok(Some(Dir { path: Cow::Borrowed(path), rank, last_accessed }))
}
fn deserialize_legacy(bytes: &[u8]) -> Result<Vec<Dir<'_>>> {
// Assume a maximum size for the database. This prevents bincode from throwing
// strange errors when it encounters invalid data.
const MAX_SIZE: u64 = 32 << 20; // 32 MiB
const VERSION: u32 = 3;
let (bytes_version, bytes_dirs) =
bytes.split_at_checked(size_of::<u32>()).context("corrupted data")?;
let deserializer = &mut bincode::options().with_fixint_encoding().with_limit(MAX_SIZE);
// Split bytes into sections.
let version_size = deserializer.serialized_size(&Self::VERSION).unwrap() as _;
if bytes.len() < version_size {
bail!("could not deserialize database: corrupted data");
let version: u32 = deserializer.deserialize(bytes_version)?;
if version != VERSION {
bail!("corrupted data");
}
let (bytes_version, bytes_dirs) = bytes.split_at(version_size);
// Deserialize sections.
let version = deserializer.deserialize(bytes_version)?;
let dirs = match version {
Self::VERSION => {
deserializer.deserialize(bytes_dirs).context("could not deserialize database")?
}
version => {
bail!("unsupported version (got {version}, supports {})", Self::VERSION)
}
};
Ok(dirs)
deserializer.deserialize(bytes_dirs).context("corrupted data")
}
}