diff --git a/README.md b/README.md index 448bfc5..94a6ece 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/man/man1/zoxide.1 b/man/man1/zoxide.1 index ef1792b..aa4760d 100644 --- a/man/man1/zoxide.1 +++ b/man/man1/zoxide.1 @@ -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 diff --git a/src/db/dir.rs b/src/db/dir.rs index 5d6d62c..63afe84 100644 --- a/src/db/dir.rs +++ b/src/db/dir.rs @@ -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>, diff --git a/src/db/mod.rs b/src/db/mod.rs index 1856fda..e7f0e58 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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 { 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) -> Result { 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> { - (|| -> 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>> { + 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>> { + 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: "; + 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::() + .with_context(|| format!("could not parse timestamp: {last_accessed}"))?; + let rank = rank + .parse::() + .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>> { // 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::()).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") } }