use crate::models::{Entry, EntryType};
pub struct EntryFilters<'a> {
pub query: Option<&'a str>,
pub project: Option<&'a str>,
pub entry_type: Option<&'a EntryType>,
pub session_id: Option<&'a str>,
pub success: Option<bool>,
pub since: Option<&'a str>,
pub offset: Option<usize>,
pub limit: Option<usize>,
}
impl<'a> EntryFilters<'a> {
fn matches(&self, entry: &Entry) -> bool {
self.project.is_none_or(|project| entry.project == project)
&& self
.entry_type
.is_none_or(|entry_type| entry.entry_type == *entry_type)
&& self
.session_id
.is_none_or(|session_id| entry.session_id.as_deref() == Some(session_id))
&& self
.success
.is_none_or(|success| entry.success == Some(success))
&& self
.since
.is_none_or(|since| entry.timestamp.as_str() >= since)
}
}
pub fn process_entries<'a>(entries: &'a [Entry], filters: &EntryFilters<'_>) -> Vec<&'a Entry> {
match filters.query.and_then(parse_terms) {
Some(terms) => process_search_results(entries, filters, &terms),
None => process_recent_entries(entries, filters),
}
}
pub fn find_last_entry<'a>(entries: &'a [Entry], filters: &EntryFilters<'_>) -> Option<&'a Entry> {
entries.iter().rev().find(|entry| filters.matches(entry))
}
pub fn fuzzy_match(text: &str, query: &str) -> i32 {
if query.is_empty() {
return 0;
}
if equals_ignore_ascii_case(text, query) {
return 3;
}
if contains_ignore_ascii_case(text, query) {
return 2;
}
if chars_in_order_ignore_ascii_case(text, query) {
return 1;
}
0
}
fn process_recent_entries<'a>(entries: &'a [Entry], filters: &EntryFilters<'_>) -> Vec<&'a Entry> {
let offset = filters.offset.unwrap_or(0);
let limit = filters.limit.unwrap_or(usize::MAX);
let capacity = entries.len().saturating_sub(offset).min(limit);
let mut results = Vec::with_capacity(capacity);
for entry in entries
.iter()
.rev()
.filter(|entry| filters.matches(entry))
.skip(offset)
{
if results.len() == limit {
break;
}
results.push(entry);
}
results
}
fn process_search_results<'a>(
entries: &'a [Entry],
filters: &EntryFilters<'_>,
terms: &[String],
) -> Vec<&'a Entry> {
let mut scored_entries: Vec<(&Entry, i32)> =
Vec::with_capacity(entries.len().min(filters.limit.unwrap_or(entries.len())));
for entry in entries.iter().filter(|entry| filters.matches(entry)) {
let mut score = 0;
for term in terms {
score += fuzzy_match(&entry.content, term);
score += fuzzy_match(&entry.project, term);
for tag in &entry.tags {
score += fuzzy_match(tag, term);
}
score += fuzzy_match(entry.entry_type.as_str(), term);
}
if score > 0 {
scored_entries.push((entry, score));
}
}
scored_entries.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.0.timestamp.cmp(&a.0.timestamp))
});
let offset = filters.offset.unwrap_or(0);
let limit = filters.limit.unwrap_or(usize::MAX);
scored_entries
.into_iter()
.skip(offset)
.take(limit)
.map(|(entry, _score)| entry)
.collect()
}
pub fn parse_terms(query: &str) -> Option<Vec<String>> {
let terms: Vec<String> = query
.to_lowercase()
.split_whitespace()
.map(str::to_string)
.collect();
(!terms.is_empty()).then_some(terms)
}
fn equals_ignore_ascii_case(text: &str, query: &str) -> bool {
text.len() == query.len() && text.eq_ignore_ascii_case(query)
}
fn contains_ignore_ascii_case(text: &str, query: &str) -> bool {
let query_len = query.len();
if query_len == 0 {
return true;
}
let text_bytes = text.as_bytes();
let query_bytes = query.as_bytes();
if query_len > text_bytes.len() {
return false;
}
text_bytes.windows(query_len).any(|window| {
window
.iter()
.zip(query_bytes.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right))
})
}
fn chars_in_order_ignore_ascii_case(text: &str, query: &str) -> bool {
let mut query_bytes = query.as_bytes().iter();
let mut next = query_bytes.next();
for byte in text.as_bytes() {
if next.is_some_and(|target| byte.eq_ignore_ascii_case(target)) {
next = query_bytes.next();
if next.is_none() {
return true;
}
}
}
false
}