aptu-coder 0.32.1

MCP server for multi-language code structure analysis
Documentation
// SPDX-FileCopyrightText: 2026 aptu-coder contributors
// SPDX-License-Identifier: Apache-2.0

//! Integration tests for the MCP Resource surface pagination
//! (`resources/list` and `resources/templates/list`).
//!
//! These are server-push resources rather than tools, so `call_tool_raw` from
//! `common/mod.rs` cannot reach them. We hand-roll JSON-RPC over a duplex pipe
//! mirroring the `make_test_analyzer` harness pattern: initialize handshake,
//! initialized notification, then the method call.

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

/// Send a single JSON-RPC method call after the initialize handshake and
/// return the response whose id matches.
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();

    // Step 1: initialize
    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");

    // Step 2: initialized notification
    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");

    // Step 3: method call
    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");

    // Step 4: race response loop against server handle to surface server panics
    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}"),
            }
        }
    }
}

/// Happy path: resources/list without a cursor returns an empty list and no
/// nextCursor.
#[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}"
    );
}

/// Happy path: resources/templates/list without a cursor returns both
/// templates and no nextCursor.
#[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}"
    );
}

/// Edge case: a malformed cursor yields a JSON-RPC error with
/// INVALID_PARAMS (-32602).
#[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}"
    );
}

/// Edge case: a valid cursor pointing past the end of the three-template
/// catalog returns an empty page with no nextCursor.
#[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}"
    );
}

/// Integration test for the resources/read path asserting edges are present.
#[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);

    // SAFETY: This test mutates process-wide environment state with set_var.
    // The #[serial] attribute ensures this test runs in isolation.
    unsafe {
        env::set_var(
            "APTU_CODER_DISK_CACHE_DIR",
            tmp.to_string_lossy().into_owned(),
        );
    }

    // Build a small graph with one function calling another (Calls edge). The graph also
    // gets File --Contains--> Symbol edges from build_from_analysis, but BFS from a symbol
    // only follows outgoing edges, so the containing file is never visited and no Contains
    // edge can appear in a blast-radius/subgraph result rooted at a symbol.
    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]);

    // Write the graph to disk via GraphDiskStore
    let repo_hash = "test-edges";
    let store = GraphDiskStore::new(tmp.clone());
    store.put(repo_hash, &graph);

    // Send a resources/read request for the graph
    let resp = send_request(
        "resources/read",
        json!({
            "uri": format!("aptu-coder://graph/{repo_hash}/blast-radius/caller"),
        }),
    )
    .await;

    // Assert no error
    assert!(
        resp.get("error").is_none(),
        "unexpected error response: {resp}"
    );

    // Extract and parse the result
    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");

    // Assert edges array is present and contains at least one Calls edge
    let edges = payload["edges"]
        .as_array()
        .expect("edges should be an array");
    assert!(
        !edges.is_empty(),
        "edges array should be non-empty, got: {payload}"
    );

    // Check that we have at least one Calls edge (the caller -> callee call)
    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");

    // Verify that each edge has source, target, and kind fields
    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");
    }

    // Cleanup
    let _ = std::fs::remove_dir_all(&tmp);
}