use super::types::SearchItem;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;
const TTL: Duration = Duration::from_secs(5 * 60);
pub const PAGE_SIZE: usize = 10;
pub struct QueryState {
pub results: Vec<SearchItem>,
pub next_offset: usize,
pub created_at: Instant,
}
impl QueryState {
fn new() -> Self {
Self {
results: vec![],
next_offset: 0,
created_at: Instant::now(),
}
}
pub fn reset(&mut self, results: Vec<SearchItem>) {
self.results = results;
self.next_offset = 0;
self.created_at = Instant::now();
}
pub fn is_expired(&self) -> bool {
self.created_at.elapsed() >= TTL
}
pub fn is_empty(&self) -> bool {
self.results.is_empty() && self.next_offset == 0
}
}
pub struct QueryLock {
pub state: Mutex<QueryState>,
}
impl QueryLock {
pub fn new() -> Self {
Self {
state: Mutex::new(QueryState::new()),
}
}
#[expect(
clippy::unwrap_used,
reason = "Mutex poisoning indicates a prior panic. Fail fast to avoid \
inconsistent pagination state."
)]
pub fn lock_state(&self) -> std::sync::MutexGuard<'_, QueryState> {
self.state.lock().unwrap()
}
}
impl Default for QueryLock {
fn default() -> Self {
Self::new()
}
}
#[derive(Default)]
pub struct PaginationCache {
map: Mutex<HashMap<String, Arc<QueryLock>>>,
}
impl PaginationCache {
pub fn new() -> Self {
Self::default()
}
#[expect(
clippy::unwrap_used,
reason = "Mutex poisoning indicates a prior panic. Fail fast for pagination cache."
)]
fn lock_map(&self) -> std::sync::MutexGuard<'_, HashMap<String, Arc<QueryLock>>> {
self.map.lock().unwrap()
}
pub fn get_or_create(&self, key: &str) -> Arc<QueryLock> {
let mut m = self.lock_map();
Arc::clone(
m.entry(key.to_string())
.or_insert_with(|| Arc::new(QueryLock::new())),
)
}
pub fn remove_if_same(&self, key: &str, candidate: &Arc<QueryLock>) {
let mut m = self.lock_map();
if let Some(existing) = m.get(key)
&& Arc::ptr_eq(existing, candidate)
{
m.remove(key);
}
}
pub fn sweep_expired(&self) {
let snapshot: Vec<_> = {
let m = self.lock_map();
m.iter().map(|(k, v)| (k.clone(), Arc::clone(v))).collect()
};
for (k, lk) in snapshot {
let expired = lk.lock_state().is_expired();
if expired {
self.remove_if_same(&k, &lk);
}
}
}
}
pub fn make_key(dir: &str, query: &str) -> String {
let normalized_dir = dir.trim_end_matches('/');
format!("dir={}|q={}", normalized_dir, query.to_ascii_lowercase())
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn make_key_consistent() {
let k1 = make_key("/repo", "build");
let k2 = make_key("/repo", "BUILD");
assert_eq!(k1, k2); }
#[test]
fn make_key_different_dirs() {
let k1 = make_key("/repo1", "build");
let k2 = make_key("/repo2", "build");
assert_ne!(k1, k2);
}
#[test]
fn make_key_normalizes_trailing_slash() {
let k1 = make_key("/repo", "build");
let k2 = make_key("/repo/", "build");
assert_eq!(k1, k2); }
#[test]
fn cache_get_or_create() {
let cache = PaginationCache::new();
let lock1 = cache.get_or_create("key1");
let lock2 = cache.get_or_create("key1");
assert!(Arc::ptr_eq(&lock1, &lock2));
let lock3 = cache.get_or_create("key2");
assert!(!Arc::ptr_eq(&lock1, &lock3));
}
#[test]
fn cache_remove_if_same() {
let cache = PaginationCache::new();
let lock1 = cache.get_or_create("key1");
cache.remove_if_same("key1", &lock1);
let lock2 = cache.get_or_create("key1");
assert!(!Arc::ptr_eq(&lock1, &lock2));
}
#[test]
fn query_state_lifecycle() {
let mut state = QueryState::new();
assert!(state.is_empty());
assert!(!state.is_expired());
state.reset(vec![SearchItem {
recipe: "test".into(),
dir: "/repo".into(),
doc: None,
params: vec![],
}]);
assert!(!state.is_empty());
assert_eq!(state.next_offset, 0);
}
#[test]
fn sweep_removes_expired() {
let cache = PaginationCache::new();
let lock = cache.get_or_create("key1");
{
let mut st = lock.state.lock().unwrap();
st.created_at = Instant::now()
.checked_sub(Duration::from_secs(6 * 60))
.unwrap();
}
cache.sweep_expired();
let lock2 = cache.get_or_create("key1");
assert!(!Arc::ptr_eq(&lock, &lock2));
}
#[test]
fn sweep_keeps_fresh() {
let cache = PaginationCache::new();
let lock1 = cache.get_or_create("key1");
cache.sweep_expired();
let lock2 = cache.get_or_create("key1");
assert!(Arc::ptr_eq(&lock1, &lock2));
}
}