#![allow(clippy::too_many_lines)]
mod support;
use std::time::{Duration, SystemTime};
use serde_json::{Value, json};
use sqry_core::project::{ProjectRootMode, canonicalize_path};
use sqry_daemon::error::DaemonError;
use sqry_daemon::ipc::framing::{read_frame_json, write_frame_json};
use sqry_daemon::mcp_host::error_map::{daemon_err_to_mcp, daemon_err_to_mcp_with_tool};
use sqry_daemon::{
DaemonConfig, JSONRPC_INVALID_PARAMS, JSONRPC_TOOL_TIMEOUT, WorkspaceKey, WorkspaceState,
};
use sqry_daemon_protocol::{ShimProtocol, ShimRegister, ShimRegisterAck};
use sqry_mcp::error::RpcError;
use support::ipc::{TestIpcClient, TestServer, expect_error};
use tempfile::TempDir;
use tokio::net::UnixStream;
const STANDALONE_DEFAULT_RETRY_AFTER_MS: u64 = 500;
fn reference_envelope_deadline_exceeded(tool: &str, deadline_ms: u64) -> Value {
let err = RpcError::deadline_exceeded(tool, deadline_ms, STANDALONE_DEFAULT_RETRY_AFTER_MS);
json!({
"kind": err.kind,
"retryable": err.retryable,
"retry_after_ms": err.retry_after_ms,
"details": err.details,
})
}
fn call_tool_request(
name: &'static str,
arguments: serde_json::Map<String, serde_json::Value>,
) -> rmcp::model::CallToolRequestParams {
rmcp::model::CallToolRequestParams::new(name).with_arguments(arguments)
}
async fn connect_mcp_shim(
server: &TestServer,
) -> (
tokio::io::ReadHalf<UnixStream>,
tokio::io::WriteHalf<UnixStream>,
) {
let stream = UnixStream::connect(&server.path).await.expect("connect");
let (mut read_half, mut write_half) = tokio::io::split(stream);
let shim_reg = ShimRegister {
protocol: ShimProtocol::Mcp,
pid: std::process::id(),
};
write_frame_json(&mut write_half, &shim_reg)
.await
.expect("write ShimRegister");
let ack = read_frame_json::<_, ShimRegisterAck>(&mut read_half)
.await
.expect("read ack")
.expect("ack frame");
assert!(ack.accepted, "ack must be accepted");
(read_half, write_half)
}
fn setup_loaded_workspace(server: &TestServer, dir: &TempDir) -> std::path::PathBuf {
let canon = canonicalize_path(dir.path()).unwrap();
let key = WorkspaceKey::new(canon.clone(), ProjectRootMode::GitRoot, 0);
server
.manager
.insert_workspace_in_state_for_test(key, WorkspaceState::Loaded);
canon
}
fn setup_stale_workspace(server: &TestServer, dir: &TempDir, age_secs: u64) -> std::path::PathBuf {
let canon = canonicalize_path(dir.path()).unwrap();
let key = WorkspaceKey::new(canon.clone(), ProjectRootMode::GitRoot, 0);
server
.manager
.insert_workspace_in_state_for_test(key.clone(), WorkspaceState::Failed);
let ws = server.manager.lookup(&key).expect("ws registered");
ws.set_last_good_at_for_test(Some(SystemTime::now() - Duration::from_secs(age_secs)));
canon
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_timeout_error_data_maps_to_code_32000_and_deadline_exceeded() {
let config = DaemonConfig {
tool_timeout_secs: 1,
..DaemonConfig::default()
};
let server = TestServer::with_config(config).await;
let dir = tempfile::tempdir().unwrap();
let canon = setup_loaded_workspace(&server, &dir);
use sqry_daemon::error::DaemonError;
let timeout_err = DaemonError::ToolTimeout {
root: canon.clone(),
secs: 1,
deadline_ms: 1000,
};
let code = timeout_err.jsonrpc_code();
assert_eq!(
code,
Some(JSONRPC_TOOL_TIMEOUT),
"ToolTimeout must map to JSONRPC_TOOL_TIMEOUT (-32000)"
);
let data = timeout_err
.error_data()
.expect("error_data populated for ToolTimeout");
assert_eq!(
data["kind"], "deadline_exceeded",
"ToolTimeout error_data kind must be 'deadline_exceeded'"
);
assert_eq!(data["retryable"], true, "ToolTimeout must be retryable");
assert_eq!(
data["details"]["deadline_ms"], 1000_u64,
"details.deadline_ms must be secs*1000"
);
let _ = canon;
assert!(
data["details"].get("root").is_none(),
"details.root must be absent — it would diverge from the standalone shape"
);
server.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_timeout_mcp_envelope_parity() {
let root = std::path::PathBuf::from("/tmp/parity_ws");
let secs = 60u64;
let daemon_err = DaemonError::ToolTimeout {
root: root.clone(),
secs,
deadline_ms: secs * 1000,
};
let deadline_ms = secs * 1000;
let reference = reference_envelope_deadline_exceeded("semantic_search", deadline_ms);
let mcp_err = daemon_err_to_mcp_with_tool(daemon_err, "semantic_search");
let mcp_data = mcp_err.data.as_ref().unwrap();
let outer_keys_ref: std::collections::BTreeSet<String> =
reference.as_object().unwrap().keys().cloned().collect();
let outer_keys_mcp: std::collections::BTreeSet<String> =
mcp_data.as_object().unwrap().keys().cloned().collect();
assert_eq!(
outer_keys_ref, outer_keys_mcp,
"daemon MCP envelope must share the 4-key outer shape with standalone reference"
);
assert_eq!(
mcp_data["kind"], reference["kind"],
"kind must match standalone reference"
);
assert_eq!(
mcp_data["retryable"], reference["retryable"],
"retryable must match standalone reference"
);
assert_eq!(
mcp_data["retry_after_ms"], reference["retry_after_ms"],
"retry_after_ms must match standalone reference exactly"
);
let daemon_details = &mcp_data["details"];
let ref_details = &reference["details"];
assert_eq!(
daemon_details["tool"], ref_details["tool"],
"details.tool must match"
);
assert_eq!(
daemon_details["deadline_ms"], ref_details["deadline_ms"],
"details.deadline_ms must match"
);
assert!(
daemon_details.get("root").is_none(),
"daemon details must NOT carry 'root' (cluster-A iter-2 wire-identity fix)"
);
assert!(
ref_details.get("root").is_none(),
"standalone details must NOT carry 'root' key"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invalid_argument_json_rpc_code_32602() {
let server = TestServer::new().await;
let mut client = TestIpcClient::connect(&server.path).await;
client.hello(1).await;
let resp = client
.request(
"semantic_search",
json!({
"query": "kind:function",
"path": "/tmp/__nonexistent_path_for_u15_test__/totally_missing",
"max_results": 5,
"context_lines": 0,
"include_classpath": false,
}),
)
.await;
let err = expect_error(&resp);
assert_eq!(
err.code, JSONRPC_INVALID_PARAMS,
"InvalidArgument must map to -32602 (JSONRPC_INVALID_PARAMS)"
);
let data = err.data.as_ref().expect("error.data populated");
assert_eq!(
data["kind"], "validation_error",
"InvalidArgument data.kind must be 'validation_error'"
);
assert_eq!(
data["retryable"], false,
"InvalidArgument must not be retryable"
);
assert!(
data["retry_after_ms"].is_null(),
"retry_after_ms must be null for InvalidArgument"
);
let reason = data["details"]["reason"]
.as_str()
.expect("details.reason must be a string");
assert!(
!reason.is_empty(),
"InvalidArgument details.reason must describe the failure"
);
server.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invalid_argument_mcp_envelope_outer_parity() {
let server = TestServer::new().await;
let (read_half, write_half) = connect_mcp_shim(&server).await;
let running = rmcp::serve_client((), (read_half, write_half))
.await
.expect("rmcp initialize");
let call_result = running
.peer()
.call_tool(call_tool_request(
"semantic_search",
serde_json::Map::from_iter([
("query".to_string(), json!("kind:function")),
]),
))
.await;
match call_result {
Err(rmcp::ServiceError::McpError(mcp_err)) => {
let data = mcp_err
.data
.as_ref()
.expect("MCP error must carry structured data");
let keys: std::collections::BTreeSet<String> = data
.as_object()
.expect("data must be an object")
.keys()
.cloned()
.collect();
let expected: std::collections::BTreeSet<String> =
["kind", "retryable", "retry_after_ms", "details"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
keys, expected,
"MCP error must have exactly the 4 canonical outer keys"
);
assert_eq!(
data["kind"], "validation_error",
"kind must be 'validation_error' for missing-path InvalidArgument"
);
assert_eq!(data["retryable"], false, "must not be retryable");
assert!(
data["retry_after_ms"].is_null(),
"retry_after_ms must be null"
);
assert!(
data["details"]["reason"].is_string(),
"details.reason must be a string"
);
}
Err(other) => panic!("expected McpError variant, got: {other:?}"),
Ok(result) => panic!("expected MCP error, got success: {result:?}"),
}
drop(running);
server.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workspace_stale_expired_mcp_envelope() {
let config = DaemonConfig {
stale_serve_max_age_hours: 24,
..DaemonConfig::default()
};
let server = TestServer::with_config(config).await;
let dir = tempfile::tempdir().unwrap();
let canon = setup_stale_workspace(&server, &dir, 48 * 3600);
let (read_half, write_half) = connect_mcp_shim(&server).await;
let running = rmcp::serve_client((), (read_half, write_half))
.await
.expect("rmcp initialize");
let call_result = running
.peer()
.call_tool(call_tool_request(
"semantic_search",
serde_json::Map::from_iter([
("query".to_string(), json!("kind:function")),
("path".to_string(), json!(canon.to_string_lossy().as_ref())),
("max_results".to_string(), json!(5)),
("context_lines".to_string(), json!(0)),
("include_classpath".to_string(), json!(false)),
]),
))
.await;
match call_result {
Err(rmcp::ServiceError::McpError(mcp_err)) => {
let data = mcp_err
.data
.as_ref()
.expect("stale-expired error must carry structured data");
assert_eq!(
data["kind"], "workspace_stale_expired",
"stale-expired kind must be 'workspace_stale_expired'"
);
assert_eq!(
data["retryable"], false,
"stale-expired must not be retryable"
);
assert!(
data["retry_after_ms"].is_null(),
"retry_after_ms must be null for stale-expired"
);
let details = &data["details"];
for key in [
"root",
"age_hours",
"cap_hours",
"last_good_at",
"last_error",
] {
assert!(
details.get(key).is_some(),
"details.{key} must be present in workspace_stale_expired envelope"
);
}
let age = details["age_hours"].as_u64().expect("age_hours numeric");
let cap = details["cap_hours"].as_u64().expect("cap_hours numeric");
assert!(age >= cap, "expired implies age >= cap; {age} vs {cap}");
if let Some(s) = details["last_good_at"].as_str() {
assert!(
s.ends_with('Z'),
"last_good_at must be UTC-Zulu RFC3339: {s}"
);
}
}
Err(other) => panic!("expected McpError variant, got: {other:?}"),
Ok(result) => panic!("expected MCP error for stale-expired, got success: {result:?}"),
}
drop(running);
server.stop().await;
}
#[test]
fn tool_timeout_details_tool_populated() {
let err = DaemonError::ToolTimeout {
root: std::path::PathBuf::from("/tmp/ws"),
secs: 60,
deadline_ms: 60_000,
};
let mcp_err = daemon_err_to_mcp_with_tool(err, "semantic_search");
let data = mcp_err.data.as_ref().unwrap();
assert_eq!(
data["details"]["tool"], "semantic_search",
"daemon_err_to_mcp_with_tool must populate details.tool with the tool name"
);
assert_eq!(data["kind"], "deadline_exceeded");
assert_eq!(data["retryable"], true);
assert_eq!(data["details"]["deadline_ms"], 60_000_u64);
assert!(
data["details"].get("root").is_none(),
"details.root must be absent — it would diverge from the standalone shape"
);
let err_null = DaemonError::ToolTimeout {
root: std::path::PathBuf::from("/tmp/ws"),
secs: 60,
deadline_ms: 60_000,
};
let mcp_null = daemon_err_to_mcp(err_null);
assert!(
mcp_null.data.as_ref().unwrap()["details"]["tool"].is_null(),
"non-site-aware daemon_err_to_mcp must emit null for details.tool"
);
}