From a4fcb39c8b1265130027f5c3c4846bd889eab92e Mon Sep 17 00:00:00 2001 From: Ajeet D'Souza <98ajeet@gmail.com> Date: Mon, 17 May 2021 11:39:26 +0530 Subject: [PATCH] Remove deleted entries if not accessed in the last 90 days --- man/zoxide.1 | 2 + src/app/_app.rs | 2 +- src/app/query.rs | 19 ++++---- src/app/remove.rs | 6 +-- src/db/mod.rs | 29 +++++------ src/db/{query.rs => stream.rs} | 89 +++++++++++++++++++++++++++++----- 6 files changed, 104 insertions(+), 43 deletions(-) rename src/db/{query.rs => stream.rs} (53%) diff --git a/man/zoxide.1 b/man/zoxide.1 index f3788fb..54edce3 100644 --- a/man/zoxide.1 +++ b/man/zoxide.1 @@ -8,6 +8,7 @@ zoxide is a smarter replacement for your cd command. It keeps track of the directories you use most frequently, and uses a ranking algorithm to navigate to the best match. .SH USAGE +.nf \fBz\fR \fIfoo\fR # cd into highest ranked directory matching foo \fBz\fR \fIfoo bar\fR # cd into highest ranked directory matching foo and bar .sp @@ -17,6 +18,7 @@ to the best match. \fBz\fR \fI-\fR # cd into previous directory .sp \fBzi\fR \fIfoo\fR # cd with interactive selection (using fzf) +.fi .SH SUBCOMMANDS .TP \fBzoxide-add\fR(1) diff --git a/src/app/_app.rs b/src/app/_app.rs index 4989883..3d9a027 100644 --- a/src/app/_app.rs +++ b/src/app/_app.rs @@ -116,7 +116,7 @@ pub struct Query { pub score: bool, /// Exclude a path from results - #[clap(long, hidden = true)] + #[clap(long, value_name = "path")] pub exclude: Option, } diff --git a/src/app/query.rs b/src/app/query.rs index 0e4e8e7..a218576 100644 --- a/src/app/query.rs +++ b/src/app/query.rs @@ -1,6 +1,6 @@ use crate::app::{Query, Run}; use crate::config; -use crate::db::{DatabaseFile, Matcher}; +use crate::db::DatabaseFile; use crate::error::BrokenPipeHandler; use crate::fzf::Fzf; use crate::util; @@ -16,19 +16,18 @@ impl Run for Query { let mut db = db.open()?; let now = util::current_time()?; - let mut matcher = Matcher::new().with_keywords(&self.keywords); + let mut stream = db.stream(now).with_keywords(&self.keywords); if !self.all { let resolve_symlinks = config::zo_resolve_symlinks(); - matcher = matcher.with_exists(resolve_symlinks); + stream = stream.with_exists(resolve_symlinks); + } + if let Some(path) = &self.exclude { + stream = stream.with_exclude(path); } - - let mut matches = db - .iter(&matcher, now) - .filter(|dir| Some(dir.path.as_ref()) != self.exclude.as_deref()); if self.interactive { let mut fzf = Fzf::new(false)?; - for dir in matches { + while let Some(dir) = stream.next() { writeln!(fzf.stdin(), "{}", dir.display_score(now)).pipe_exit("fzf")?; } @@ -49,7 +48,7 @@ impl Run for Query { let stdout = stdout.lock(); let mut handle = BufWriter::new(stdout); - for dir in matches { + while let Some(dir) = stream.next() { if self.score { writeln!(handle, "{}", dir.display_score(now)) } else { @@ -59,7 +58,7 @@ impl Run for Query { } handle.flush().pipe_exit("stdout")?; } else { - let dir = matches.next().context("no match found")?; + let dir = stream.next().context("no match found")?; if self.score { writeln!(io::stdout(), "{}", dir.display_score(now)) } else { diff --git a/src/app/remove.rs b/src/app/remove.rs index d9295fe..906a3ba 100644 --- a/src/app/remove.rs +++ b/src/app/remove.rs @@ -1,6 +1,6 @@ use crate::app::{Remove, Run}; use crate::config; -use crate::db::{DatabaseFile, Matcher}; +use crate::db::DatabaseFile; use crate::error::BrokenPipeHandler; use crate::fzf::Fzf; use crate::util; @@ -18,11 +18,11 @@ impl Run for Remove { let selection; match &self.interactive { Some(keywords) => { - let matcher = Matcher::new().with_keywords(keywords); let now = util::current_time()?; + let mut stream = db.stream(now).with_keywords(keywords); let mut fzf = Fzf::new(true)?; - for dir in db.iter(&matcher, now) { + while let Some(dir) = stream.next() { writeln!(fzf.stdin(), "{}", dir.display_score(now)).pipe_exit("fzf")?; } diff --git a/src/db/mod.rs b/src/db/mod.rs index 5a93d66..1ba757e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,26 +1,24 @@ mod dir; -mod query; +mod stream; pub use dir::{Dir, DirList, Epoch, Rank}; -pub use query::Matcher; +pub use stream::Stream; use anyhow::{Context, Result}; -use ordered_float::OrderedFloat; use tempfile::{NamedTempFile, PersistError}; use std::borrow::Cow; -use std::cmp::Reverse; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -pub struct Database<'a> { - pub dirs: DirList<'a>, +pub struct Database<'file> { + pub dirs: DirList<'file>, pub modified: bool, - data_dir: &'a Path, + data_dir: &'file PathBuf, } -impl<'a> Database<'a> { +impl<'file> Database<'file> { pub fn save(&mut self) -> Result<()> { if !self.modified { return Ok(()); @@ -72,12 +70,9 @@ impl<'a> Database<'a> { self.modified = true; } - pub fn iter<'i>(&'i mut self, m: &'i Matcher, now: Epoch) -> impl Iterator { - self.dirs - .sort_unstable_by_key(|dir| Reverse(OrderedFloat(dir.score(now)))); - self.dirs - .iter() - .filter(move |dir| m.matches(dir.path.as_ref())) + // Streaming iterator for directories. + pub fn stream(&mut self, now: Epoch) -> Stream<'_, 'file> { + Stream::new(self, now) } /// Removes the directory with `path` from the store. @@ -159,15 +154,15 @@ fn persist>(file: NamedTempFile, path: P) -> Result<(), PersistEr } pub struct DatabaseFile { - data_dir: PathBuf, buffer: Vec, + data_dir: PathBuf, } impl DatabaseFile { - pub fn new>(data_dir: P) -> DatabaseFile { + pub fn new>(data_dir: P) -> Self { DatabaseFile { - data_dir: data_dir.into(), buffer: Vec::new(), + data_dir: data_dir.into(), } } diff --git a/src/db/query.rs b/src/db/stream.rs similarity index 53% rename from src/db/query.rs rename to src/db/stream.rs index add5483..4179f10 100644 --- a/src/db/query.rs +++ b/src/db/stream.rs @@ -1,33 +1,89 @@ +use super::{Database, Dir, Epoch}; use crate::util; +use ordered_float::OrderedFloat; + use std::fs; +use std::iter::Rev; +use std::ops::Range; use std::path; -#[derive(Debug, Default)] -pub struct Matcher { +pub struct Stream<'db, 'file> { + db: &'db mut Database<'file>, + idxs: Rev>, + keywords: Vec, + check_exists: bool, + expire_below: Epoch, resolve_symlinks: bool, + + exclude_path: Option, } -impl Matcher { - pub fn new() -> Matcher { - Matcher::default() +impl<'db, 'file> Stream<'db, 'file> { + pub fn new(db: &'db mut Database<'file>, now: Epoch) -> Self { + // Iterate in descending order of score. + db.dirs + .sort_unstable_by_key(|dir| OrderedFloat(dir.score(now))); + 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. + let expire_below = now.saturating_sub(90 * 24 * 60 * 60); + + Stream { + db, + idxs, + keywords: Vec::new(), + check_exists: false, + expire_below, + resolve_symlinks: false, + exclude_path: None, + } } - pub fn with_exists(mut self, resolve_symlinks: bool) -> Matcher { + pub fn with_exclude>(mut self, path: S) -> Self { + self.exclude_path = Some(path.into()); + self + } + + pub fn with_exists(mut self, resolve_symlinks: bool) -> Self { self.check_exists = true; self.resolve_symlinks = resolve_symlinks; self } - pub fn with_keywords>(mut self, keywords: &[S]) -> Matcher { + pub fn with_keywords>(mut self, keywords: &[S]) -> Self { self.keywords = keywords.iter().map(util::to_lowercase).collect(); self } - pub fn matches>(&self, path: S) -> bool { - self.matches_keywords(&path) && self.matches_exists(path) + pub fn next(&mut self) -> Option<&Dir<'file>> { + while let Some(idx) = self.idxs.next() { + let dir = &self.db.dirs[idx]; + + if !self.matches_keywords(&dir.path) { + continue; + } + + if !self.matches_exists(&dir.path) { + if dir.last_accessed < self.expire_below { + self.db.dirs.swap_remove(idx); + self.db.modified = true; + } + continue; + } + + if Some(dir.path.as_ref()) == self.exclude_path.as_deref() { + continue; + } + + let dir = &self.db.dirs[idx]; + return Some(dir); + } + + None } fn matches_exists>(&self, path: S) -> bool { @@ -77,7 +133,9 @@ impl Matcher { #[cfg(test)] mod tests { - use super::Matcher; + use super::Database; + + use std::path::PathBuf; #[test] fn query() { @@ -103,9 +161,16 @@ mod tests { (&["/foo/", "/bar"], "/foo/baz/bar", true), ]; + let mut db = Database { + dirs: Vec::new().into(), + modified: false, + data_dir: &PathBuf::new(), + }; + let now = 0; + for &(keywords, path, is_match) in CASES { - let matcher = Matcher::new().with_keywords(keywords); - assert_eq!(is_match, matcher.matches(path)) + let stream = db.stream(now).with_keywords(keywords); + assert_eq!(is_match, stream.matches_keywords(path)); } } }