diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d9010..f103d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `edit` subcommand to adjust the scores of entries. + ### Fixed - Zsh: completions clashing with `zsh-autocomplete`. @@ -20,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fzf: `` now cycles through completions. - Fzf: enable colors in preview when possible on macOS / BSD. +### Removed + +- `remove` subcommand: use `edit` instead. + ## [0.8.3] - 2022-09-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index e413350..c366bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "aho-corasick" version = "0.7.19" @@ -11,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "anyhow" version = "1.0.66" @@ -411,6 +423,29 @@ version = "6.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3baf96e39c5359d2eb0dd6ccb42c62b91d9678aa68160d261b9e0ccbf9e9dea9" +[[package]] +name = "ouroboros" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbb50b356159620db6ac971c6d5c9ab788c9cc38a6f49619fca2a27acb062ca" +dependencies = [ + "aliasable", + "ouroboros_macro", +] + +[[package]] +name = "ouroboros_macro" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0d9d1a6191c4f391f87219d1ea42b23f09ee84d64763cd05ee6ea88d9f384d" +dependencies = [ + "Inflector", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "predicates" version = "2.1.1" @@ -806,6 +841,7 @@ dependencies = [ "fastrand", "glob", "nix", + "ouroboros", "rstest", "rstest_reuse", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6c4d906..fb1e930 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ nix = { version = "0.25.0", default-features = false, features = [ "fs", "user", ] } +ouroboros = "0.15.5" rstest = { version = "0.15.0", default-features = false } rstest_reuse = "0.4.0" serde = { version = "1.0.116", features = ["derive"] } @@ -51,6 +52,7 @@ dirs.workspace = true dunce.workspace = true fastrand.workspace = true glob.workspace = true +ouroboros.workspace = true serde.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index e0ca960..3a5c48c 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -134,8 +134,6 @@ _arguments "${_arguments_options[@]}" \ ;; (remove) _arguments "${_arguments_options[@]}" \ -'-i[Use interactive selection]' \ -'--interactive[Use interactive selection]' \ '-h[Print help information]' \ '--help[Print help information]' \ '-V[Print version information]' \ diff --git a/contrib/completions/_zoxide.ps1 b/contrib/completions/_zoxide.ps1 index a065513..abdee9d 100644 --- a/contrib/completions/_zoxide.ps1 +++ b/contrib/completions/_zoxide.ps1 @@ -114,8 +114,6 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { break } 'zoxide;remove' { - [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'Use interactive selection') - [CompletionResult]::new('--interactive', 'interactive', [CompletionResultType]::ParameterName, 'Use interactive selection') [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information') [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information') [CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information') diff --git a/contrib/completions/zoxide.bash b/contrib/completions/zoxide.bash index ac5ea86..6583641 100644 --- a/contrib/completions/zoxide.bash +++ b/contrib/completions/zoxide.bash @@ -205,7 +205,7 @@ _zoxide() { return 0 ;; zoxide__remove) - opts="-i -h -V --interactive --help --version [PATHS]..." + opts="-h -V --help --version [PATHS]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/contrib/completions/zoxide.elv b/contrib/completions/zoxide.elv index ebf560c..2bcdb9b 100644 --- a/contrib/completions/zoxide.elv +++ b/contrib/completions/zoxide.elv @@ -101,8 +101,6 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand --version 'Print version information' } &'zoxide;remove'= { - cand -i 'Use interactive selection' - cand --interactive 'Use interactive selection' cand -h 'Print help information' cand --help 'Print help information' cand -V 'Print version information' diff --git a/contrib/completions/zoxide.fish b/contrib/completions/zoxide.fish index b85412f..9545776 100644 --- a/contrib/completions/zoxide.fish +++ b/contrib/completions/zoxide.fish @@ -38,6 +38,5 @@ complete -c zoxide -n "__fish_seen_subcommand_from query" -s l -l list -d 'List complete -c zoxide -n "__fish_seen_subcommand_from query" -s s -l score -d 'Print score with results' complete -c zoxide -n "__fish_seen_subcommand_from query" -s h -l help -d 'Print help information' complete -c zoxide -n "__fish_seen_subcommand_from query" -s V -l version -d 'Print version information' -complete -c zoxide -n "__fish_seen_subcommand_from remove" -s i -l interactive -d 'Use interactive selection' complete -c zoxide -n "__fish_seen_subcommand_from remove" -s h -l help -d 'Print help information' complete -c zoxide -n "__fish_seen_subcommand_from remove" -s V -l version -d 'Print version information' diff --git a/contrib/completions/zoxide.ts b/contrib/completions/zoxide.ts index 9b6cf63..b188b93 100644 --- a/contrib/completions/zoxide.ts +++ b/contrib/completions/zoxide.ts @@ -243,10 +243,6 @@ const completion: Fig.Spec = { name: "remove", description: "Remove a directory from the database", options: [ - { - name: ["-i", "--interactive"], - description: "Use interactive selection", - }, { name: ["-h", "--help"], description: "Print help information", diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs index 34d1aaf..9a17633 100644 --- a/src/cmd/cmd.rs +++ b/src/cmd/cmd.rs @@ -141,9 +141,6 @@ pub struct Query { /// Remove a directory from the database #[derive(Debug, Parser)] pub struct Remove { - /// Use interactive selection - #[clap(long, short)] - pub interactive: bool, #[clap(value_hint = ValueHint::DirPath)] pub paths: Vec, } diff --git a/src/cmd/edit.rs b/src/cmd/edit.rs index f16c661..7a1e54f 100644 --- a/src/cmd/edit.rs +++ b/src/cmd/edit.rs @@ -4,76 +4,61 @@ use std::process::Command; use anyhow::Result; use crate::cmd::{Edit, EditCommand, Run}; -use crate::db::{Database, DatabaseFile, Epoch}; -use crate::{config, util}; +use crate::store::{Epoch, Store}; +use crate::util; impl Run for Edit { fn run(&self) -> Result<()> { let now = util::current_time()?; - - let data_dir = config::data_dir()?; - let mut db = DatabaseFile::new(data_dir); - let db = &mut db.open()?; + let db = &mut Store::open()?; match &self.cmd { Some(EditCommand::Decrement { path }) => { - if let Some(dir) = db.dirs.iter_mut().find(|dir| &dir.path == path) { - dir.rank = (dir.rank - 1.0).max(0.0); - } - db.modified = true; + db.increment(path, -1.0, now); db.save()?; print_dirs(db, now); } Some(EditCommand::Delete { path }) => { - if let Some(idx) = db.dirs.iter().position(|dir| &dir.path == path) { - db.dirs.remove(idx); - } - db.modified = true; + db.remove(path); db.save()?; print_dirs(db, now); } Some(EditCommand::Increment { path }) => { - if let Some(dir) = db.dirs.iter_mut().find(|dir| &dir.path == path) { - dir.rank += 1.0; - } - db.modified = true; + db.increment(path, 1.0, now); db.save()?; print_dirs(db, now); } Some(EditCommand::Reload) => print_dirs(db, now), None => { - db.dirs.sort_unstable_by(|dir1, dir2| dir2.score(now).total_cmp(&dir1.score(now))); - db.modified = true; + db.sort_by_score(now); db.save()?; let mut fzf = Command::new("fzf"); fzf.args([ - "--bind=\ -ctrl-r:reload(zoxide edit reload),\ -ctrl-w:reload(zoxide edit delete {2..}),\ -ctrl-a:reload(zoxide edit increment {2..}),\ -ctrl-d:reload(zoxide edit decrement {2..}),\ -double-click:ignore,\ -enter:abort", - "--header=\ -ctrl-r:reload ctrl-w:delete -ctrl-a:increment ctrl-d:decrement - -SCORE PATH", - // // Search mode "--delimiter=\\x00 ", "--nth=2", // Search result "--no-sort", // Interface + "--bind=\ + ctrl-r:reload(zoxide edit reload),\ + ctrl-w:reload(zoxide edit delete {2..}),\ + ctrl-a:reload(zoxide edit increment {2..}),\ + ctrl-d:reload(zoxide edit decrement {2..}),\ + ctrl-z:ignore,\ + double-click:ignore,\ + enter:abort", "--cycle", "--keep-right", // Layout + "--header=\ +ctrl-r:reload ctrl-w:delete +ctrl-a:increment ctrl-d:decrement + +SCORE PATH", "--info=inline", "--layout=reverse", - // Key/Event bindings - "--bind=ctrl-z:ignore", ]) .envs([("FZF_DEFAULT_COMMAND", "zoxide edit reload")]); @@ -86,9 +71,9 @@ SCORE PATH", } } -fn print_dirs(db: &Database, now: Epoch) { +fn print_dirs(db: &Store, now: Epoch) { let stdout = &mut io::stdout().lock(); - for dir in db.dirs.iter() { + for dir in db.dirs().iter().rev() { writeln!(stdout, "{:>5}\x00 {}", dir.score(now), &dir.path).unwrap(); } } diff --git a/src/cmd/remove.rs b/src/cmd/remove.rs index e5ed48c..abdb1e6 100644 --- a/src/cmd/remove.rs +++ b/src/cmd/remove.rs @@ -1,11 +1,8 @@ -use std::io::{self, Write}; - -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Result}; use crate::cmd::{Remove, Run}; -use crate::config; use crate::db::DatabaseFile; -use crate::util::{self, Fzf}; +use crate::{config, util}; impl Run for Remove { fn run(&self) -> Result<()> { @@ -13,38 +10,13 @@ impl Run for Remove { let mut db = DatabaseFile::new(data_dir); let mut db = db.open()?; - if self.interactive { - let keywords = &self.paths; - let now = util::current_time()?; - let mut stream = db.stream(now).with_keywords(keywords); - - let mut fzf = Fzf::new(true)?; - let stdin = fzf.stdin(); - - let selection = loop { - let Some(dir) = stream.next() else { break fzf.select()? }; - match writeln!(stdin, "{}", dir.display_score(now)) { - Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break fzf.select()?, - result => result.context("could not write to fzf")?, - } - }; - - let paths = selection.lines().filter_map(|line| line.get(5..)); - for path in paths { - if !db.remove(path) { + for path in &self.paths { + if !db.remove(path) { + let path_abs = util::resolve_path(path)?; + let path_abs = util::path_to_str(&path_abs)?; + if path_abs == path || !db.remove(path_abs) { db.modified = false; - bail!("path not found in database: {path}"); - } - } - } else { - for path in &self.paths { - if !db.remove(path) { - let path_abs = util::resolve_path(path)?; - let path_abs = util::path_to_str(&path_abs)?; - if path_abs == path || !db.remove(path_abs) { - db.modified = false; - bail!("path not found in database: {path}") - } + bail!("path not found in database: {path}") } } } diff --git a/src/main.rs b/src/main.rs index 6c7545f..3a5f0a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod config; mod db; mod error; mod shell; +mod store; mod util; use std::env; diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 0000000..da8b32b --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,223 @@ +use std::borrow::Cow; +use std::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, + #[borrows(bytes)] + #[covariant] + dirs: Vec>, + dirty: bool, +} + +impl Store { + const VERSION: u32 = 3; + + pub fn open() -> Result { + let data_dir = config::data_dir()?; + 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(data_dir, 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.borrow_dirty() { + return Ok(()); + } + + let bytes = Self::serialize(self.borrow_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 increment(&mut self, path: impl AsRef + Into, 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); + } + + /// Increments the rank and updates the last_accessed of a directory, or + /// creates it if it does not exist. + pub fn increment_update(&mut self, path: impl AsRef + Into, 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); + } + + pub fn remove(&mut self, path: impl AsRef) -> 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::(); + 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 dirs(&self) -> &[Dir] { + self.borrow_dirs() + } + + 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); + + // 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> { + // 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; diff --git a/src/util.rs b/src/util.rs index 9e4cef7..605c3da 100644 --- a/src/util.rs +++ b/src/util.rs @@ -161,7 +161,9 @@ fn tmpfile>(dir: P) -> Result<(File, PathBuf)> { match OpenOptions::new().write(true).create_new(true).open(&path) { Ok(file) => break Ok((file, path)), Err(e) if e.kind() == io::ErrorKind::AlreadyExists && attempts < MAX_ATTEMPTS => (), - Err(e) => break Err(e).with_context(|| format!("could not create file: {}", path.display())), + Err(e) => { + break Err(e).with_context(|| format!("could not create file: {}", path.display())); + } } } }