Rename
This commit is contained in:
parent
b4b3001be0
commit
6c1da6ec96
|
|
@ -21,8 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Zsh: don't hide output from `chpwd` hooks.
|
- Zsh: don't hide output from `chpwd` hooks.
|
||||||
- Nushell: upgrade minimum supported version to v0.73.0.
|
- Nushell: upgrade minimum supported version to v0.73.0.
|
||||||
- Zsh: fix extra space in interactive completions when no match is found.
|
- Zsh: fix extra space in interactive completions when no match is found.
|
||||||
- Fzf: `<TAB>` now cycles through completions.
|
- Fzf: various improvements.
|
||||||
- Fzf: enable colors in preview when possible on macOS / BSD.
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::path::Path;
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
use crate::cmd::{Add, Run};
|
use crate::cmd::{Add, Run};
|
||||||
use crate::store::Store;
|
use crate::db::Database;
|
||||||
use crate::{config, util};
|
use crate::{config, util};
|
||||||
|
|
||||||
impl Run for Add {
|
impl Run for Add {
|
||||||
|
|
@ -16,7 +16,7 @@ impl Run for Add {
|
||||||
let max_age = config::maxage()?;
|
let max_age = config::maxage()?;
|
||||||
let now = util::current_time()?;
|
let now = util::current_time()?;
|
||||||
|
|
||||||
let mut db = Store::open()?;
|
let mut db = Database::open()?;
|
||||||
|
|
||||||
for path in &self.paths {
|
for path in &self.paths {
|
||||||
let path = if config::resolve_symlinks() { util::canonicalize } else { util::resolve_path }(path)?;
|
let path = if config::resolve_symlinks() { util::canonicalize } else { util::resolve_path }(path)?;
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,35 @@
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::cmd::{Edit, EditCommand, Run};
|
use crate::cmd::{Edit, EditCommand, Run};
|
||||||
use crate::store::{Epoch, Store};
|
use crate::db::{Database, Epoch};
|
||||||
use crate::util;
|
use crate::error::BrokenPipeHandler;
|
||||||
|
use crate::util::{self, Fz};
|
||||||
|
|
||||||
impl Run for Edit {
|
impl Run for Edit {
|
||||||
fn run(&self) -> Result<()> {
|
fn run(&self) -> Result<()> {
|
||||||
let now = util::current_time()?;
|
let now = util::current_time()?;
|
||||||
let db = &mut Store::open()?;
|
let db = &mut Database::open()?;
|
||||||
|
|
||||||
match &self.cmd {
|
match &self.cmd {
|
||||||
Some(EditCommand::Decrement { path }) => {
|
Some(cmd) => {
|
||||||
db.add(path, -1.0, now);
|
match cmd {
|
||||||
|
EditCommand::Decrement { path } => db.add(path, -1.0, now),
|
||||||
|
EditCommand::Delete { path } => {
|
||||||
|
db.remove(path);
|
||||||
|
}
|
||||||
|
EditCommand::Increment { path } => db.add(path, 1.0, now),
|
||||||
|
EditCommand::Reload => {}
|
||||||
|
}
|
||||||
db.save()?;
|
db.save()?;
|
||||||
print_dirs(db, now);
|
print_dirs(db, now)
|
||||||
}
|
}
|
||||||
Some(EditCommand::Delete { path }) => {
|
|
||||||
db.remove(path);
|
|
||||||
db.save()?;
|
|
||||||
print_dirs(db, now);
|
|
||||||
}
|
|
||||||
Some(EditCommand::Increment { path }) => {
|
|
||||||
db.add(path, 1.0, now);
|
|
||||||
db.save()?;
|
|
||||||
print_dirs(db, now);
|
|
||||||
}
|
|
||||||
Some(EditCommand::Reload) => print_dirs(db, now),
|
|
||||||
None => {
|
None => {
|
||||||
db.sort_by_score(now);
|
db.sort_by_score(now);
|
||||||
db.save()?;
|
db.save()?;
|
||||||
|
|
||||||
let mut fzf = Command::new("fzf");
|
let mut fzf = Fz::new()?;
|
||||||
fzf.args([
|
fzf.args([
|
||||||
// Search mode
|
// Search mode
|
||||||
"--delimiter=\t",
|
"--delimiter=\t",
|
||||||
|
|
@ -54,18 +50,18 @@ enter:abort",
|
||||||
"--keep-right",
|
"--keep-right",
|
||||||
// Layout
|
// Layout
|
||||||
"--border=rounded",
|
"--border=rounded",
|
||||||
"--border-label= zoxide-edit ",
|
"--border-label= zoxide-edit ",
|
||||||
"--header=\
|
"--header=\
|
||||||
ctrl-r:reload \tctrl-w:delete
|
ctrl-r:reload \tctrl-w:delete
|
||||||
ctrl-a:increment\tctrl-d:decrement
|
ctrl-a:increment\tctrl-d:decrement
|
||||||
|
|
||||||
SCORE\tPATH",
|
SCORE\tPATH",
|
||||||
"--info=inline",
|
"--info=inline",
|
||||||
"--layout=reverse",
|
"--layout=reverse",
|
||||||
"--padding=1",
|
"--padding=1,0,0,0",
|
||||||
// Display
|
// Display
|
||||||
"--color=label:bold",
|
"--color=label:bold",
|
||||||
"--tabstop=2",
|
"--tabstop=1",
|
||||||
// Scripting
|
// Scripting
|
||||||
"--read0",
|
"--read0",
|
||||||
])
|
])
|
||||||
|
|
@ -85,18 +81,18 @@ SCORE\tPATH",
|
||||||
fzf.args([PREVIEW_ARG, "--preview-window=down,30%"]).env("SHELL", "sh");
|
fzf.args([PREVIEW_ARG, "--preview-window=down,30%"]).env("SHELL", "sh");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut fzf = fzf.spawn().unwrap();
|
let mut fzf = fzf.spawn()?;
|
||||||
fzf.wait().unwrap();
|
fzf.wait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_dirs(db: &Store, now: Epoch) {
|
fn print_dirs(db: &Database, now: Epoch) -> Result<()> {
|
||||||
let stdout = &mut io::stdout().lock();
|
let stdout = &mut io::stdout().lock();
|
||||||
for dir in db.dirs().iter().rev() {
|
for dir in db.dirs().iter().rev() {
|
||||||
write!(stdout, "{:>5}\t{}\x00", dir.score(now), &dir.path).unwrap();
|
let score = dir.score(now).clamp(0.0, 9999.0);
|
||||||
|
write!(stdout, "{:>6.1}\t{}\x00", score, &dir.path).pipe_exit("fzf")?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ use std::fs;
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
use crate::cmd::{Import, ImportFrom, Run};
|
use crate::cmd::{Import, ImportFrom, Run};
|
||||||
use crate::store::Store;
|
use crate::db::Database;
|
||||||
|
|
||||||
impl Run for Import {
|
impl Run for Import {
|
||||||
fn run(&self) -> Result<()> {
|
fn run(&self) -> Result<()> {
|
||||||
let buffer = fs::read_to_string(&self.path)
|
let buffer = fs::read_to_string(&self.path)
|
||||||
.with_context(|| format!("could not open database for importing: {}", &self.path.display()))?;
|
.with_context(|| format!("could not open database for importing: {}", &self.path.display()))?;
|
||||||
|
|
||||||
let mut db = Store::open()?;
|
let mut db = Database::open()?;
|
||||||
if !self.merge && !db.dirs().is_empty() {
|
if !self.merge && !db.dirs().is_empty() {
|
||||||
bail!("current database is not empty, specify --merge to continue anyway");
|
bail!("current database is not empty, specify --merge to continue anyway");
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ impl Run for Import {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_autojump(db: &mut Store, buffer: &str) -> Result<()> {
|
fn import_autojump(db: &mut Database, buffer: &str) -> Result<()> {
|
||||||
for line in buffer.lines() {
|
for line in buffer.lines() {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -49,7 +49,7 @@ fn import_autojump(db: &mut Store, buffer: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_z(db: &mut Store, buffer: &str) -> Result<()> {
|
fn import_z(db: &mut Database, buffer: &str) -> Result<()> {
|
||||||
for line in buffer.lines() {
|
for line in buffer.lines() {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -80,12 +80,12 @@ fn sigmoid(x: f64) -> f64 {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::Dir;
|
use crate::db::Dir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_autojump() {
|
fn from_autojump() {
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
let mut db = Store::open_dir(data_dir.path()).unwrap();
|
let mut db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
for (path, rank, last_accessed) in [
|
for (path, rank, last_accessed) in [
|
||||||
("/quux/quuz", 1.0, 100),
|
("/quux/quuz", 1.0, 100),
|
||||||
("/corge/grault/garply", 6.0, 600),
|
("/corge/grault/garply", 6.0, 600),
|
||||||
|
|
@ -125,7 +125,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn from_z() {
|
fn from_z() {
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
let mut db = Store::open_dir(data_dir.path()).unwrap();
|
let mut db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
for (path, rank, last_accessed) in [
|
for (path, rank, last_accessed) in [
|
||||||
("/quux/quuz", 1.0, 100),
|
("/quux/quuz", 1.0, 100),
|
||||||
("/corge/grault/garply", 6.0, 600),
|
("/corge/grault/garply", 6.0, 600),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
||||||
|
|
||||||
use crate::cmd::{Query, Run};
|
use crate::cmd::{Query, Run};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::db::{Database, DatabaseFile};
|
use crate::db2::{Database, DatabaseFile};
|
||||||
use crate::error::BrokenPipeHandler;
|
use crate::error::BrokenPipeHandler;
|
||||||
use crate::util::{self, Fzf};
|
use crate::util::{self, Fzf};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
use crate::cmd::{Remove, Run};
|
use crate::cmd::{Remove, Run};
|
||||||
use crate::store::Store;
|
use crate::db::Database;
|
||||||
use crate::util;
|
use crate::util;
|
||||||
|
|
||||||
impl Run for Remove {
|
impl Run for Remove {
|
||||||
fn run(&self) -> Result<()> {
|
fn run(&self) -> Result<()> {
|
||||||
let mut db = Store::open()?;
|
let mut db = Database::open()?;
|
||||||
|
|
||||||
for path in &self.paths {
|
for path in &self.paths {
|
||||||
if !db.remove(path) {
|
if !db.remove(path) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::path::PathBuf;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use glob::Pattern;
|
use glob::Pattern;
|
||||||
|
|
||||||
use crate::db::Rank;
|
use crate::db2::Rank;
|
||||||
|
|
||||||
pub fn data_dir() -> Result<PathBuf> {
|
pub fn data_dir() -> Result<PathBuf> {
|
||||||
let path = match env::var_os("_ZO_DATA_DIR") {
|
let path = match env::var_os("_ZO_DATA_DIR") {
|
||||||
|
|
|
||||||
130
src/db/dir.rs
130
src/db/dir.rs
|
|
@ -1,83 +1,8 @@
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::fmt::{self, Display, Formatter};
|
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use bincode::Options as _;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
use crate::util::{DAY, HOUR, WEEK};
|
||||||
pub struct DirList<'a>(#[serde(borrow)] pub Vec<Dir<'a>>);
|
|
||||||
|
|
||||||
impl DirList<'_> {
|
|
||||||
const VERSION: u32 = 3;
|
|
||||||
|
|
||||||
pub fn new() -> DirList<'static> {
|
|
||||||
DirList(Vec::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<DirList> {
|
|
||||||
// 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
|
|
||||||
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 (bytes_version, bytes_dirs) = bytes.split_at(version_size);
|
|
||||||
|
|
||||||
// Deserialize sections.
|
|
||||||
(|| {
|
|
||||||
let version = deserializer.deserialize(bytes_version)?;
|
|
||||||
match version {
|
|
||||||
Self::VERSION => Ok(deserializer.deserialize(bytes_dirs)?),
|
|
||||||
version => {
|
|
||||||
bail!("unsupported version (got {version}, supports {})", Self::VERSION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
.context("could not deserialize database")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
|
||||||
(|| -> bincode::Result<_> {
|
|
||||||
// Preallocate buffer with combined size of sections.
|
|
||||||
let version_size = bincode::serialized_size(&Self::VERSION)?;
|
|
||||||
let dirs_size = bincode::serialized_size(&self)?;
|
|
||||||
let buffer_size = version_size + dirs_size;
|
|
||||||
let mut buffer = Vec::with_capacity(buffer_size as _);
|
|
||||||
|
|
||||||
// Serialize sections into buffer.
|
|
||||||
bincode::serialize_into(&mut buffer, &Self::VERSION)?;
|
|
||||||
bincode::serialize_into(&mut buffer, &self)?;
|
|
||||||
Ok(buffer)
|
|
||||||
})()
|
|
||||||
.context("could not serialize database")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Deref for DirList<'a> {
|
|
||||||
type Target = Vec<Dir<'a>>;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> DerefMut for DirList<'a> {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<Vec<Dir<'a>>> for DirList<'a> {
|
|
||||||
fn from(dirs: Vec<Dir<'a>>) -> Self {
|
|
||||||
DirList(dirs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct Dir<'a> {
|
pub struct Dir<'a> {
|
||||||
|
|
@ -89,10 +14,6 @@ pub struct Dir<'a> {
|
||||||
|
|
||||||
impl Dir<'_> {
|
impl Dir<'_> {
|
||||||
pub fn score(&self, now: Epoch) -> Rank {
|
pub fn score(&self, now: Epoch) -> Rank {
|
||||||
const HOUR: Epoch = 60 * 60;
|
|
||||||
const DAY: Epoch = 24 * HOUR;
|
|
||||||
const WEEK: Epoch = 7 * DAY;
|
|
||||||
|
|
||||||
// The older the entry, the lesser its importance.
|
// The older the entry, the lesser its importance.
|
||||||
let duration = now.saturating_sub(self.last_accessed);
|
let duration = now.saturating_sub(self.last_accessed);
|
||||||
if duration < HOUR {
|
if duration < HOUR {
|
||||||
|
|
@ -105,56 +26,7 @@ impl Dir<'_> {
|
||||||
self.rank * 0.25
|
self.rank * 0.25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display(&self) -> DirDisplay {
|
|
||||||
DirDisplay { dir: self }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn display_score(&self, now: Epoch) -> DirDisplayScore {
|
|
||||||
DirDisplayScore { dir: self, now }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DirDisplay<'a> {
|
|
||||||
dir: &'a Dir<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for DirDisplay<'_> {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.dir.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DirDisplayScore<'a> {
|
|
||||||
dir: &'a Dir<'a>,
|
|
||||||
now: Epoch,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for DirDisplayScore<'_> {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
||||||
let score = self.dir.score(self.now).clamp(0.0, 9999.0) as u32;
|
|
||||||
write!(f, "{:>4} {}", score, self.dir.path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Rank = f64;
|
pub type Rank = f64;
|
||||||
pub type Epoch = u64;
|
pub type Epoch = u64;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::borrow::Cow;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_copy() {
|
|
||||||
let dirs = DirList(vec![Dir { path: "/".into(), rank: 0.0, last_accessed: 0 }]);
|
|
||||||
|
|
||||||
let bytes = dirs.to_bytes().unwrap();
|
|
||||||
let dirs = DirList::from_bytes(&bytes).unwrap();
|
|
||||||
|
|
||||||
for dir in dirs.iter() {
|
|
||||||
assert!(matches!(dir.path, Cow::Borrowed(_)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
293
src/db/mod.rs
293
src/db/mod.rs
|
|
@ -1,75 +1,266 @@
|
||||||
mod dir;
|
mod dir;
|
||||||
mod stream;
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::{fs, io};
|
use std::{fs, io};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
pub use dir::{Dir, DirList, Epoch, Rank};
|
use bincode::Options;
|
||||||
pub use stream::Stream;
|
use ouroboros::self_referencing;
|
||||||
|
|
||||||
use crate::util;
|
pub use crate::db::dir::{Dir, Epoch, Rank};
|
||||||
|
use crate::{config, util};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[self_referencing]
|
||||||
pub struct Database<'file> {
|
pub struct Database {
|
||||||
pub dirs: DirList<'file>,
|
path: PathBuf,
|
||||||
pub modified: bool,
|
bytes: Vec<u8>,
|
||||||
pub data_dir: &'file Path,
|
#[borrows(bytes)]
|
||||||
|
#[covariant]
|
||||||
|
dirs: Vec<Dir<'this>>,
|
||||||
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'file> Database<'file> {
|
impl Database {
|
||||||
pub fn save(&mut self) -> Result<()> {
|
const VERSION: u32 = 3;
|
||||||
if !self.modified {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let buffer = self.dirs.to_bytes()?;
|
pub fn open() -> Result<Self> {
|
||||||
let path = db_path(self.data_dir);
|
let data_dir = config::data_dir()?;
|
||||||
util::write(&path, &buffer).context("could not write to database")?;
|
Self::open_dir(&data_dir)
|
||||||
self.modified = false;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming iterator for directories.
|
pub fn open_dir(data_dir: &Path) -> Result<Self> {
|
||||||
pub fn stream(&mut self, now: Epoch) -> Stream<'_, 'file> {
|
let path = data_dir.join("db.zo");
|
||||||
Stream::new(self, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DatabaseFile {
|
|
||||||
buffer: Vec<u8>,
|
|
||||||
data_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabaseFile {
|
|
||||||
pub fn new<P: Into<PathBuf>>(data_dir: P) -> Self {
|
|
||||||
DatabaseFile { buffer: Vec::new(), data_dir: data_dir.into() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(&mut self) -> Result<Database> {
|
|
||||||
// Read the entire database to memory. For smaller files, this is faster than
|
|
||||||
// mmap / streaming, and allows for zero-copy deserialization.
|
|
||||||
let path = db_path(&self.data_dir);
|
|
||||||
match fs::read(&path) {
|
match fs::read(&path) {
|
||||||
Ok(buffer) => {
|
Ok(bytes) => Self::try_new(path, bytes, |bytes| Self::deserialize(bytes), false),
|
||||||
self.buffer = buffer;
|
|
||||||
let dirs = DirList::from_bytes(&self.buffer)
|
|
||||||
.with_context(|| format!("could not deserialize database: {}", path.display()))?;
|
|
||||||
Ok(Database { dirs, modified: false, data_dir: &self.data_dir })
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||||
// Create data directory, but don't create any file yet. The file will be created
|
// Create data directory, but don't create any file yet. The file will be created
|
||||||
// later by [`Database::save`] if any data is modified.
|
// later by [`Database::save`] if any data is modified.
|
||||||
fs::create_dir_all(&self.data_dir)
|
fs::create_dir_all(data_dir)
|
||||||
.with_context(|| format!("unable to create data directory: {}", self.data_dir.display()))?;
|
.with_context(|| format!("unable to create data directory: {}", data_dir.display()))?;
|
||||||
Ok(Database { dirs: DirList::new(), modified: false, data_dir: &self.data_dir })
|
Ok(Self::new(path, Vec::new(), |_| Vec::new(), false))
|
||||||
}
|
}
|
||||||
Err(e) => Err(e).with_context(|| format!("could not read from database: {}", path.display())),
|
Err(e) => Err(e).with_context(|| format!("could not read from database: {}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn save(&mut self) -> Result<()> {
|
||||||
|
// Only write to disk if the database is modified.
|
||||||
|
if !self.dirty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = Self::serialize(self.dirs())?;
|
||||||
|
util::write(self.borrow_path(), &bytes).context("could not write to database")?;
|
||||||
|
self.with_dirty_mut(|dirty| *dirty = false);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increments the rank of a directory, or creates it if it does not exist.
|
||||||
|
pub fn add(&mut self, path: impl AsRef<str> + Into<String>, by: Rank, now: Epoch) {
|
||||||
|
self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) {
|
||||||
|
Some(dir) => dir.rank = (dir.rank + by).max(0.0),
|
||||||
|
None => dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }),
|
||||||
|
});
|
||||||
|
self.with_dirty_mut(|dirty| *dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new directory. This will create a duplicate entry if this
|
||||||
|
/// directory is always in the database, it is expected that the user either
|
||||||
|
/// does a check before calling this, or calls `dedup()` afterward.
|
||||||
|
pub fn add_unchecked(&mut self, path: impl AsRef<str> + Into<String>, rank: Rank, now: Epoch) {
|
||||||
|
self.with_dirs_mut(|dirs| dirs.push(Dir { path: path.into().into(), rank, last_accessed: now }));
|
||||||
|
self.with_dirty_mut(|dirty| *dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increments the rank and updates the last_accessed of a directory, or
|
||||||
|
/// creates it if it does not exist.
|
||||||
|
pub fn add_update(&mut self, path: impl AsRef<str> + Into<String>, by: Rank, now: Epoch) {
|
||||||
|
self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) {
|
||||||
|
Some(dir) => {
|
||||||
|
dir.rank = (dir.rank + by).max(0.0);
|
||||||
|
dir.last_accessed = now;
|
||||||
|
}
|
||||||
|
None => dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }),
|
||||||
|
});
|
||||||
|
self.with_dirty_mut(|dirty| *dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the directory with `path` from the store. This does not preserve
|
||||||
|
/// ordering, but is O(1).
|
||||||
|
pub fn remove(&mut self, path: impl AsRef<str>) -> bool {
|
||||||
|
let deleted = self.with_dirs_mut(|dirs| match dirs.iter().position(|dir| dir.path == path.as_ref()) {
|
||||||
|
Some(idx) => {
|
||||||
|
dirs.swap_remove(idx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
self.with_dirty_mut(|dirty| *dirty |= deleted);
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn age(&mut self, max_age: Rank) {
|
||||||
|
let mut dirty = false;
|
||||||
|
self.with_dirs_mut(|dirs| {
|
||||||
|
let total_age = dirs.iter().map(|dir| dir.rank).sum::<Rank>();
|
||||||
|
if total_age > max_age {
|
||||||
|
let factor = 0.9 * max_age / total_age;
|
||||||
|
for idx in (0..dirs.len()).rev() {
|
||||||
|
let dir = &mut dirs[idx];
|
||||||
|
dir.rank *= factor;
|
||||||
|
if dir.rank < 1.0 {
|
||||||
|
dirs.swap_remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.with_dirty_mut(|dirty_prev| *dirty_prev |= dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dedup(&mut self) {
|
||||||
|
// Sort by path, so that equal paths are next to each other.
|
||||||
|
self.sort_by_path();
|
||||||
|
|
||||||
|
let mut dirty = false;
|
||||||
|
self.with_dirs_mut(|dirs| {
|
||||||
|
for idx in (1..dirs.len()).rev() {
|
||||||
|
// Check if curr_dir and next_dir have equal paths.
|
||||||
|
let curr_dir = &dirs[idx];
|
||||||
|
let next_dir = &dirs[idx - 1];
|
||||||
|
if next_dir.path != curr_dir.path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge curr_dir's rank and last_accessed into next_dir.
|
||||||
|
let rank = curr_dir.rank;
|
||||||
|
let last_accessed = curr_dir.last_accessed;
|
||||||
|
let next_dir = &mut dirs[idx - 1];
|
||||||
|
next_dir.last_accessed = next_dir.last_accessed.max(last_accessed);
|
||||||
|
next_dir.rank += rank;
|
||||||
|
|
||||||
|
// Delete curr_dir.
|
||||||
|
dirs.swap_remove(idx);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.with_dirty_mut(|dirty_prev| *dirty_prev |= dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_by_path(&mut self) {
|
||||||
|
self.with_dirs_mut(|dirs| dirs.sort_unstable_by(|dir1, dir2| dir1.path.cmp(&dir2.path)));
|
||||||
|
self.with_dirty_mut(|dirty| *dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_by_score(&mut self, now: Epoch) {
|
||||||
|
self.with_dirs_mut(|dirs| {
|
||||||
|
dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| dir1.score(now).total_cmp(&dir2.score(now)))
|
||||||
|
});
|
||||||
|
self.with_dirty_mut(|dirty| *dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dirty(&self) -> bool {
|
||||||
|
*self.borrow_dirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dirs(&self) -> &[Dir] {
|
||||||
|
self.borrow_dirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Serialize sections into buffer.
|
||||||
|
bincode::serialize_into(&mut buffer, &Self::VERSION)?;
|
||||||
|
bincode::serialize_into(&mut buffer, &dirs)?;
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
})()
|
||||||
|
.context("could not serialize database")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize(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
|
||||||
|
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 (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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn db_path<P: AsRef<Path>>(data_dir: P) -> PathBuf {
|
#[cfg(test)]
|
||||||
const DB_FILENAME: &str = "db.zo";
|
mod tests {
|
||||||
data_dir.as_ref().join(DB_FILENAME)
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add() {
|
||||||
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" };
|
||||||
|
let now = 946684800;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
|
db.add(path, 1.0, now);
|
||||||
|
db.add(path, 1.0, now);
|
||||||
|
db.save().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
|
assert_eq!(db.dirs().len(), 1);
|
||||||
|
|
||||||
|
let dir = &db.dirs()[0];
|
||||||
|
assert_eq!(dir.path, path);
|
||||||
|
assert!((dir.rank - 2.0).abs() < 0.01);
|
||||||
|
assert_eq!(dir.last_accessed, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove() {
|
||||||
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" };
|
||||||
|
let now = 946684800;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
|
db.add(path, 1.0, now);
|
||||||
|
db.save().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
|
assert!(db.remove(path));
|
||||||
|
db.save().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut db = Database::open_dir(data_dir.path()).unwrap();
|
||||||
|
assert!(db.dirs().is_empty());
|
||||||
|
assert!(!db.remove(path));
|
||||||
|
db.save().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::fmt::{self, Display, Formatter};
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use bincode::Options as _;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DirList<'a>(#[serde(borrow)] pub Vec<Dir<'a>>);
|
||||||
|
|
||||||
|
impl DirList<'_> {
|
||||||
|
const VERSION: u32 = 3;
|
||||||
|
|
||||||
|
pub fn new() -> DirList<'static> {
|
||||||
|
DirList(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> Result<DirList> {
|
||||||
|
// 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
|
||||||
|
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 (bytes_version, bytes_dirs) = bytes.split_at(version_size);
|
||||||
|
|
||||||
|
// Deserialize sections.
|
||||||
|
(|| {
|
||||||
|
let version = deserializer.deserialize(bytes_version)?;
|
||||||
|
match version {
|
||||||
|
Self::VERSION => Ok(deserializer.deserialize(bytes_dirs)?),
|
||||||
|
version => {
|
||||||
|
bail!("unsupported version (got {version}, supports {})", Self::VERSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.context("could not deserialize database")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
||||||
|
(|| -> bincode::Result<_> {
|
||||||
|
// Preallocate buffer with combined size of sections.
|
||||||
|
let version_size = bincode::serialized_size(&Self::VERSION)?;
|
||||||
|
let dirs_size = bincode::serialized_size(&self)?;
|
||||||
|
let buffer_size = version_size + dirs_size;
|
||||||
|
let mut buffer = Vec::with_capacity(buffer_size as _);
|
||||||
|
|
||||||
|
// Serialize sections into buffer.
|
||||||
|
bincode::serialize_into(&mut buffer, &Self::VERSION)?;
|
||||||
|
bincode::serialize_into(&mut buffer, &self)?;
|
||||||
|
Ok(buffer)
|
||||||
|
})()
|
||||||
|
.context("could not serialize database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Deref for DirList<'a> {
|
||||||
|
type Target = Vec<Dir<'a>>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> DerefMut for DirList<'a> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Vec<Dir<'a>>> for DirList<'a> {
|
||||||
|
fn from(dirs: Vec<Dir<'a>>) -> Self {
|
||||||
|
DirList(dirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Dir<'a> {
|
||||||
|
#[serde(borrow)]
|
||||||
|
pub path: Cow<'a, str>,
|
||||||
|
pub rank: Rank,
|
||||||
|
pub last_accessed: Epoch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dir<'_> {
|
||||||
|
pub fn score(&self, now: Epoch) -> Rank {
|
||||||
|
const HOUR: Epoch = 60 * 60;
|
||||||
|
const DAY: Epoch = 24 * HOUR;
|
||||||
|
const WEEK: Epoch = 7 * DAY;
|
||||||
|
|
||||||
|
// The older the entry, the lesser its importance.
|
||||||
|
let duration = now.saturating_sub(self.last_accessed);
|
||||||
|
if duration < HOUR {
|
||||||
|
self.rank * 4.0
|
||||||
|
} else if duration < DAY {
|
||||||
|
self.rank * 2.0
|
||||||
|
} else if duration < WEEK {
|
||||||
|
self.rank * 0.5
|
||||||
|
} else {
|
||||||
|
self.rank * 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) -> DirDisplay {
|
||||||
|
DirDisplay { dir: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_score(&self, now: Epoch) -> DirDisplayScore {
|
||||||
|
DirDisplayScore { dir: self, now }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DirDisplay<'a> {
|
||||||
|
dir: &'a Dir<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for DirDisplay<'_> {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.dir.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DirDisplayScore<'a> {
|
||||||
|
dir: &'a Dir<'a>,
|
||||||
|
now: Epoch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for DirDisplayScore<'_> {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
let score = self.dir.score(self.now).clamp(0.0, 9999.0) as u64;
|
||||||
|
write!(f, "{:>4} {}", score, self.dir.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Rank = f64;
|
||||||
|
pub type Epoch = u64;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_copy() {
|
||||||
|
let dirs = DirList(vec![Dir { path: "/".into(), rank: 0.0, last_accessed: 0 }]);
|
||||||
|
|
||||||
|
let bytes = dirs.to_bytes().unwrap();
|
||||||
|
let dirs = DirList::from_bytes(&bytes).unwrap();
|
||||||
|
|
||||||
|
for dir in dirs.iter() {
|
||||||
|
assert!(matches!(dir.path, Cow::Borrowed(_)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
mod dir;
|
||||||
|
mod stream;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
pub use dir::{Dir, DirList, Epoch, Rank};
|
||||||
|
pub use stream::Stream;
|
||||||
|
|
||||||
|
use crate::util;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Database<'file> {
|
||||||
|
pub dirs: DirList<'file>,
|
||||||
|
pub modified: bool,
|
||||||
|
pub data_dir: &'file Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'file> Database<'file> {
|
||||||
|
pub fn save(&mut self) -> Result<()> {
|
||||||
|
if !self.modified {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = self.dirs.to_bytes()?;
|
||||||
|
let path = db_path(self.data_dir);
|
||||||
|
util::write(&path, &buffer).context("could not write to database")?;
|
||||||
|
self.modified = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming iterator for directories.
|
||||||
|
pub fn stream(&mut self, now: Epoch) -> Stream<'_, 'file> {
|
||||||
|
Stream::new(self, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DatabaseFile {
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseFile {
|
||||||
|
pub fn new<P: Into<PathBuf>>(data_dir: P) -> Self {
|
||||||
|
DatabaseFile { buffer: Vec::new(), data_dir: data_dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(&mut self) -> Result<Database> {
|
||||||
|
// Read the entire database to memory. For smaller files, this is faster than
|
||||||
|
// mmap / streaming, and allows for zero-copy deserialization.
|
||||||
|
let path = db_path(&self.data_dir);
|
||||||
|
match fs::read(&path) {
|
||||||
|
Ok(buffer) => {
|
||||||
|
self.buffer = buffer;
|
||||||
|
let dirs = DirList::from_bytes(&self.buffer)
|
||||||
|
.with_context(|| format!("could not deserialize database: {}", path.display()))?;
|
||||||
|
Ok(Database { dirs, modified: false, data_dir: &self.data_dir })
|
||||||
|
}
|
||||||
|
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(&self.data_dir)
|
||||||
|
.with_context(|| format!("unable to create data directory: {}", self.data_dir.display()))?;
|
||||||
|
Ok(Database { dirs: DirList::new(), modified: false, data_dir: &self.data_dir })
|
||||||
|
}
|
||||||
|
Err(e) => Err(e).with_context(|| format!("could not read from database: {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_path<P: AsRef<Path>>(data_dir: P) -> PathBuf {
|
||||||
|
const DB_FILENAME: &str = "db.zo";
|
||||||
|
data_dir.as_ref().join(DB_FILENAME)
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@ use std::iter::Rev;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::{fs, path};
|
use std::{fs, path};
|
||||||
|
|
||||||
use crate::db::{Database, Dir, Epoch};
|
use crate::db2::{Database, Dir, Epoch};
|
||||||
use crate::util;
|
use crate::util::{self, MONTH};
|
||||||
|
|
||||||
pub struct Stream<'db, 'file> {
|
pub struct Stream<'db, 'file> {
|
||||||
db: &'db mut Database<'file>,
|
db: &'db mut Database<'file>,
|
||||||
|
|
@ -24,8 +24,9 @@ impl<'db, 'file> Stream<'db, 'file> {
|
||||||
db.dirs.sort_unstable_by(|dir1, dir2| dir1.score(now).total_cmp(&dir2.score(now)));
|
db.dirs.sort_unstable_by(|dir1, dir2| dir1.score(now).total_cmp(&dir2.score(now)));
|
||||||
let idxs = (0..db.dirs.len()).rev();
|
let idxs = (0..db.dirs.len()).rev();
|
||||||
|
|
||||||
// If a directory is deleted and hasn't been used for 90 days, delete it from the database.
|
// If a directory is deleted and hasn't been used for 3 months, delete
|
||||||
let expire_below = now.saturating_sub(90 * 24 * 60 * 60);
|
// it from the database.
|
||||||
|
let expire_below = now.saturating_sub(3 * MONTH);
|
||||||
|
|
||||||
Stream {
|
Stream {
|
||||||
db,
|
db,
|
||||||
|
|
@ -7,9 +7,9 @@ use rstest_reuse;
|
||||||
mod cmd;
|
mod cmd;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod db2;
|
||||||
mod error;
|
mod error;
|
||||||
mod shell;
|
mod shell;
|
||||||
mod store;
|
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
|
||||||
296
src/store/mod.rs
296
src/store/mod.rs
|
|
@ -1,296 +0,0 @@
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::{fs, io};
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use bincode::Options;
|
|
||||||
use ouroboros::self_referencing;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{config, util};
|
|
||||||
|
|
||||||
#[self_referencing]
|
|
||||||
pub struct Store {
|
|
||||||
path: PathBuf,
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
#[borrows(bytes)]
|
|
||||||
#[covariant]
|
|
||||||
dirs: Vec<Dir<'this>>,
|
|
||||||
dirty: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Store {
|
|
||||||
const VERSION: u32 = 3;
|
|
||||||
|
|
||||||
pub fn open() -> Result<Self> {
|
|
||||||
let data_dir = config::data_dir()?;
|
|
||||||
Self::open_dir(&data_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_dir(data_dir: &Path) -> Result<Self> {
|
|
||||||
let path = data_dir.join("db.zo");
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
Err(e) => Err(e).with_context(|| format!("could not read from database: {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&mut self) -> Result<()> {
|
|
||||||
// Only write to disk if the database is modified.
|
|
||||||
if !self.dirty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = Self::serialize(self.dirs())?;
|
|
||||||
util::write(self.borrow_path(), &bytes).context("could not write to database")?;
|
|
||||||
self.with_dirty_mut(|dirty| *dirty = false);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increments the rank of a directory, or creates it if it does not exist.
|
|
||||||
pub fn add(&mut self, path: impl AsRef<str> + Into<String>, by: Rank, now: Epoch) {
|
|
||||||
self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) {
|
|
||||||
Some(dir) => dir.rank = (dir.rank + by).max(0.0),
|
|
||||||
None => dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }),
|
|
||||||
});
|
|
||||||
self.with_dirty_mut(|dirty| *dirty = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new directory. This will create a duplicate entry if this
|
|
||||||
/// directory is always in the database, it is expected that the user either
|
|
||||||
/// does a check before calling this, or calls `dedup()` afterward.
|
|
||||||
pub fn add_unchecked(&mut self, path: impl AsRef<str> + Into<String>, rank: Rank, now: Epoch) {
|
|
||||||
self.with_dirs_mut(|dirs| dirs.push(Dir { path: path.into().into(), rank, last_accessed: now }));
|
|
||||||
self.with_dirty_mut(|dirty| *dirty = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increments the rank and updates the last_accessed of a directory, or
|
|
||||||
/// creates it if it does not exist.
|
|
||||||
pub fn add_update(&mut self, path: impl AsRef<str> + Into<String>, by: Rank, now: Epoch) {
|
|
||||||
self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) {
|
|
||||||
Some(dir) => {
|
|
||||||
dir.rank = (dir.rank + by).max(0.0);
|
|
||||||
dir.last_accessed = now;
|
|
||||||
}
|
|
||||||
None => dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }),
|
|
||||||
});
|
|
||||||
self.with_dirty_mut(|dirty| *dirty = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the directory with `path` from the store. This does not preserve ordering, but is
|
|
||||||
/// O(1).
|
|
||||||
pub fn remove(&mut self, path: impl AsRef<str>) -> bool {
|
|
||||||
let deleted = self.with_dirs_mut(|dirs| match dirs.iter().position(|dir| dir.path == path.as_ref()) {
|
|
||||||
Some(idx) => {
|
|
||||||
dirs.swap_remove(idx);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
None => false,
|
|
||||||
});
|
|
||||||
self.with_dirty_mut(|dirty| *dirty |= deleted);
|
|
||||||
deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn age(&mut self, max_age: Rank) {
|
|
||||||
let mut dirty = false;
|
|
||||||
self.with_dirs_mut(|dirs| {
|
|
||||||
let total_age = dirs.iter().map(|dir| dir.rank).sum::<Rank>();
|
|
||||||
if total_age > max_age {
|
|
||||||
let factor = 0.9 * max_age / total_age;
|
|
||||||
for idx in (0..dirs.len()).rev() {
|
|
||||||
let dir = &mut dirs[idx];
|
|
||||||
dir.rank *= factor;
|
|
||||||
if dir.rank < 1.0 {
|
|
||||||
dirs.swap_remove(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.with_dirty_mut(|dirty_prev| *dirty_prev |= dirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dedup(&mut self) {
|
|
||||||
// Sort by path, so that equal paths are next to each other.
|
|
||||||
self.sort_by_path();
|
|
||||||
|
|
||||||
let mut dirty = false;
|
|
||||||
self.with_dirs_mut(|dirs| {
|
|
||||||
for idx in (1..dirs.len()).rev() {
|
|
||||||
// Check if curr_dir and next_dir have equal paths.
|
|
||||||
let curr_dir = &dirs[idx];
|
|
||||||
let next_dir = &dirs[idx - 1];
|
|
||||||
if next_dir.path != curr_dir.path {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge curr_dir's rank and last_accessed into next_dir.
|
|
||||||
let rank = curr_dir.rank;
|
|
||||||
let last_accessed = curr_dir.last_accessed;
|
|
||||||
let next_dir = &mut dirs[idx - 1];
|
|
||||||
next_dir.last_accessed = next_dir.last_accessed.max(last_accessed);
|
|
||||||
next_dir.rank += rank;
|
|
||||||
|
|
||||||
// Delete curr_dir.
|
|
||||||
dirs.swap_remove(idx);
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.with_dirty_mut(|dirty_prev| *dirty_prev |= dirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sort_by_path(&mut self) {
|
|
||||||
self.with_dirs_mut(|dirs| dirs.sort_unstable_by(|dir1, dir2| dir1.path.cmp(&dir2.path)));
|
|
||||||
self.with_dirty_mut(|dirty| *dirty = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sort_by_score(&mut self, now: Epoch) {
|
|
||||||
self.with_dirs_mut(|dirs| {
|
|
||||||
dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| dir1.score(now).total_cmp(&dir2.score(now)))
|
|
||||||
});
|
|
||||||
self.with_dirty_mut(|dirty| *dirty = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dirty(&self) -> bool {
|
|
||||||
*self.borrow_dirty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dirs(&self) -> &[Dir] {
|
|
||||||
self.borrow_dirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Serialize sections into buffer.
|
|
||||||
bincode::serialize_into(&mut buffer, &Self::VERSION)?;
|
|
||||||
bincode::serialize_into(&mut buffer, &dirs)?;
|
|
||||||
|
|
||||||
Ok(buffer)
|
|
||||||
})()
|
|
||||||
.context("could not serialize database")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize(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
|
|
||||||
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 (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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct Dir<'a> {
|
|
||||||
#[serde(borrow)]
|
|
||||||
pub path: Cow<'a, str>,
|
|
||||||
pub rank: Rank,
|
|
||||||
pub last_accessed: Epoch,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dir<'_> {
|
|
||||||
pub fn score(&self, now: Epoch) -> Rank {
|
|
||||||
const HOUR: Epoch = 60 * 60;
|
|
||||||
const DAY: Epoch = 24 * HOUR;
|
|
||||||
const WEEK: Epoch = 7 * DAY;
|
|
||||||
|
|
||||||
// The older the entry, the lesser its importance.
|
|
||||||
let duration = now.saturating_sub(self.last_accessed);
|
|
||||||
if duration < HOUR {
|
|
||||||
self.rank * 4.0
|
|
||||||
} else if duration < DAY {
|
|
||||||
self.rank * 2.0
|
|
||||||
} else if duration < WEEK {
|
|
||||||
self.rank * 0.5
|
|
||||||
} else {
|
|
||||||
self.rank * 0.25
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Rank = f64;
|
|
||||||
pub type Epoch = u64;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add() {
|
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" };
|
|
||||||
let now = 946684800;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut db = Store::open_dir(data_dir.path()).unwrap();
|
|
||||||
db.add(path, 1.0, now);
|
|
||||||
db.add(path, 1.0, now);
|
|
||||||
db.save().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let db = Store::open_dir(data_dir.path()).unwrap();
|
|
||||||
assert_eq!(db.dirs().len(), 1);
|
|
||||||
|
|
||||||
let dir = &db.dirs()[0];
|
|
||||||
assert_eq!(dir.path, path);
|
|
||||||
assert!((dir.rank - 2.0).abs() < 0.01);
|
|
||||||
assert_eq!(dir.last_accessed, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove() {
|
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" };
|
|
||||||
let now = 946684800;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut db = Store::open_dir(data_dir.path()).unwrap();
|
|
||||||
db.add(path, 1.0, now);
|
|
||||||
db.save().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut db = Store::open_dir(data_dir.path()).unwrap();
|
|
||||||
assert!(db.remove(path));
|
|
||||||
db.save().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut db = Store::open_dir(data_dir.path()).unwrap();
|
|
||||||
assert!(db.dirs().is_empty());
|
|
||||||
assert!(!db.remove(path));
|
|
||||||
db.save().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
92
src/util.rs
92
src/util.rs
|
|
@ -1,5 +1,6 @@
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
@ -10,9 +11,98 @@ use anyhow::anyhow;
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::db::Epoch;
|
use crate::db2::Epoch;
|
||||||
use crate::error::SilentExit;
|
use crate::error::SilentExit;
|
||||||
|
|
||||||
|
pub const SECOND: Epoch = 1;
|
||||||
|
pub const MINUTE: Epoch = 60 * SECOND;
|
||||||
|
pub const HOUR: Epoch = 60 * MINUTE;
|
||||||
|
pub const DAY: Epoch = 24 * HOUR;
|
||||||
|
pub const WEEK: Epoch = 7 * DAY;
|
||||||
|
pub const MONTH: Epoch = 30 * DAY;
|
||||||
|
|
||||||
|
pub struct Fz(Command);
|
||||||
|
|
||||||
|
impl Fz {
|
||||||
|
const ERR_FZF_NOT_FOUND: &str = "could not find fzf, is it installed?";
|
||||||
|
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
// On Windows, CreateProcess implicitly searches the current working
|
||||||
|
// directory for the executable, which is a potential security issue.
|
||||||
|
// Instead, we resolve the path to the executable and then pass it to
|
||||||
|
// CreateProcess.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let program = which::which("fzf.exe").map_err(|_| anyhow!(Self::ERR_FZF_NOT_FOUND))?;
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let program = "fzf";
|
||||||
|
Ok(Fz(Command::new(program)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(&mut self) -> Result<FzfChild> {
|
||||||
|
match self.0.spawn() {
|
||||||
|
Ok(child) => Ok(FzfChild(child)),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => bail!(Self::ERR_FZF_NOT_FOUND),
|
||||||
|
Err(e) => Err(e).context("could not launch fzf"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for Fz {
|
||||||
|
type Target = Command;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for Fz {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FzfChild(Child);
|
||||||
|
|
||||||
|
impl FzfChild {
|
||||||
|
pub fn select(&mut self) -> Result<String> {
|
||||||
|
// Drop stdin to prevent deadlock.
|
||||||
|
mem::drop(self.stdin.take());
|
||||||
|
|
||||||
|
let mut stdout = self.stdout.take().unwrap();
|
||||||
|
let mut output = String::new();
|
||||||
|
stdout.read_to_string(&mut output).context("failed to read from fzf")?;
|
||||||
|
|
||||||
|
self.wait()?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait(&mut self) -> Result<()> {
|
||||||
|
let status = self.0.wait().context("wait failed on fzf")?;
|
||||||
|
match status.code() {
|
||||||
|
Some(0) => Ok(()),
|
||||||
|
Some(1) => bail!("no match found"),
|
||||||
|
Some(2) => bail!("fzf returned an error"),
|
||||||
|
Some(130) => bail!(SilentExit { code: 130 }),
|
||||||
|
Some(128..=254) | None => bail!("fzf was terminated"),
|
||||||
|
_ => bail!("fzf returned an unknown error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for FzfChild {
|
||||||
|
type Target = Child;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for FzfChild {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Fzf {
|
pub struct Fzf {
|
||||||
child: Child,
|
child: Child,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue