use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use assert_cmd::prelude::*;
use serde_json::{json, Value};
fn run_git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
if !out.status.success() {
panic!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
fn create_test_repo() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
run_git(dir.path(), &["init", "-b", "main"]);
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
run_git(dir.path(), &["config", "user.name", "Test"]);
run_git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);
dir
}
struct McpSession {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl McpSession {
fn spawn(home_override: &Path) -> Self {
let mut cmd = Command::cargo_bin("iso-code-mcp").expect("iso-code-mcp binary");
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.env("ISO_CODE_HOME", home_override);
let mut child = cmd.spawn().expect("spawn iso-code-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self { child, stdin, stdout }
}
fn request(&mut self, method: &str, params: Value, id: i64) -> Value {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": id,
});
writeln!(self.stdin, "{req}").expect("write mcp request");
self.stdin.flush().expect("flush mcp stdin");
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read mcp response");
assert!(!line.trim().is_empty(), "empty mcp response");
serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad JSON-RPC response {line:?}: {e}"))
}
fn tools_call(&mut self, tool: &str, args: Value, id: i64) -> Value {
self.request(
"tools/call",
json!({ "name": tool, "arguments": args }),
id,
)
}
fn shutdown(mut self) {
drop(self.stdin);
let _ = self.child.wait();
}
}
fn content_payload(resp: &Value) -> Value {
let text = resp["result"]["content"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("response has no content.text: {resp}"));
serde_json::from_str(text).unwrap_or_else(|e| panic!("content.text not JSON: {text}: {e}"))
}
fn tool_definition(session: &mut McpSession, tool: &str) -> Value {
let resp = session.request("tools/list", json!({}), 999);
resp["result"]["tools"]
.as_array()
.expect("tools array")
.iter()
.find(|t| t["name"] == tool)
.cloned()
.unwrap_or_else(|| panic!("tool {tool} not advertised"))
}
#[test]
fn qa_m_001_worktree_list() {
let repo = create_test_repo();
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let tool = tool_definition(&mut s, "worktree_list");
assert_eq!(tool["annotations"]["readOnlyHint"], true);
assert_eq!(tool["annotations"]["destructiveHint"], false);
assert_eq!(tool["annotations"]["idempotentHint"], true);
let resp = s.tools_call(
"worktree_list",
json!({ "repo_path": repo.path().to_string_lossy() }),
1,
);
let payload = content_payload(&resp);
let entries = payload["worktrees"].as_array().expect("worktrees array");
assert!(
!entries.is_empty(),
"primary worktree should be listed at minimum"
);
for e in entries {
assert!(e.get("path").is_some());
assert!(e.get("branch").is_some());
assert!(e.get("state").is_some());
}
s.shutdown();
}
#[test]
fn qa_m_002_worktree_status() {
let repo = create_test_repo();
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let tool = tool_definition(&mut s, "worktree_status");
assert_eq!(tool["annotations"]["readOnlyHint"], true);
assert_eq!(tool["annotations"]["destructiveHint"], false);
assert_eq!(tool["annotations"]["idempotentHint"], true);
let resp = s.tools_call(
"worktree_status",
json!({ "repo_path": repo.path().to_string_lossy() }),
2,
);
let payload = content_payload(&resp);
assert!(payload["count"].as_u64().is_some(), "count field required");
let worktrees = payload["worktrees"].as_array().expect("worktrees array");
for wt in worktrees {
assert!(wt.get("disk_usage_bytes").is_some());
}
s.shutdown();
}
#[test]
fn qa_m_003_conflict_check_returns_not_implemented() {
let repo = create_test_repo();
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let tool = tool_definition(&mut s, "conflict_check");
assert_eq!(tool["annotations"]["readOnlyHint"], true);
assert_eq!(tool["annotations"]["destructiveHint"], false);
assert_eq!(tool["annotations"]["idempotentHint"], true);
let resp = s.tools_call(
"conflict_check",
json!({ "repo_path": repo.path().to_string_lossy() }),
3,
);
assert!(
resp["error"].is_null(),
"conflict_check must not error in v1.0: {resp}"
);
let payload = content_payload(&resp);
assert_eq!(payload["status"], "not_implemented");
s.shutdown();
}
#[test]
fn qa_m_004_worktree_create() {
let repo = create_test_repo();
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let tool = tool_definition(&mut s, "worktree_create");
assert_eq!(tool["annotations"]["readOnlyHint"], false);
assert_eq!(tool["annotations"]["destructiveHint"], false);
assert_eq!(tool["annotations"]["idempotentHint"], false);
let wt_path = repo.path().join("mcp-create-wt");
let resp = s.tools_call(
"worktree_create",
json!({
"repo_path": repo.path().to_string_lossy(),
"branch": "mcp-create-branch",
"path": wt_path.to_string_lossy(),
}),
4,
);
let payload = content_payload(&resp);
assert_eq!(payload["branch"], "mcp-create-branch");
assert_eq!(payload["state"], "Active");
assert!(payload["session_uuid"].as_str().is_some_and(|s| !s.is_empty()));
assert!(wt_path.exists(), "worktree dir must be created on disk");
s.shutdown();
}
#[test]
fn qa_m_005_worktree_delete() {
let repo = create_test_repo();
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let tool = tool_definition(&mut s, "worktree_delete");
assert_eq!(tool["annotations"]["readOnlyHint"], false);
assert_eq!(tool["annotations"]["destructiveHint"], true);
assert_eq!(tool["annotations"]["idempotentHint"], false);
let wt_path = repo.path().join("mcp-delete-wt");
let create = s.tools_call(
"worktree_create",
json!({
"repo_path": repo.path().to_string_lossy(),
"branch": "mcp-delete-branch",
"path": wt_path.to_string_lossy(),
}),
5,
);
assert!(create["error"].is_null(), "create failed: {create}");
let resp = s.tools_call(
"worktree_delete",
json!({
"repo_path": repo.path().to_string_lossy(),
"path": wt_path.to_string_lossy(),
"force": true,
}),
6,
);
assert!(resp["error"].is_null(), "delete errored: {resp}");
assert!(!wt_path.exists(), "worktree dir must be removed");
let list = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo.path())
.output()
.unwrap()
.stdout;
let list = String::from_utf8_lossy(&list);
assert!(
!list.contains(wt_path.to_string_lossy().as_ref()),
"git worktree list still contains deleted path"
);
s.shutdown();
}
#[test]
fn qa_m_006_worktree_gc_report_shape() {
let repo = create_test_repo();
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let tool = tool_definition(&mut s, "worktree_gc");
assert_eq!(tool["annotations"]["readOnlyHint"], false);
assert_eq!(tool["annotations"]["destructiveHint"], true);
assert_eq!(tool["annotations"]["idempotentHint"], false);
let resp = s.tools_call(
"worktree_gc",
json!({
"repo_path": repo.path().to_string_lossy(),
"dry_run": true,
}),
7,
);
let payload = content_payload(&resp);
assert!(payload["orphans"].is_array());
assert!(payload["removed"].is_array());
assert!(payload["evicted"].is_array());
assert!(payload["freed_bytes"].as_u64().is_some());
assert_eq!(payload["dry_run"], true);
s.shutdown();
}
#[test]
fn mcp_tools_list_advertises_six_tools() {
let home = tempfile::TempDir::new().unwrap();
let mut s = McpSession::spawn(home.path());
let resp = s.request("tools/list", json!({}), 1);
let tools = resp["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 6);
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"worktree_list",
"worktree_status",
"conflict_check",
"worktree_create",
"worktree_delete",
"worktree_gc",
] {
assert!(names.contains(&expected), "missing tool {expected}");
}
s.shutdown();
}