From 84080ff59f5cf93d63b064eab577ab1437466c8a Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Tue, 18 Nov 2025 10:44:08 +0530 Subject: [PATCH 1/7] fix: prefer exact keyword match over score match --- src/db/mod.rs | 19 +++++++++++++++++++ src/db/stream.rs | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index d459f39..045f3ba 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -179,6 +179,25 @@ impl Database { self.with_dirty_mut(|dirty| *dirty = true); } + pub fn sort_by_keywords(&mut self, keywords: &[String]) { + if keywords.is_empty() { + return; + } + + self.with_dirs_mut(|dirs| { + dirs.sort_by_key(|dir| Self::has_exact_match(&dir.path, keywords)); + }); + self.with_dirty_mut(|dirty| *dirty = true); + } + + fn has_exact_match(path: &str, keywords: &[String]) -> bool { + keywords.last().map_or(false, |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() } diff --git a/src/db/stream.rs b/src/db/stream.rs index 4b06193..677ecad 100644 --- a/src/db/stream.rs +++ b/src/db/stream.rs @@ -16,7 +16,11 @@ pub struct Stream<'a> { impl<'a> Stream<'a> { pub fn new(db: &'a mut Database, options: StreamOptions) -> Self { - db.sort_by_score(options.now); + let now = options.now; + let keywords = &options.keywords; + + db.sort_by_score(now); + db.sort_by_keywords(keywords); let idxs = (0..db.dirs().len()).rev(); Stream { db, idxs, options } } From bce46a2295ca2d3c508e2c05e8b7e5e1142ba2b2 Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Tue, 18 Nov 2025 11:18:06 +0530 Subject: [PATCH 2/7] chore: clippy --- src/db/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 045f3ba..526368a 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -191,7 +191,7 @@ impl Database { } fn has_exact_match(path: &str, keywords: &[String]) -> bool { - keywords.last().map_or(false, |keyword| { + 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 From d6d225cf6337f27814efd47938e91c5bcf36c993 Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Sun, 7 Jun 2026 01:27:17 +0530 Subject: [PATCH 3/7] 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)] From cecf758c3f6058e4b990279d8b4583ed467890bc Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Sun, 7 Jun 2026 01:27:51 +0530 Subject: [PATCH 4/7] fmt --- src/db/dir.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/db/dir.rs b/src/db/dir.rs index 91e1144..d3a1ac2 100644 --- a/src/db/dir.rs +++ b/src/db/dir.rs @@ -34,10 +34,7 @@ impl Dir<'_> { } 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(""); + let last = Path::new(self.path.as_ref()).file_name().and_then(|s| s.to_str()).unwrap_or(""); to_lowercase(last) == keyword } } From 3c94f8735a306aa6ee61d5c3fdaa51e9f46077c5 Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Sun, 7 Jun 2026 01:32:30 +0530 Subject: [PATCH 5/7] fix: simplify score promoting --- src/db/mod.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index b571653..78315c0 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -182,13 +182,14 @@ impl Database { pub fn sort_by_score_with_keywords(&mut self, keywords: &[String], now: Epoch) { self.with_dirs_mut(|dirs| { 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 score = |dir: &Dir| { + if keywords.last().is_some_and(|kw| dir.is_exact_match(kw)) { + f64::MAX + } else { + dir.score(now) + } }; - let (exact1, score1) = key(dir1); - let (exact2, score2) = key(dir2); - exact1.cmp(&exact2).then(score1.total_cmp(&score2)) + score(dir1).total_cmp(&score(dir2)) }) }); self.with_dirty_mut(|dirty| *dirty = true); From d502338c6c614298e762f81871255bba38a346a1 Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Sun, 7 Jun 2026 01:38:00 +0530 Subject: [PATCH 6/7] fix: prefer db score when both are exact match --- src/db/mod.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 78315c0..5d38254 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -190,6 +190,7 @@ impl Database { } }; score(dir1).total_cmp(&score(dir2)) + .then(dir1.score(now).total_cmp(&dir2.score(now))) }) }); self.with_dirty_mut(|dirty| *dirty = true); @@ -278,7 +279,7 @@ 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); + let mut db = Database::new(PathBuf::default(), Vec::default(), |_| Vec::new(), false); db.add_unchecked("/foo/baz", 100.0, now); db.add_unchecked("/foo/bar", 1.0, now); @@ -299,7 +300,7 @@ mod tests { #[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); + let mut db = Database::new(PathBuf::default(), Vec::default(), |_| Vec::new(), false); db.add_unchecked("/foo/baz", 100.0, now); db.add_unchecked("/foo/bar", 1.0, now); @@ -311,6 +312,20 @@ mod tests { assert_eq!(dirs.last().unwrap().path.as_ref(), "/foo/baz"); } + #[test] + fn sort_by_score_with_keywords_both_exact_match_frecency_breaks_tie() { + let now = util::current_time().unwrap(); + let mut db = Database::new(PathBuf::default(), Vec::default(), |_| Vec::new(), false); + + db.add_unchecked("/foo/bar", 1.0, now); + db.add_unchecked("/baz/bar", 100.0, now); + + let keywords = vec!["bar".to_string()]; + db.sort_by_score_with_keywords(&keywords, now); + + assert_eq!(db.dirs().last().unwrap().path.as_ref(), "/baz/bar"); + } + #[test] fn remove() { let data_dir = tempfile::tempdir().unwrap(); From e29d1eb11d3c33f3bbb85af1ad7e479be6d269c0 Mon Sep 17 00:00:00 2001 From: dracarys18 Date: Sun, 7 Jun 2026 01:42:45 +0530 Subject: [PATCH 7/7] fix: fmt --- src/db/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 4d85300..91be1bd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -190,7 +190,8 @@ impl Database { dir.score(now) } }; - score(dir1).total_cmp(&score(dir2)) + score(dir1) + .total_cmp(&score(dir2)) .then(dir1.score(now).total_cmp(&dir2.score(now))) }) });