use std::cell::Cell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tool_result_cache::{cached_call, make_key, CacheStats, ToolCache};
#[test]
fn make_key_canonicalizes_arg_order() {
let a = make_key("t", &json!({"a": 1, "b": 2}));
let b = make_key("t", &json!({"b": 2, "a": 1}));
assert_eq!(a, b);
}
#[test]
fn make_key_differs_by_tool_name() {
let a = make_key("search", &json!({"q": "x"}));
let b = make_key("fetch", &json!({"q": "x"}));
assert_ne!(a, b);
}
#[test]
fn make_key_is_hex_64() {
let k = make_key("t", &Value::Null);
assert_eq!(k.len(), 64);
assert!(k.chars().all(|c| c.is_ascii_hexdigit()));
let bytes: Vec<u8> = (0..32)
.map(|i| u8::from_str_radix(&k[i * 2..i * 2 + 2], 16).unwrap())
.collect();
assert_eq!(bytes.len(), 32);
}
#[test]
fn make_key_handles_null_args() {
let a = make_key("t", &Value::Null);
let b = make_key("t", &Value::Null);
assert_eq!(a, b);
}
#[test]
fn make_key_canonicalizes_nested_objects() {
let a = make_key(
"t",
&json!({"outer": {"a": 1, "b": [{"x": 1, "y": 2}, {"y": 4, "x": 3}]}}),
);
let b = make_key(
"t",
&json!({"outer": {"b": [{"y": 2, "x": 1}, {"x": 3, "y": 4}], "a": 1}}),
);
assert_eq!(a, b, "nested object key reordering must be canonicalized");
}
#[test]
fn make_key_distinguishes_different_payload_shapes() {
let a = make_key("t", &json!({"a": 1}));
let b = make_key("t", &json!({"a": 2}));
assert_ne!(a, b);
let c = make_key("t", &json!([1, 2, 3]));
let d = make_key("t", &json!([3, 2, 1]));
assert_ne!(c, d, "array order is significant");
}
#[test]
fn set_then_get_round_trip() {
let mut c: ToolCache<Vec<String>> = ToolCache::new();
c.set("search", &json!({"q": "x"}), vec!["result-a".to_string()]);
let got = c.get("search", &json!({"q": "x"})).cloned();
assert_eq!(got, Some(vec!["result-a".to_string()]));
}
#[test]
fn get_miss_returns_none_and_counts() {
let mut c: ToolCache<&'static str> = ToolCache::new();
assert!(c.get("search", &json!({"q": "x"})).is_none());
assert_eq!(c.misses(), 1);
assert_eq!(c.hits(), 0);
}
#[test]
fn get_hit_counts() {
let mut c: ToolCache<&'static str> = ToolCache::new();
c.set("search", &json!({"q": "x"}), "ok");
assert_eq!(c.get("search", &json!({"q": "x"})).copied(), Some("ok"));
assert_eq!(c.hits(), 1);
assert_eq!(c.misses(), 0);
}
#[test]
fn get_or_set_computes_on_miss_and_caches_on_hit() {
let calls = Rc::new(Cell::new(0u32));
let mut c: ToolCache<u32> = ToolCache::new();
let calls_a = calls.clone();
let first = *c.get_or_set("t", &json!({"k": 1}), move || {
calls_a.set(calls_a.get() + 1);
42
});
let calls_b = calls.clone();
let second = *c.get_or_set("t", &json!({"k": 1}), move || {
calls_b.set(calls_b.get() + 1);
42
});
assert_eq!(first, 42);
assert_eq!(second, 42);
assert_eq!(calls.get(), 1, "compute closure only runs on miss");
assert_eq!(c.hits(), 1);
assert_eq!(c.misses(), 1);
}
#[test]
fn get_or_set_canonicalizes_args() {
let mut c: ToolCache<&'static str> = ToolCache::new();
c.get_or_set("t", &json!({"a": 1, "b": 2}), || "x");
let v = *c.get_or_set("t", &json!({"b": 2, "a": 1}), || {
panic!("must not recompute");
});
assert_eq!(v, "x");
assert_eq!(c.hits(), 1);
}
#[test]
fn lru_evicts_oldest_when_over_capacity() {
let mut c: ToolCache<&'static str> = ToolCache::new().with_capacity(2);
c.set("t", &json!({"k": 1}), "a");
c.set("t", &json!({"k": 2}), "b");
c.set("t", &json!({"k": 3}), "c"); assert!(c.get("t", &json!({"k": 1})).is_none());
assert_eq!(c.get("t", &json!({"k": 2})).copied(), Some("b"));
assert_eq!(c.evictions(), 1);
}
#[test]
fn access_promotes_entry_in_lru() {
let mut c: ToolCache<&'static str> = ToolCache::new().with_capacity(2);
c.set("t", &json!({"k": 1}), "a");
c.set("t", &json!({"k": 2}), "b");
let _ = c.get("t", &json!({"k": 1}));
c.set("t", &json!({"k": 3}), "c"); assert!(c.get("t", &json!({"k": 2})).is_none());
assert_eq!(c.get("t", &json!({"k": 1})).copied(), Some("a"));
}
#[test]
fn max_size_zero_disables_eviction() {
let mut c: ToolCache<i32> = ToolCache::new().with_capacity(0);
for i in 0..100 {
c.set("t", &json!({"k": i}), i);
}
assert_eq!(c.evictions(), 0);
assert_eq!(c.len(), 100);
}
fn scripted_clock(times: Vec<Instant>) -> impl Fn() -> Instant + Send + Sync + 'static {
let queue = Arc::new(Mutex::new(times));
move || {
let mut q = queue.lock().expect("clock mutex");
if q.is_empty() {
panic!("clock called more times than scripted");
}
q.remove(0)
}
}
#[test]
fn ttl_expires_entry() {
let t0 = Instant::now();
let t6 = t0 + Duration::from_secs(6);
let mut c: ToolCache<&'static str> = ToolCache::new()
.with_ttl(Duration::from_secs(5))
.with_clock(scripted_clock(vec![t0, t6]));
c.set("t", &json!({"k": 1}), "ok");
assert!(c.get("t", &json!({"k": 1})).is_none());
assert_eq!(c.expirations(), 1);
assert_eq!(c.misses(), 1);
}
#[test]
fn per_call_ttl_overrides_default() {
let t0 = Instant::now();
let t3 = t0 + Duration::from_secs(3);
let mut c: ToolCache<&'static str> = ToolCache::new()
.with_ttl(Duration::from_secs(1))
.with_clock(scripted_clock(vec![t0, t3]));
c.set_with_ttl("t", &json!({"k": 1}), "ok", Duration::from_secs(10));
assert_eq!(c.get("t", &json!({"k": 1})).copied(), Some("ok"));
}
#[test]
fn no_ttl_means_no_time_expiration() {
let t0 = Instant::now();
let t_far = t0 + Duration::from_secs(1_000_000);
let mut c: ToolCache<&'static str> = ToolCache::new()
.with_clock(scripted_clock(vec![t_far]));
c.set("t", &json!({"k": 1}), "ok");
assert_eq!(c.get("t", &json!({"k": 1})).copied(), Some("ok"));
}
#[test]
fn invalidate_drops_entry_and_reports() {
let mut c: ToolCache<&'static str> = ToolCache::new();
c.set("t", &json!({"k": 1}), "ok");
assert!(c.invalidate("t", &json!({"k": 1})));
assert!(!c.invalidate("t", &json!({"k": 1})));
assert!(c.get("t", &json!({"k": 1})).is_none());
}
#[test]
fn clear_resets_data_and_stats() {
let mut c: ToolCache<&'static str> = ToolCache::new();
c.set("t", &json!({"k": 1}), "ok");
let _ = c.get("t", &json!({"k": 1}));
c.clear();
assert_eq!(c.len(), 0);
assert_eq!(c.hits(), 0);
assert_eq!(c.misses(), 0);
assert_eq!(c.evictions(), 0);
assert_eq!(c.expirations(), 0);
}
#[test]
fn len_reports_entry_count() {
let mut c: ToolCache<u32> = ToolCache::new();
assert_eq!(c.len(), 0);
assert!(c.is_empty());
c.set("t", &json!({"k": 1}), 1);
c.set("t", &json!({"k": 2}), 2);
assert_eq!(c.len(), 2);
assert!(!c.is_empty());
c.invalidate("t", &json!({"k": 1}));
assert_eq!(c.len(), 1);
}
#[test]
fn stats_returns_a_snapshot_copy() {
let mut c: ToolCache<&'static str> = ToolCache::new();
c.set("t", &json!({"k": 1}), "ok");
let _ = c.get("t", &json!({"k": 1}));
let s1: CacheStats = c.stats();
let _ = c.get("t", &json!({"k": 1}));
let s2: CacheStats = c.stats();
assert_eq!(s1.hits, 1);
assert_eq!(s2.hits, 2);
assert_eq!(s1.hits, 1);
}
#[test]
fn cached_call_returns_owned_value_and_caches() {
let calls = Rc::new(Cell::new(0u32));
let mut c: ToolCache<String> = ToolCache::new();
let calls_a = calls.clone();
let a = cached_call(&mut c, "search", &json!({"q": "x"}), move || {
calls_a.set(calls_a.get() + 1);
"result".to_string()
});
let calls_b = calls.clone();
let b = cached_call(&mut c, "search", &json!({"q": "x"}), move || {
calls_b.set(calls_b.get() + 1);
"result".to_string()
});
assert_eq!(a, "result");
assert_eq!(b, "result");
assert_eq!(calls.get(), 1);
}
#[test]
fn builder_chaining_compiles_and_applies() {
let cache: ToolCache<u32> = ToolCache::new()
.with_capacity(128)
.with_ttl(Duration::from_secs(300));
assert_eq!(cache.len(), 0);
}
#[test]
fn replacing_an_existing_key_resets_recency() {
let mut c: ToolCache<&'static str> = ToolCache::new().with_capacity(2);
c.set("t", &json!({"k": 1}), "a");
c.set("t", &json!({"k": 2}), "b");
c.set("t", &json!({"k": 1}), "a2");
assert_eq!(c.len(), 2);
assert_eq!(c.evictions(), 0);
c.set("t", &json!({"k": 3}), "c");
assert!(c.get("t", &json!({"k": 2})).is_none());
assert_eq!(c.get("t", &json!({"k": 1})).copied(), Some("a2"));
}