use crate::constants::DEBOUNCE_SEARCH;
use crate::utils::fuzzy_match;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SearchMode {
#[default]
Contains,
Fuzzy,
Prefix,
Exact,
}
#[derive(Clone, Debug)]
pub struct SearchState {
query: String,
query_lowercased: String,
mode: SearchMode,
active: bool,
last_update: Option<Instant>,
debounce: Duration,
case_sensitive: bool,
}
impl Default for SearchState {
fn default() -> Self {
Self::new()
}
}
impl SearchState {
pub fn new() -> Self {
Self {
query: String::new(),
query_lowercased: String::new(),
mode: SearchMode::Contains,
active: false,
last_update: None,
debounce: DEBOUNCE_SEARCH,
case_sensitive: false,
}
}
pub fn mode(mut self, mode: SearchMode) -> Self {
self.mode = mode;
self
}
pub fn case_sensitive(mut self, sensitive: bool) -> Self {
self.case_sensitive = sensitive;
self
}
pub fn debounce(mut self, duration: Duration) -> Self {
self.debounce = duration;
self
}
pub fn query(&self) -> &str {
&self.query
}
pub fn has_query(&self) -> bool {
!self.query.is_empty()
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn activate(&mut self) {
self.active = true;
}
pub fn deactivate(&mut self) {
self.active = false;
}
pub fn toggle(&mut self) {
self.active = !self.active;
}
pub fn set_query(&mut self, query: impl Into<String>) {
self.query = query.into();
self.query_lowercased = self.query.to_lowercase();
self.last_update = Some(Instant::now());
}
pub fn clear(&mut self) {
self.query.clear();
self.query_lowercased.clear();
self.last_update = None;
}
pub fn push(&mut self, ch: char) {
self.query.push(ch);
for c in ch.to_lowercase() {
self.query_lowercased.push(c);
}
self.last_update = Some(Instant::now());
}
pub fn pop(&mut self) -> Option<char> {
let ch = self.query.pop();
self.query_lowercased = self.query.to_lowercase();
self.last_update = Some(Instant::now());
ch
}
pub fn is_ready(&self) -> bool {
match self.last_update {
Some(t) => t.elapsed() >= self.debounce,
None => true,
}
}
pub fn matches(&self, text: &str) -> bool {
if self.query.is_empty() {
return true;
}
let query = if self.case_sensitive {
&self.query
} else {
&self.query_lowercased
};
let text_lower = if self.case_sensitive {
None
} else {
Some(text.to_lowercase())
};
let search_text = text_lower.as_deref().unwrap_or(text);
match self.mode {
SearchMode::Contains => search_text.contains(query),
SearchMode::Prefix => search_text.starts_with(query),
SearchMode::Exact => search_text == query,
SearchMode::Fuzzy => fuzzy_match(query, search_text).is_some(),
}
}
pub fn filter<'a, T, F>(&self, items: &'a [T], to_string: F) -> Vec<&'a T>
where
F: Fn(&T) -> String,
{
if self.query.is_empty() {
return items.iter().collect();
}
items
.iter()
.filter(|item| self.matches(&to_string(item)))
.collect()
}
pub fn filter_indices<T, F>(&self, items: &[T], to_string: F) -> Vec<usize>
where
F: Fn(&T) -> String,
{
if self.query.is_empty() {
return (0..items.len()).collect();
}
items
.iter()
.enumerate()
.filter(|(_, item)| self.matches(&to_string(item)))
.map(|(i, _)| i)
.collect()
}
pub fn score(&self, text: &str) -> Option<i32> {
if self.query.is_empty() {
return Some(0);
}
let query = if self.case_sensitive {
&self.query
} else {
&self.query_lowercased
};
let text_lower = if self.case_sensitive {
None
} else {
Some(text.to_lowercase())
};
let search_text = text_lower.as_deref().unwrap_or(text);
match self.mode {
SearchMode::Fuzzy => fuzzy_match(query, search_text).map(|m| m.score),
SearchMode::Exact if search_text == query => Some(100),
SearchMode::Prefix if search_text.starts_with(query) => {
Some(50 + (query.len() as i32 * 100 / search_text.len().max(1) as i32))
}
SearchMode::Contains if search_text.contains(query) => {
let pos = search_text.find(query).unwrap_or(0);
Some(25 - pos as i32)
}
_ => None,
}
}
pub fn filter_ranked<'a, T, F>(&self, items: &'a [T], to_string: F) -> Vec<&'a T>
where
F: Fn(&T) -> String,
{
if self.query.is_empty() {
return items.iter().collect();
}
let mut scored: Vec<_> = items
.iter()
.filter_map(|item| {
let text = to_string(item);
self.score(&text).map(|score| (item, score))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.into_iter().map(|(item, _)| item).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_contains() {
let mut search = SearchState::new();
search.set_query("an");
assert!(search.matches("banana"));
assert!(search.matches("mango"));
assert!(!search.matches("apple"));
}
#[test]
fn test_search_prefix() {
let mut search = SearchState::new().mode(SearchMode::Prefix);
search.set_query("app");
assert!(search.matches("apple"));
assert!(search.matches("application"));
assert!(!search.matches("pineapple"));
}
#[test]
fn test_search_exact() {
let mut search = SearchState::new().mode(SearchMode::Exact);
search.set_query("apple");
assert!(search.matches("apple"));
assert!(!search.matches("apples"));
assert!(search.matches("Apple"));
let mut search_cs = SearchState::new()
.mode(SearchMode::Exact)
.case_sensitive(true);
search_cs.set_query("apple");
assert!(!search_cs.matches("Apple")); }
#[test]
fn test_search_case_sensitive() {
let mut search = SearchState::new().case_sensitive(true);
search.set_query("Apple");
assert!(search.matches("Apple"));
assert!(!search.matches("apple"));
}
#[test]
fn test_filter() {
let items = vec!["apple", "banana", "cherry", "date"];
let mut search = SearchState::new();
search.set_query("a");
let filtered: Vec<_> = search.filter(&items, |s| s.to_string());
assert_eq!(filtered.len(), 3); }
#[test]
fn test_filter_indices() {
let items = vec!["apple", "banana", "cherry"];
let mut search = SearchState::new();
search.set_query("a");
let indices = search.filter_indices(&items, |s| s.to_string());
assert!(indices.contains(&0)); assert!(indices.contains(&1)); assert!(!indices.contains(&2)); }
#[test]
fn test_empty_query() {
let items = vec!["a", "b", "c"];
let search = SearchState::new();
let filtered: Vec<_> = search.filter(&items, |s| s.to_string());
assert_eq!(filtered.len(), 3);
}
#[test]
fn test_push_pop() {
let mut search = SearchState::new();
search.push('a');
search.push('p');
assert_eq!(search.query(), "ap");
search.pop();
assert_eq!(search.query(), "a");
}
#[test]
fn test_active_toggle() {
let mut search = SearchState::new();
assert!(!search.is_active());
search.activate();
assert!(search.is_active());
search.toggle();
assert!(!search.is_active());
}
#[test]
fn test_fuzzy_mode() {
let mut search = SearchState::new().mode(SearchMode::Fuzzy);
search.set_query("apl");
assert!(search.matches("apple"));
}
}