use crate::session::Session;
use nucleo::{
Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
pub fn filter(list: &[Session], query: &str, limit: usize) -> Vec<Session> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return list.iter().take(limit).cloned().collect();
}
let terms: Vec<_> = query.split_whitespace().collect();
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
let mut matcher = Matcher::default();
let mut scored: Vec<_> = list
.iter()
.filter_map(|session| {
score(session, &terms, &pattern, &mut matcher).map(|score| (score, session.clone()))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.updated.cmp(&a.1.updated)));
scored
.into_iter()
.map(|(_, session)| session)
.take(limit)
.collect()
}
fn score(
session: &Session,
terms: &[&str],
pattern: &Pattern,
matcher: &mut Matcher,
) -> Option<i64> {
let title = session.title.to_lowercase();
let core = session.core();
let paths = session.paths();
let path_query = terms.iter().any(|term| {
term.starts_with("path:") || term.contains('/') || term.contains('~') || term.contains('.')
});
let terms: Vec<_> = terms
.iter()
.map(|term| term.strip_prefix("path:").unwrap_or(term))
.collect();
let hay = if path_query {
session.haystack()
} else {
core.clone()
};
let strict = terms.iter().all(|term| term.len() <= 3);
if strict && !terms.iter().all(|term| hay.contains(term)) {
return None;
}
if !strict && !terms.iter().any(|term| hay.contains(term)) {
return None;
}
let mut buf = Vec::new();
let mut title_buf = Vec::new();
let score = if strict {
0
} else {
pattern.score(Utf32Str::new(&hay, &mut buf), matcher)? as i64
};
let title_score = pattern
.score(Utf32Str::new(&title, &mut title_buf), matcher)
.unwrap_or_default() as i64;
let exact = terms.iter().fold(0, |total, term| {
total
+ if title.contains(term) { 200 } else { 0 }
+ if path_query && paths.contains(term) {
120
} else {
0
}
+ if session
.preview
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(term)
{
40
} else {
0
}
});
Some(exact + score + title_score * 2 + session.updated / 1_000_000_000)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn searches_title_and_path() {
let list = vec![Session {
id: "ses_1".into(),
title: "API recovery".into(),
directory: PathBuf::from("/tmp/project"),
path: None,
agent: None,
model: None,
project: None,
worktree: None,
updated: 10,
created: 1,
db: PathBuf::from("db"),
preview: None,
}];
assert_eq!(filter(&list, "api", 10).len(), 1);
assert_eq!(filter(&list, "/tmp/project", 10).len(), 1);
assert_eq!(filter(&list, "missing", 10).len(), 0);
}
#[test]
fn default_search_does_not_match_path_only() {
let list = vec![item("Unrelated", "/tmp/acme")];
assert_eq!(filter(&list, "acme", 10).len(), 0);
assert_eq!(filter(&list, "/tmp/acme", 10).len(), 1);
}
#[test]
fn short_terms_are_not_loose_fuzzy_matches() {
let list = vec![
item("API planning", "/tmp/one"),
item("another big goal", "/tmp/two"),
];
let matches = filter(&list, "api", 10);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].title, "API planning");
}
fn item(title: &str, dir: &str) -> Session {
Session {
id: title.into(),
title: title.into(),
directory: PathBuf::from(dir),
path: None,
agent: None,
model: None,
project: None,
worktree: None,
updated: 10,
created: 1,
db: PathBuf::from("db"),
preview: None,
}
}
}