use std::collections::VecDeque;
const DEFAULT_MAX_SIZE: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recall<'a> {
Entry(&'a str),
Draft(&'a str),
}
#[derive(Debug, Clone)]
pub struct Store {
entries: VecDeque<String>,
max_size: usize,
cursor: Option<usize>,
draft: String,
}
impl Default for Store {
fn default() -> Self {
Self::new()
}
}
impl Store {
pub fn new() -> Self {
Self::with_max_size(DEFAULT_MAX_SIZE)
}
pub fn with_max_size(max_size: usize) -> Self {
Self {
entries: VecDeque::new(),
max_size,
cursor: None,
draft: String::new(),
}
}
pub fn load<I, S>(&mut self, entries: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.entries.clear();
self.cursor = None;
self.draft.clear();
for entry in entries {
self.push_raw(entry.into());
}
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &str> {
self.entries.iter().map(String::as_str)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn at(&self, idx: usize) -> Option<&str> {
self.entries.get(idx).map(String::as_str)
}
pub fn push(&mut self, entry: impl Into<String>) {
let entry = entry.into();
self.push_raw(entry);
self.cursor = None;
self.draft.clear();
}
fn push_raw(&mut self, entry: String) {
if entry.is_empty() {
return;
}
if self.entries.back().is_some_and(|last| last == &entry) {
return;
}
self.entries.push_back(entry);
if self.max_size > 0 {
while self.entries.len() > self.max_size {
self.entries.pop_front();
}
}
}
pub fn prev(&mut self, current: &str) -> Option<Recall<'_>> {
if self.entries.is_empty() {
return None;
}
match self.cursor {
None => {
self.draft = current.to_string();
let idx = self.entries.len() - 1;
self.cursor = Some(idx);
Some(Recall::Entry(&self.entries[idx]))
}
Some(0) => None,
Some(idx) => {
let new_idx = idx - 1;
self.cursor = Some(new_idx);
Some(Recall::Entry(&self.entries[new_idx]))
}
}
}
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Option<Recall<'_>> {
let idx = self.cursor?;
let last = self.entries.len().saturating_sub(1);
if idx < last {
let new_idx = idx + 1;
self.cursor = Some(new_idx);
Some(Recall::Entry(&self.entries[new_idx]))
} else {
self.cursor = None;
Some(Recall::Draft(&self.draft))
}
}
pub fn cancel_recall(&mut self) -> String {
self.cursor = None;
std::mem::take(&mut self.draft)
}
pub fn is_browsing(&self) -> bool {
self.cursor.is_some()
}
pub fn search(&self, query: &str) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
let needle = query.to_lowercase();
let mut hits = Vec::new();
for (i, entry) in self.entries.iter().enumerate().rev() {
if entry.to_lowercase().contains(&needle) {
hits.push(i);
}
}
hits
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_collapses_consecutive_duplicates() {
let mut s = Store::new();
s.push("a");
s.push("a");
s.push("b");
s.push("a");
let xs: Vec<&str> = s.entries().collect();
assert_eq!(xs, vec!["a", "b", "a"]);
}
#[test]
fn push_ignores_empty() {
let mut s = Store::new();
s.push("");
assert!(s.is_empty());
}
#[test]
fn push_respects_max_size() {
let mut s = Store::with_max_size(2);
s.push("a");
s.push("b");
s.push("c");
let xs: Vec<&str> = s.entries().collect();
assert_eq!(xs, vec!["b", "c"]);
}
#[test]
fn max_size_zero_means_unbounded() {
let mut s = Store::with_max_size(0);
for i in 0..50 {
s.push(format!("e{i}"));
}
assert_eq!(s.len(), 50);
}
#[test]
fn prev_stashes_draft_and_walks_back() {
let mut s = Store::new();
s.push("one");
s.push("two");
s.push("three");
assert_eq!(s.prev("draft").unwrap(), Recall::Entry("three"));
assert_eq!(s.prev("ignored").unwrap(), Recall::Entry("two"));
assert_eq!(s.prev("ignored").unwrap(), Recall::Entry("one"));
assert!(s.prev("ignored").is_none(), "stops at oldest");
}
#[test]
fn next_restores_draft_when_past_newest() {
let mut s = Store::new();
s.push("a");
s.push("b");
let _ = s.prev("my draft");
let _ = s.prev("ignored");
assert_eq!(s.next().unwrap(), Recall::Entry("b"));
assert_eq!(s.next().unwrap(), Recall::Draft("my draft"));
assert!(s.next().is_none(), "no-op when not browsing");
assert!(!s.is_browsing());
}
#[test]
fn prev_on_empty_returns_none() {
let mut s = Store::new();
assert!(s.prev("anything").is_none());
assert!(s.next().is_none());
}
#[test]
fn push_during_recall_resets_state() {
let mut s = Store::new();
s.push("a");
let _ = s.prev("draft");
assert!(s.is_browsing());
s.push("b");
assert!(!s.is_browsing());
}
#[test]
fn cancel_recall_returns_draft() {
let mut s = Store::new();
s.push("a");
let _ = s.prev("typed this");
assert_eq!(s.cancel_recall(), "typed this");
assert!(!s.is_browsing());
}
#[test]
fn search_returns_indices_newest_first() {
let mut s = Store::new();
s.push("alpha");
s.push("beta");
s.push("alpha gamma");
s.push("delta");
let hits = s.search("alpha");
assert_eq!(hits, vec![2, 0]);
assert_eq!(s.at(hits[0]), Some("alpha gamma"));
assert_eq!(s.at(hits[1]), Some("alpha"));
}
#[test]
fn search_is_case_insensitive_and_substring() {
let mut s = Store::new();
s.push("Hello World");
let hits = s.search("WORLD");
assert_eq!(hits, vec![0]);
}
#[test]
fn search_empty_query_returns_nothing() {
let mut s = Store::new();
s.push("anything");
assert!(s.search("").is_empty());
}
#[test]
fn load_replaces_entries_and_resets_state() {
let mut s = Store::new();
s.push("old");
let _ = s.prev("draft");
s.load(["a", "b", "c"]);
let xs: Vec<&str> = s.entries().collect();
assert_eq!(xs, vec!["a", "b", "c"]);
assert!(!s.is_browsing());
}
}