use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{agent_file, hook_is_portable, map_event, portable_names, project_root, reconcile_hooks, render_agent};
use crate::agents::{BackendState, mcpjson};
use crate::components::{HookBinding, MarkdownDoc, McpKind, McpServer};
use crate::host::{Outcome, Scope};
fn scratch(name: &str) -> PathBuf {
let dir = crate::scratch::path("ez-vscodecopilot-unit");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn server(name: &str, command: &str) -> McpServer {
McpServer { name: name.into(), kind: McpKind::Stdio, command: command.into(), args: vec!["mcp".into()], env: BTreeMap::new() }
}
fn read(path: &Path) -> Value {
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
#[test]
fn remote_renders_http_only_and_skips_sse() {
let http = McpServer {
name: "h".into(),
kind: McpKind::Http { url: "https://x/mcp".into() },
command: String::new(),
args: vec![],
env: BTreeMap::new(),
};
let sse = McpServer { name: "s".into(), kind: McpKind::Sse { url: "https://x/sse".into() }, ..http.clone() };
assert_eq!(mcpjson::render_server(&http, super::SHAPE).unwrap(), serde_json::json!({"type":"http","url":"https://x/mcp","headers":{}}));
assert!(mcpjson::render_server(&sse, super::SHAPE).is_none(), "sse must be skipped (VS Code rewrites it to http)");
}
#[test]
fn reconcile_writes_servers_key_with_typed_stdio_shape() {
let path = scratch("mcp.json");
let servers = [server("ez-fixture", "host_fixture")];
let outcome = mcpjson::reconcile(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap();
assert_eq!(outcome, Outcome::Installed);
let root = read(&path);
assert!(root.get("mcpServers").is_none(), "must not write the `mcpServers` key:\n{root}");
let entry = &root["servers"]["ez-fixture"];
assert_eq!(entry["type"], "stdio", "stdio entries carry an explicit type:\n{entry}");
assert_eq!(entry["command"], "host_fixture");
assert_eq!(entry["args"][0], "mcp");
assert!(entry.get("env").is_some(), "env object present:\n{entry}");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn second_reconcile_is_noop() {
let path = scratch("mcp.json");
let servers = [server("ez-fixture", "host_fixture")];
assert_eq!(mcpjson::reconcile(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap(), Outcome::Installed);
assert_eq!(
mcpjson::reconcile(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap(),
Outcome::NoOp,
"a converged reconcile must not rewrite the file"
);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn remove_deletes_only_ours_and_keeps_a_pre_seeded_user_entry() {
let path = scratch("mcp.json");
std::fs::write(&path, r#"{"inputs":[],"servers":{"theirs":{"type":"stdio","command":"their-server","args":[],"env":{}}}}"#).unwrap();
let servers = [server("ez-fixture", "host_fixture")];
mcpjson::reconcile(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap();
let removed = mcpjson::remove(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap();
assert_eq!(removed, Outcome::Removed);
let root = read(&path);
assert!(root["servers"].get("ez-fixture").is_none(), "our server survived removal:\n{root}");
assert_eq!(root["servers"]["theirs"]["command"], "their-server", "seeded user server was clobbered:\n{root}");
assert!(root.get("inputs").is_some(), "unrelated sibling key was dropped:\n{root}");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn probe_classifies_absent_healthy_and_needs_repair() {
let path = scratch("mcp.json");
let servers = [server("ez-fixture", "host_fixture")];
assert!(matches!(mcpjson::probe(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap(), BackendState::Absent));
mcpjson::reconcile(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap();
assert!(matches!(mcpjson::probe(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap(), BackendState::Healthy));
let drifted = [server("ez-fixture", "different-binary")];
assert!(matches!(mcpjson::probe(&path, super::MCP_KEY, &drifted, super::SHAPE).unwrap(), BackendState::NeedsRepair));
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn non_portable_claude_plugin_root_server_is_skipped() {
let path = scratch("mcp.json");
let leaky = McpServer {
name: "rooted".into(),
kind: McpKind::Stdio,
command: "${CLAUDE_PLUGIN_ROOT}/bin/leaky".into(),
args: Vec::new(),
env: BTreeMap::new(),
};
let servers = [server("ez-fixture", "host_fixture"), leaky.clone()];
mcpjson::reconcile(&path, super::MCP_KEY, &servers, super::SHAPE).unwrap();
let root = read(&path);
assert!(root["servers"].get("ez-fixture").is_some(), "portable server must be written:\n{root}");
assert!(root["servers"].get("rooted").is_none(), "non-portable server leaked into mcp.json:\n{root}");
assert_eq!(portable_names(&servers), vec!["ez-fixture"]);
assert!(portable_names(std::slice::from_ref(&leaky)).is_empty());
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn hook_portability_and_event_mapping() {
let portable = HookBinding { event: "SessionStart".into(), matcher: None, command: "host_fixture self-heal".into() };
let rooted = HookBinding { event: "SessionStart".into(), matcher: None, command: "${CLAUDE_PLUGIN_ROOT}/hooks/self-heal.sh".into() };
assert!(hook_is_portable(&portable));
assert!(!hook_is_portable(&rooted));
assert_eq!(map_event("SessionStart"), Some("SessionStart"));
assert_eq!(map_event("UserPromptSubmit"), Some("UserPromptSubmit"));
assert_eq!(map_event("PreToolUse"), Some("PreToolUse"));
assert_eq!(map_event("SubagentStop"), Some("SubagentStop"));
assert_eq!(map_event("SessionEnd"), Some("SessionEnd"));
assert_eq!(map_event("SubagentStart"), Some("SubagentStart"));
assert_eq!(map_event("Notification"), None);
}
#[test]
fn reconcile_hooks_writes_owned_file_maps_events_and_is_idempotent() {
let path = scratch("ez-fixture-plugin.json");
let hooks = vec![
HookBinding { event: "SessionStart".into(), matcher: None, command: "host_fixture self-heal".into() },
HookBinding { event: "UserPromptSubmit".into(), matcher: None, command: "host_fixture check-restart".into() },
HookBinding { event: "PreToolUse".into(), matcher: Some("Write".into()), command: "host_fixture guard".into() },
HookBinding { event: "Notification".into(), matcher: None, command: "host_fixture ping".into() },
HookBinding { event: "Stop".into(), matcher: None, command: "${CLAUDE_PLUGIN_ROOT}/x.sh".into() },
];
assert!(reconcile_hooks(&path, &hooks).unwrap(), "first write must report a change");
let root = read(&path);
let events = root["hooks"].as_object().unwrap();
assert!(events.contains_key("SessionStart") && events.contains_key("UserPromptSubmit") && events.contains_key("PreToolUse"));
assert!(!events.contains_key("Notification"), "an event with no VS Code analog must be skipped:\n{root}");
assert!(!events.contains_key("Stop"), "a non-portable hook must be skipped:\n{root}");
let pre = &events["PreToolUse"][0];
assert_eq!(pre["type"], "command");
assert_eq!(pre["command"], "host_fixture guard");
assert!(pre.get("matcher").is_none(), "CC matcher must not leak into the VS Code entry:\n{root}");
assert!(!reconcile_hooks(&path, &hooks).unwrap(), "a converged hooks file must not be rewritten");
assert!(reconcile_hooks(&path, &hooks[3..]).unwrap(), "removing all writable hooks must change (delete) the owned file");
assert!(!path.exists(), "owned hooks file must be gone when no writable hook remains");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn reconcile_hooks_creates_nothing_for_zero_writable_hooks() {
let path = scratch("ez-fixture-plugin.json");
let hooks = vec![
HookBinding { event: "Notification".into(), matcher: None, command: "host_fixture ping".into() },
HookBinding { event: "SessionStart".into(), matcher: None, command: "${CLAUDE_PLUGIN_ROOT}/x.sh".into() },
];
assert!(!reconcile_hooks(&path, &hooks).unwrap(), "no writable hook must not report a change");
assert!(!path.exists(), "reconcile_hooks must not create a file for zero writable hooks");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn render_agent_name_matches_filename_stem_even_when_frontmatter_name_differs() {
let mut fm = BTreeMap::new();
fm.insert("name".to_string(), Value::String("helper".into()));
fm.insert("description".to_string(), Value::String("does things: carefully".into()));
let doc = MarkdownDoc {
name: "ez-helper".into(),
rel: "agents/ez-helper.md".into(),
frontmatter: fm,
body: "You are a helper.\n".into(),
raw: Vec::new(),
};
assert_eq!(agent_file("ez-fixture-plugin", &doc), "ez-fixture-plugin-ez-helper.agent.md");
let rendered = render_agent("ez-fixture-plugin", &doc);
assert!(
rendered.contains("name: ez-fixture-plugin-ez-helper"),
"frontmatter name must be the plugin-prefixed file stem, not the CC `name` field:\n{rendered}"
);
assert!(rendered.contains(r#"description: "does things: carefully""#), "description must be JSON-quoted:\n{rendered}");
assert!(!rendered.contains("model:"), "CC model alias must be dropped:\n{rendered}");
assert!(rendered.trim_end().ends_with("You are a helper."), "body must follow the frontmatter:\n{rendered}");
}
#[test]
fn project_root_rejects_user_scope() {
assert!(project_root(&Scope::User).is_err(), "user scope has no config surface for a project-only backend");
let dir = std::env::temp_dir();
assert_eq!(project_root(&Scope::Project { path: dir.clone() }).unwrap(), dir.as_path());
}