use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use crate::tui::history::OutputRow;
const DEFAULT_CAPACITY: usize = 256;
#[derive(Debug, Clone)]
struct CacheEntry {
rows: Vec<OutputRow>,
selected_by_limit: HashMap<usize, Vec<usize>>,
}
impl CacheEntry {
fn new(rows: Vec<OutputRow>) -> Self {
Self {
rows,
selected_by_limit: HashMap::new(),
}
}
}
#[derive(Debug)]
struct OutputRowsCacheInner {
capacity: usize,
by_key: HashMap<RowsKey, CacheEntry>,
insertion_order: VecDeque<RowsKey>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct RowsKey {
content_hash: u64,
width: u16,
}
impl OutputRowsCacheInner {
fn new() -> Self {
Self::with_capacity(DEFAULT_CAPACITY)
}
fn with_capacity(capacity: usize) -> Self {
let cap = capacity.max(1);
Self {
capacity: cap,
by_key: HashMap::with_capacity(cap),
insertion_order: VecDeque::with_capacity(cap),
}
}
fn get_or_compute_rows<F>(
&mut self,
content_hash: u64,
width: u16,
compute: F,
) -> Vec<OutputRow>
where
F: FnOnce() -> Vec<OutputRow>,
{
let key = RowsKey {
content_hash,
width,
};
if let Some(entry) = self.by_key.get(&key) {
return entry.rows.clone();
}
let rows = compute();
let entry = CacheEntry::new(rows.clone());
if self.by_key.len() >= self.capacity
&& let Some(oldest) = self.insertion_order.pop_front()
{
self.by_key.remove(&oldest);
}
self.by_key.insert(key, entry);
self.insertion_order.push_back(key);
rows
}
fn get_or_compute_indices<F>(
&mut self,
content_hash: u64,
width: u16,
line_limit: usize,
compute: F,
) -> Vec<usize>
where
F: FnOnce() -> Vec<usize>,
{
let key = RowsKey {
content_hash,
width,
};
if let Some(entry) = self.by_key.get_mut(&key)
&& let Some(indices) = entry.selected_by_limit.get(&line_limit)
{
return indices.clone();
}
let indices = compute();
if let Some(entry) = self.by_key.get_mut(&key) {
entry.selected_by_limit.insert(line_limit, indices.clone());
}
indices
}
}
thread_local! {
static GLOBAL_CACHE: RefCell<OutputRowsCacheInner> =
RefCell::new(OutputRowsCacheInner::new());
}
#[cfg(test)]
pub fn reset_for_tests() {
GLOBAL_CACHE.with(|c| *c.borrow_mut() = OutputRowsCacheInner::new());
}
pub fn get_or_compute_rows<F>(output: &str, width: u16, compute: F) -> Vec<OutputRow>
where
F: FnOnce() -> Vec<OutputRow>,
{
let content_hash = hash_str(output);
GLOBAL_CACHE.with(|c| {
c.borrow_mut()
.get_or_compute_rows(content_hash, width, compute)
})
}
pub fn get_or_compute_indices<F>(
content_hash: u64,
width: u16,
line_limit: usize,
compute: F,
) -> Vec<usize>
where
F: FnOnce() -> Vec<usize>,
{
GLOBAL_CACHE.with(|c| {
c.borrow_mut()
.get_or_compute_indices(content_hash, width, line_limit, compute)
})
}
pub fn hash_str(s: &str) -> u64 {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET_BASIS;
for byte in s.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash ^= s.len() as u64;
hash.wrapping_mul(FNV_PRIME)
}
#[cfg(test)]
mod tests {
use super::*;
fn row(text: &str) -> OutputRow {
OutputRow {
text: text.to_string(),
intact: false,
}
}
#[test]
fn cache_hit_returns_cached_rows() {
reset_for_tests();
let calls = std::cell::Cell::new(0u32);
let compute = || {
calls.set(calls.get() + 1);
vec![row("hello"), row("world")]
};
let a = get_or_compute_rows("payload", 80, compute);
let b = get_or_compute_rows("payload", 80, || {
calls.set(calls.get() + 1);
vec![row("hello"), row("world")]
});
assert_eq!(calls.get(), 1, "second call should hit the cache");
assert_eq!(a, b);
}
#[test]
fn different_width_invalidates_rows() {
reset_for_tests();
let calls = std::cell::Cell::new(0u32);
let make = || {
calls.set(calls.get() + 1);
vec![row("hello")]
};
let _ = get_or_compute_rows("payload", 80, make);
let _ = get_or_compute_rows("payload", 120, make);
assert_eq!(calls.get(), 2, "different width must miss the cache");
}
#[test]
fn different_output_invalidates_rows() {
reset_for_tests();
let calls = std::cell::Cell::new(0u32);
let make = || {
calls.set(calls.get() + 1);
vec![row("x")]
};
let _ = get_or_compute_rows("payload-a", 80, make);
let _ = get_or_compute_rows("payload-b", 80, make);
assert_eq!(calls.get(), 2);
}
#[test]
fn indices_cached_per_line_limit() {
reset_for_tests();
let rows = get_or_compute_rows("payload", 80, || {
vec![row("a"), row("b"), row("c"), row("d"), row("e")]
});
assert_eq!(rows.len(), 5);
let content_hash = hash_str("payload");
let mut calls = 0;
let pick_two_a = get_or_compute_indices(content_hash, 80, 2, || {
calls += 1;
vec![0usize, 4]
});
let pick_two_b = get_or_compute_indices(content_hash, 80, 2, || {
calls += 1;
vec![0usize, 4]
});
assert_eq!(calls, 1, "second lookup with same limit hits the cache");
assert_eq!(pick_two_a, pick_two_b);
assert_eq!(pick_two_a, vec![0, 4]);
let _ = get_or_compute_indices(content_hash, 80, 3, || {
calls += 1;
vec![0usize, 1, 4]
});
assert_eq!(calls, 2);
}
#[test]
fn capacity_evicts_oldest() {
let mut cache = OutputRowsCacheInner::with_capacity(2);
let _ = cache.get_or_compute_rows(1, 80, || vec![row("a")]);
let _ = cache.get_or_compute_rows(2, 80, || vec![row("b")]);
let _ = cache.get_or_compute_rows(3, 80, || vec![row("c")]);
let mut compute_calls = 0;
let _ = cache.get_or_compute_rows(1, 80, || {
compute_calls += 1;
vec![row("a")]
});
assert_eq!(compute_calls, 1, "evicted entry must miss");
}
#[test]
fn hash_str_stable_for_identical_input() {
assert_eq!(hash_str("hello"), hash_str("hello"));
assert_ne!(hash_str("hello"), hash_str("world"));
}
#[test]
fn hash_str_differs_on_length_suffix() {
assert_ne!(hash_str("hello"), hash_str("hello\n"));
}
#[test]
fn hash_str_handles_empty() {
assert_eq!(hash_str(""), hash_str(""));
}
}