tool-result-cache 0.1.0

Content-addressable LRU cache for LLM agent tool calls. Same tool, same args -> same answer, returned from memory. Optional TTL, content-addressable on (tool_name, args) with canonical-JSON keys.
Documentation
//! End-to-end behavior checks.

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};

// ---- make_key --------------------------------------------------------------

#[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()));
    // parses as 256-bit hex (32 bytes)
    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() {
    // Distinct payloads must not collide.
    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");
}

// ---- get / set / get_or_set ----------------------------------------------

#[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");
    // Different key order, same key — must hit and never invoke the closure.
    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);
}

// ---- LRU eviction ---------------------------------------------------------

#[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"); // evicts {k:1}
    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");
    // Touch {k:1} so {k:2} becomes the oldest.
    let _ = c.get("t", &json!({"k": 1}));
    c.set("t", &json!({"k": 3}), "c"); // evicts {k:2}
    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);
}

// ---- TTL ------------------------------------------------------------------

/// Build a clock that hands out the next [`Instant`] from a fixed list each
/// time it is called.
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);
    // set() reads clock once for expires_at; get() reads clock once in touch.
    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();
    // Far in the future, but no default TTL means there's nothing to compare.
    let t_far = t0 + Duration::from_secs(1_000_000);
    let mut c: ToolCache<&'static str> = ToolCache::new()
        // no with_ttl(...) call — default is None
        .with_clock(scripted_clock(vec![t_far]));
    c.set("t", &json!({"k": 1}), "ok");
    // Only one clock call is consumed (the get path's touch), proving that
    // set() did not stamp an expires_at.
    assert_eq!(c.get("t", &json!({"k": 1})).copied(), Some("ok"));
}

// ---- invalidate / clear ---------------------------------------------------

#[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);
}

// ---- stats snapshot -------------------------------------------------------

#[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);
    // s1 must not have mutated; it was a copy at the time of the call.
    assert_eq!(s1.hits, 1);
}

// ---- cached_call helper ---------------------------------------------------

#[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);
}

// ---- builder chaining -----------------------------------------------------

#[test]
fn builder_chaining_compiles_and_applies() {
    let cache: ToolCache<u32> = ToolCache::new()
        .with_capacity(128)
        .with_ttl(Duration::from_secs(300));
    // We can't directly read max_size/ttl, but we can prove capacity is set
    // by checking that 0 means "unlimited" comparatively (separate test
    // already covered len() vs evictions()). For this one we just sanity
    // check len() == 0 at construction.
    assert_eq!(cache.len(), 0);
}

// ---- expiration cleans up before set --------------------------------------

#[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");
    // Re-set {k:1} — should move it to the tail and evict nothing yet.
    c.set("t", &json!({"k": 1}), "a2");
    assert_eq!(c.len(), 2);
    assert_eq!(c.evictions(), 0);
    // Now adding {k:3} should evict {k:2}, not {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("a2"));
}