From d6d225cf6337f27814efd47938e91c5bcf36c993 Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Sun, 7 Jun 2026 01:27:17 +0530 Subject: [PATCH] fix: make it configurable and add tests --- src/cmd/query.rs | 4 ++++ src/config.rs | 4 ++++ src/db/dir.rs | 11 ++++++++- src/db/mod.rs | 60 +++++++++++++++++++++++++++++++++++++----------- src/db/stream.rs | 16 +++++++++++-- 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 6539c2e..17690b7 100644 --- a/src/cmd/query.rs +++ b/src/cmd/query.rs @@ -86,6 +86,10 @@ impl Query { options = options.with_exists(true).with_resolve_symlinks(resolve_symlinks); } + if config::prefer_exact_match() { + options = options.with_prefer_exact_match(true); + } + let stream = Stream::new(db, options); Ok(stream) } diff --git a/src/config.rs b/src/config.rs index 0aeda5c..7f32458 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,3 +60,7 @@ pub fn maxage() -> Result { pub fn resolve_symlinks() -> bool { env::var_os("_ZO_RESOLVE_SYMLINKS").is_some_and(|var| var == "1") } + +pub fn prefer_exact_match() -> bool { + env::var_os("_ZO_PREFER_EXACT_MATCH").is_some_and(|var| var == "1") +} diff --git a/src/db/dir.rs b/src/db/dir.rs index 5d6d62c..91e1144 100644 --- a/src/db/dir.rs +++ b/src/db/dir.rs @@ -1,9 +1,10 @@ use std::borrow::Cow; use std::fmt::{self, Display, Formatter}; +use std::path::Path; use serde::{Deserialize, Serialize}; -use crate::util::{DAY, HOUR, WEEK}; +use crate::util::{DAY, HOUR, WEEK, to_lowercase}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Dir<'a> { @@ -31,6 +32,14 @@ impl Dir<'_> { self.rank * 0.25 } } + + pub fn is_exact_match(&self, keyword: &str) -> bool { + let last = Path::new(self.path.as_ref()) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + to_lowercase(last) == keyword + } } pub struct DirDisplay<'a> { diff --git a/src/db/mod.rs b/src/db/mod.rs index 526368a..b571653 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -179,25 +179,21 @@ impl Database { self.with_dirty_mut(|dirty| *dirty = true); } - pub fn sort_by_keywords(&mut self, keywords: &[String]) { - if keywords.is_empty() { - return; - } - + pub fn sort_by_score_with_keywords(&mut self, keywords: &[String], now: Epoch) { self.with_dirs_mut(|dirs| { - dirs.sort_by_key(|dir| Self::has_exact_match(&dir.path, keywords)); + dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| { + let key = |dir: &Dir| { + let exact = keywords.last().is_some_and(|kw| dir.is_exact_match(kw)); + (exact, dir.score(now)) + }; + let (exact1, score1) = key(dir1); + let (exact2, score2) = key(dir2); + exact1.cmp(&exact2).then(score1.total_cmp(&score2)) + }) }); self.with_dirty_mut(|dirty| *dirty = true); } - fn has_exact_match(path: &str, keywords: &[String]) -> bool { - keywords.last().is_some_and(|keyword| { - let path_lower = util::to_lowercase(path); - let last_component = path_lower.rsplit(std::path::is_separator).next().unwrap_or(""); - last_component == keyword - }) - } - pub fn dirty(&self) -> bool { *self.borrow_dirty() } @@ -278,6 +274,42 @@ mod tests { } } + #[test] + fn sort_by_score_with_keywords_exact_match_wins() { + let now = util::current_time().unwrap(); + let mut db = Database::new(PathBuf::new(), Vec::new(), |_| Vec::new(), false); + + db.add_unchecked("/foo/baz", 100.0, now); + db.add_unchecked("/foo/bar", 1.0, now); + + let keywords = vec!["bar".to_string()]; + + let baz = db.dirs()[0].score(now); + let bar = db.dirs()[1].score(now); + assert!(baz > bar); + + db.sort_by_score_with_keywords(&keywords, now); + + let dirs = db.dirs(); + assert_eq!(dirs.last().unwrap().path.as_ref(), "/foo/bar"); + assert!((dirs.last().unwrap().rank - 1.0).abs() < 0.01); + } + + #[test] + fn sort_by_score_with_keywords_highest_score_wins_without_exact_match() { + let now = util::current_time().unwrap(); + let mut db = Database::new(PathBuf::new(), Vec::new(), |_| Vec::new(), false); + + db.add_unchecked("/foo/baz", 100.0, now); + db.add_unchecked("/foo/bar", 1.0, now); + + let keywords = vec!["foo".to_string()]; + db.sort_by_score_with_keywords(&keywords, now); + + let dirs = db.dirs(); + assert_eq!(dirs.last().unwrap().path.as_ref(), "/foo/baz"); + } + #[test] fn remove() { let data_dir = tempfile::tempdir().unwrap(); diff --git a/src/db/stream.rs b/src/db/stream.rs index 677ecad..f42c579 100644 --- a/src/db/stream.rs +++ b/src/db/stream.rs @@ -19,8 +19,11 @@ impl<'a> Stream<'a> { let now = options.now; let keywords = &options.keywords; - db.sort_by_score(now); - db.sort_by_keywords(keywords); + if options.prefer_exact_match { + db.sort_by_score_with_keywords(keywords, now); + } else { + db.sort_by_score(now); + } let idxs = (0..db.dirs().len()).rev(); Stream { db, idxs, options } } @@ -133,6 +136,9 @@ pub struct StreamOptions { /// Only return directories within this parent directory /// Does not check if the path exists base_dir: Option, + + /// Whether to prefer exact matches over the match with higher score. + prefer_exact_match: bool, } impl StreamOptions { @@ -145,6 +151,7 @@ impl StreamOptions { resolve_symlinks: false, ttl: now.saturating_sub(3 * MONTH), base_dir: None, + prefer_exact_match: false, } } @@ -176,6 +183,11 @@ impl StreamOptions { self.base_dir = base_dir; self } + + pub fn with_prefer_exact_match(mut self, prefer_exact_match: bool) -> Self { + self.prefer_exact_match = prefer_exact_match; + self + } } #[cfg(test)]