mod common;
use common::make_test_analyzer;
use rmcp::serve_server;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
async fn call_tool_twice_sequential(
tool_name: &str,
params: serde_json::Value,
) -> (serde_json::Value, serde_json::Value) {
let analyzer = make_test_analyzer();
let (client, server) = tokio::io::duplex(65536);
let mut server_handle = tokio::spawn(async move {
let (server_rx, server_tx) = tokio::io::split(server);
if let Ok(service) = serve_server(analyzer, (server_rx, server_tx)).await {
let _ = service.waiting().await;
}
});
let (client_rx, mut client_tx) = tokio::io::split(client);
let mut reader = BufReader::new(client_rx).lines();
let init = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "0.1.0"}
}
})
.to_string()
+ "\n";
client_tx.write_all(init.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
reader.next_line().await.unwrap().unwrap();
let notif = serde_json::json!({
"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}
})
.to_string()
+ "\n";
client_tx.write_all(notif.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
async fn read_response(
reader: &mut tokio::io::Lines<BufReader<tokio::io::ReadHalf<tokio::io::DuplexStream>>>,
id: u64,
) -> Value {
loop {
let line = reader.next_line().await.unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
}
}
let msg1 = serde_json::json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": tool_name, "arguments": ¶ms}
})
.to_string()
+ "\n";
client_tx.write_all(msg1.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
let resp1 = read_response(&mut reader, 2).await;
let msg2 = serde_json::json!({
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": tool_name, "arguments": ¶ms}
})
.to_string()
+ "\n";
client_tx.write_all(msg2.as_bytes()).await.unwrap();
client_tx.flush().await.unwrap();
let resp2 = read_response(&mut reader, 3).await;
server_handle.abort();
(resp1, resp2)
}
fn extract_cache_tier(resp: &serde_json::Value) -> Option<String> {
resp["result"]["structuredContent"]
.get("cache_tier")
.and_then(|v| v.as_str())
.map(|s| s.to_owned())
}
fn is_success(resp: &serde_json::Value) -> bool {
!resp["result"]["isError"].as_bool().unwrap_or(false)
}
#[tokio::test]
async fn test_analyze_symbol_call_graph_cache_hit() {
let cwd = std::env::current_dir().expect("must have cwd");
let dir = tempfile::TempDir::new_in(&cwd).expect("tempdir");
std::fs::write(
dir.path().join("lib.rs"),
"fn inner() {}\n\nfn outer() {\n inner();\n}\n",
)
.expect("write fixture");
let params = serde_json::json!({
"path": dir.path().to_str().unwrap(),
"symbol": "inner",
"follow_depth": 1
});
let (resp1, resp2) = call_tool_twice_sequential("analyze_symbol", params).await;
assert!(is_success(&resp1), "first call must succeed; got: {resp1}");
assert!(is_success(&resp2), "second call must succeed; got: {resp2}");
let tier1 = extract_cache_tier(&resp1);
assert_eq!(
tier1.as_deref(),
Some("miss"),
"first call must be a cache miss; got: {tier1:?}"
);
let tier2 = extract_cache_tier(&resp2);
assert_eq!(
tier2.as_deref(),
Some("l1_memory"),
"second call on unchanged input must be an L1 cache hit; got: {tier2:?}; resp: {resp2}"
);
}
#[tokio::test]
async fn test_analyze_symbol_cache_invalidates_on_file_change() {
let cwd = std::env::current_dir().expect("must have cwd");
let dir = tempfile::TempDir::new_in(&cwd).expect("tempdir");
let fixture = dir.path().join("lib.rs");
let source = "fn inner() {}\n\nfn outer() {\n inner();\n}\n";
std::fs::write(&fixture, source).expect("write fixture");
let params = serde_json::json!({
"path": dir.path().to_str().unwrap(),
"symbol": "inner",
"follow_depth": 1
});
let (resp1, resp2) = call_tool_twice_sequential("analyze_symbol", params.clone()).await;
assert!(is_success(&resp1), "pair1 call1 must succeed");
assert!(is_success(&resp2), "pair1 call2 must succeed");
assert_eq!(
extract_cache_tier(&resp1).as_deref(),
Some("miss"),
"pair1 call1 must be a miss"
);
assert_eq!(
extract_cache_tier(&resp2).as_deref(),
Some("l1_memory"),
"pair1 call2 must be an L1 hit (cache populated)"
);
std::thread::sleep(std::time::Duration::from_secs(1));
std::fs::write(&fixture, source).expect("touch fixture");
let (resp3, resp4) = call_tool_twice_sequential("analyze_symbol", params.clone()).await;
assert!(is_success(&resp3), "pair2 call1 must succeed");
assert!(is_success(&resp4), "pair2 call2 must succeed");
let tier3 = extract_cache_tier(&resp3);
assert_eq!(
tier3.as_deref(),
Some("miss"),
"after mtime change, first call on fresh analyzer must be a miss; got: {tier3:?}"
);
let tier4 = extract_cache_tier(&resp4);
assert_eq!(
tier4.as_deref(),
Some("l1_memory"),
"after mtime change, second call on same analyzer must be L1 hit; got: {tier4:?}"
);
}