use std::env;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex as TokioMutex;
use aptu_coder_core::analyze::FileAnalysisOutput;
use aptu_coder_core::graph::{GraphDiskStore, StructuralGraph};
use aptu_coder_core::pagination::{CursorData, PaginationMode, encode_cursor};
use aptu_coder_core::types::{CallInfo, FunctionInfo, SemanticAnalysis};
use serde_json::json;
use serial_test::serial;
fn make_analyzer() -> aptu_coder::CodeAnalyzer {
let peer = Arc::new(TokioMutex::new(None));
let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
aptu_coder::CodeAnalyzer::new(peer, aptu_coder::MetricsSender(metrics_tx))
}
async fn send_request(method: &str, params: serde_json::Value) -> serde_json::Value {
let analyzer = make_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) = rmcp::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 = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": rmcp::model::ProtocolVersion::LATEST.as_str(),
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "0.1.0"}
}
})
.to_string()
+ "\n";
client_tx
.write_all(init.as_bytes())
.await
.expect("failed to write initialize request");
client_tx
.flush()
.await
.expect("failed to flush initialize request");
let _resp = reader
.next_line()
.await
.expect("IO error reading initialize response")
.expect("server closed before sending initialize response");
let notif = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
})
.to_string()
+ "\n";
client_tx
.write_all(notif.as_bytes())
.await
.expect("failed to write initialized notification");
client_tx
.flush()
.await
.expect("failed to flush initialized notification");
let call = json!({
"jsonrpc": "2.0",
"id": 2,
"method": method,
"params": params
})
.to_string()
+ "\n";
client_tx
.write_all(call.as_bytes())
.await
.expect("failed to write request");
client_tx.flush().await.expect("failed to flush request");
tokio::select! {
result = async {
loop {
let line = reader
.next_line()
.await
.expect("IO error reading response")
.expect("server closed before sending response");
let v: serde_json::Value =
serde_json::from_str(&line).expect("response is not valid JSON");
if v.get("id") == Some(&json!(2)) {
return v;
}
}
} => {
server_handle.abort();
result
}
outcome = &mut server_handle => {
match outcome {
Ok(_) => panic!("server task exited unexpectedly before response"),
Err(e) => panic!("server task panicked: {e}"),
}
}
}
}
#[tokio::test]
async fn test_list_resources_no_cursor() {
let resp = send_request("resources/list", json!({})).await;
assert!(
resp.get("error").is_none(),
"unexpected error response: {resp}"
);
assert_eq!(resp["result"]["resources"], json!([]));
assert!(
resp["result"].get("nextCursor").is_none(),
"expected no nextCursor on a single-page result, got: {resp}"
);
}
#[tokio::test]
async fn test_list_resource_templates_no_cursor() {
let resp = send_request("resources/templates/list", json!({})).await;
assert!(
resp.get("error").is_none(),
"unexpected error response: {resp}"
);
let templates = resp["result"]["resourceTemplates"]
.as_array()
.unwrap_or_else(|| panic!("expected resourceTemplates array, got: {resp}"));
assert_eq!(
templates.len(),
2,
"expected two advertised templates, got: {resp}"
);
assert!(
resp["result"].get("nextCursor").is_none(),
"expected no nextCursor on a single-page result, got: {resp}"
);
}
#[tokio::test]
async fn test_list_resource_templates_malformed_cursor() {
let resp = send_request(
"resources/templates/list",
json!({"cursor": "not-valid-base64!!"}),
)
.await;
let error = resp
.get("error")
.unwrap_or_else(|| panic!("expected error response, got: {resp}"));
assert_eq!(
error["code"].as_i64().unwrap(),
-32602,
"expected INVALID_PARAMS code, got: {resp}"
);
}
#[tokio::test]
async fn test_list_resource_templates_out_of_range_cursor() {
let cursor = encode_cursor(&CursorData {
mode: PaginationMode::Default,
offset: 9999,
})
.expect("cursor encoding must succeed");
let resp = send_request("resources/templates/list", json!({"cursor": cursor})).await;
assert!(
resp.get("error").is_none(),
"unexpected error response: {resp}"
);
assert_eq!(
resp["result"]["resourceTemplates"],
json!([]),
"expected empty page for out-of-range offset, got: {resp}"
);
assert!(
resp["result"].get("nextCursor").is_none(),
"expected no nextCursor on the last page, got: {resp}"
);
}
#[tokio::test]
#[serial]
async fn test_resources_read_with_edges() {
let tmp = std::env::temp_dir().join("aptu-coder-test-resources-edges");
let _ = std::fs::create_dir_all(&tmp);
unsafe {
env::set_var(
"APTU_CODER_DISK_CACHE_DIR",
tmp.to_string_lossy().into_owned(),
);
}
let mut f1 = FunctionInfo::default();
f1.name = "caller".to_string();
f1.line = 1;
f1.end_line = 10;
let mut f2 = FunctionInfo::default();
f2.name = "callee".to_string();
f2.line = 20;
f2.end_line = 25;
let call: CallInfo =
serde_json::from_str(r#"{"caller":"caller","callee":"callee","line":1,"column":0}"#)
.expect("valid call JSON");
let analysis = SemanticAnalysis::new(
vec![f1, f2],
vec![],
vec![],
vec![],
Default::default(),
vec![call],
vec![],
);
let entry = FileAnalysisOutput::new(
"test.rs".to_string(),
"test.rs:1:1:1".to_string(),
analysis,
30,
None,
);
let graph = StructuralGraph::build_from_analysis(&[entry]);
let repo_hash = "test-edges";
let store = GraphDiskStore::new(tmp.clone());
store.put(repo_hash, &graph);
let resp = send_request(
"resources/read",
json!({
"uri": format!("aptu-coder://graph/{repo_hash}/blast-radius/caller"),
}),
)
.await;
assert!(
resp.get("error").is_none(),
"unexpected error response: {resp}"
);
let result = resp["result"]
.get("contents")
.and_then(|c| c.as_array().and_then(|arr| arr.first()))
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
.expect("expected text field in response");
let payload: serde_json::Value =
serde_json::from_str(result).expect("response text should be valid JSON");
let edges = payload["edges"]
.as_array()
.expect("edges should be an array");
assert!(
!edges.is_empty(),
"edges array should be non-empty, got: {payload}"
);
let has_calls_edge = edges
.iter()
.any(|e| e.get("kind").and_then(|k| k.as_str()) == Some("Calls"));
assert!(has_calls_edge, "should have at least one Calls edge");
for edge in edges {
assert!(
edge.get("source").is_some(),
"edge should have source field"
);
assert!(
edge.get("target").is_some(),
"edge should have target field"
);
assert!(edge.get("kind").is_some(), "edge should have kind field");
}
let _ = std::fs::remove_dir_all(&tmp);
}