diff --git a/README.md b/README.md index 878fba0..b21cc01 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,13 @@ Environment variables[^2] can be used for configuration. They must be set before - Configures the [aging algorithm][algorithm-aging], which limits the maximum number of entries in the database. - By default, this is set to 10000. +- `_ZO_RANKING_MODE` + - Selects the ranking function used when sorting query results: + - `frecency` (default): combines frequency and recency, as described in + [Algorithm][algorithm-frecency]. + - `recency`: sorts purely by `last_accessed`, so the most recently visited + match always wins regardless of how often older directories were used. + - The value is case-insensitive. Unrecognized values fall back to `frecency`. - `_ZO_RESOLVE_SYMLINKS` - When set to 1, `z` will resolve symlinks before adding directories to the database. @@ -469,6 +476,7 @@ Environment variables[^2] can be used for configuration. They must be set before [alfred]: https://www.alfredapp.com/ [alfred-zoxide]: https://github.com/yihou/alfred-zoxide [algorithm-aging]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#aging +[algorithm-frecency]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#frecency [algorithm-matching]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#matching [alpine linux packages]: https://pkgs.alpinelinux.org/packages?name=zoxide [arch linux extra]: https://archlinux.org/packages/extra/x86_64/zoxide/ diff --git a/man/man1/zoxide.1 b/man/man1/zoxide.1 index ef1792b..7ec7b30 100644 --- a/man/man1/zoxide.1 +++ b/man/man1/zoxide.1 @@ -96,6 +96,13 @@ manpage for the full list of options. Configures the aging algorithm, which limits the maximum number of entries in the database. By default, this is set to 10000. .TP +.B _ZO_RANKING_MODE +Selects the ranking function used when sorting query results. Accepted values +are \fBfrecency\fR (default) and \fBrecency\fR. With \fBrecency\fR, results are +sorted purely by \fBlast_accessed\fR so the most recently visited match always +wins. The value is case-insensitive; unrecognized values fall back to +\fBfrecency\fR. +.TP .B _ZO_RESOLVE_SYMLINKS When set to 1, \fBz\fR will resolve symlinks before adding directories to the database. diff --git a/src/cmd/edit.rs b/src/cmd/edit.rs index 0f37165..70b1e38 100644 --- a/src/cmd/edit.rs +++ b/src/cmd/edit.rs @@ -3,6 +3,7 @@ use std::io::{self, Write}; use anyhow::Result; use crate::cmd::{Edit, EditCommand, Run}; +use crate::config; use crate::db::Database; use crate::error::BrokenPipeHandler; use crate::util::{self, Fzf, FzfChild}; @@ -32,7 +33,7 @@ impl Run for Edit { Ok(()) } None => { - db.sort_by_score(now); + db.sort_by_score(now, config::ranking_mode()); db.save()?; Self::get_fzf()?.wait()?; Ok(()) diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 6539c2e..24bd428 100644 --- a/src/cmd/query.rs +++ b/src/cmd/query.rs @@ -80,7 +80,8 @@ impl Query { let mut options = StreamOptions::new(now) .with_keywords(self.keywords.iter().map(|s| s.as_str())) .with_exclude(config::exclude_dirs()?) - .with_base_dir(self.base_dir.clone()); + .with_base_dir(self.base_dir.clone()) + .with_ranking_mode(config::ranking_mode()); if !self.all { let resolve_symlinks = config::resolve_symlinks(); options = options.with_exists(true).with_resolve_symlinks(resolve_symlinks); diff --git a/src/config.rs b/src/config.rs index 0aeda5c..4e73a65 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,13 @@ use glob::Pattern; use crate::db::Rank; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum RankingMode { + #[default] + Frecency, + Recency, +} + pub fn data_dir() -> Result { let dir = match env::var_os("_ZO_DATA_DIR") { Some(path) => PathBuf::from(path), @@ -60,3 +67,10 @@ pub fn maxage() -> Result { pub fn resolve_symlinks() -> bool { env::var_os("_ZO_RESOLVE_SYMLINKS").is_some_and(|var| var == "1") } + +pub fn ranking_mode() -> RankingMode { + match env::var_os("_ZO_RANKING_MODE") { + Some(var) if var.eq_ignore_ascii_case("recency") => RankingMode::Recency, + _ => RankingMode::Frecency, + } +} diff --git a/src/db/dir.rs b/src/db/dir.rs index 5d6d62c..1a62c2d 100644 --- a/src/db/dir.rs +++ b/src/db/dir.rs @@ -3,6 +3,7 @@ use std::fmt::{self, Display, Formatter}; use serde::{Deserialize, Serialize}; +use crate::config::RankingMode; use crate::util::{DAY, HOUR, WEEK}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -18,7 +19,14 @@ impl Dir<'_> { DirDisplay::new(self) } - pub fn score(&self, now: Epoch) -> Rank { + pub fn score(&self, now: Epoch, mode: RankingMode) -> Rank { + match mode { + RankingMode::Frecency => self.frecency(now), + RankingMode::Recency => self.last_accessed as Rank, + } + } + + pub(crate) fn frecency(&self, now: Epoch) -> Rank { // The older the entry, the lesser its importance. let duration = now.saturating_sub(self.last_accessed); if duration < HOUR { @@ -58,7 +66,9 @@ impl<'a> DirDisplay<'a> { impl Display for DirDisplay<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { if let Some(now) = self.now { - let score = self.dir.score(now).clamp(0.0, 9999.0); + // Always display the frecency value so that `--score` stays + // human-readable regardless of the active ranking mode. + let score = self.dir.frecency(now).clamp(0.0, 9999.0); write!(f, "{score:>6.1}{}", self.separator)?; } write!(f, "{}", self.dir.path) @@ -67,3 +77,43 @@ impl Display for DirDisplay<'_> { pub type Rank = f64; pub type Epoch = u64; + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::HOUR; + + fn dir(path: &'static str, rank: Rank, last_accessed: Epoch) -> Dir<'static> { + Dir { path: Cow::Borrowed(path), rank, last_accessed } + } + + #[test] + fn frecency_mode_prefers_high_rank() { + let now = 10 * HOUR; + // Same age bucket (>1h, <1d). Higher rank wins. + let popular = dir("/popular", 50.0, now - 2 * HOUR); + let recent = dir("/recent", 1.0, now - 2 * HOUR); + assert!( + popular.score(now, RankingMode::Frecency) > recent.score(now, RankingMode::Frecency) + ); + } + + #[test] + fn recency_mode_prefers_recent_access() { + let now = 10 * HOUR; + let popular_but_old = dir("/popular", 50.0, now - 5 * HOUR); + let unpopular_but_recent = dir("/recent", 1.0, now - HOUR); + assert!( + unpopular_but_recent.score(now, RankingMode::Recency) + > popular_but_old.score(now, RankingMode::Recency) + ); + } + + #[test] + fn recency_mode_ignores_rank() { + let now = 10 * HOUR; + let a = dir("/a", 1.0, now - HOUR); + let b = dir("/b", 9999.0, now - HOUR); + assert_eq!(a.score(now, RankingMode::Recency), b.score(now, RankingMode::Recency)); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 1856fda..57e26ba 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,9 +8,10 @@ use anyhow::{Context, Result, bail}; use bincode::Options; use ouroboros::self_referencing; +use crate::config::{self, RankingMode}; pub use crate::db::dir::{Dir, Epoch, Rank}; pub use crate::db::stream::{Stream, StreamOptions}; -use crate::{config, util}; +use crate::util; #[self_referencing] pub struct Database { @@ -171,10 +172,10 @@ impl Database { self.with_dirty_mut(|dirty| *dirty = true); } - pub fn sort_by_score(&mut self, now: Epoch) { + pub fn sort_by_score(&mut self, now: Epoch, mode: RankingMode) { self.with_dirs_mut(|dirs| { dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| { - dir1.score(now).total_cmp(&dir2.score(now)) + dir1.score(now, mode).total_cmp(&dir2.score(now, mode)) }) }); self.with_dirty_mut(|dirty| *dirty = true); diff --git a/src/db/stream.rs b/src/db/stream.rs index 24c84e0..0b2855c 100644 --- a/src/db/stream.rs +++ b/src/db/stream.rs @@ -5,6 +5,7 @@ use std::{fs, path}; use glob::Pattern; +use crate::config::RankingMode; use crate::db::{Database, Dir, Epoch}; use crate::util::{self, MONTH}; @@ -16,7 +17,7 @@ pub struct Stream<'a> { impl<'a> Stream<'a> { pub fn new(db: &'a mut Database, options: StreamOptions) -> Self { - db.sort_by_score(options.now); + db.sort_by_score(options.now, options.ranking_mode); let idxs = (0..db.dirs().len()).rev(); Stream { db, idxs, options } } @@ -129,6 +130,9 @@ pub struct StreamOptions { /// Only return directories within this parent directory /// Does not check if the path exists base_dir: Option, + + /// Which scoring function to use when sorting directories. + ranking_mode: RankingMode, } impl StreamOptions { @@ -141,9 +145,15 @@ impl StreamOptions { resolve_symlinks: false, ttl: now.saturating_sub(3 * MONTH), base_dir: None, + ranking_mode: RankingMode::default(), } } + pub fn with_ranking_mode(mut self, ranking_mode: RankingMode) -> Self { + self.ranking_mode = ranking_mode; + self + } + pub fn with_keywords(mut self, keywords: I) -> Self where I: IntoIterator,