use std::collections::BTreeMap;
use serde_json::{Value, json};
use super::{doc_filename, hook_is_portable, map_event, namespaced, reconcile_hooks, remove_hooks, render_droid};
use crate::agents::BackendState;
use crate::agents::mcpjson::{self, ServerShape};
use crate::components::{HookBinding, MarkdownDoc, McpKind, McpServer};
use crate::host::Outcome;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = crate::scratch::path("ez-droid-unit");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn read(path: &std::path::Path) -> Value {
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
fn stdio(name: &str, command: &str, args: &[&str]) -> McpServer {
McpServer {
name: name.into(),
kind: McpKind::Stdio,
command: command.into(),
args: args.iter().map(|a| a.to_string()).collect(),
env: BTreeMap::new(),
}
}
#[test]
fn mcp_reconcile_writes_plain_body_and_second_is_noop() {
let path = scratch("mcp.json");
let server = stdio("ez-fixture", "host_fixture", &["mcp"]);
let out = mcpjson::reconcile(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap();
assert_eq!(out, Outcome::Installed);
let root = read(&path);
assert_eq!(root["mcpServers"]["ez-fixture"], json!({ "command": "host_fixture", "args": ["mcp"], "env": {} }));
let out = mcpjson::reconcile(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap();
assert_eq!(out, Outcome::NoOp);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn mcp_probe_classifies_absent_healthy_and_needs_repair() {
let path = scratch("mcp.json");
let server = stdio("ez-fixture", "host_fixture", &["mcp"]);
assert!(matches!(
mcpjson::probe(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap(),
BackendState::Absent
));
mcpjson::reconcile(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap();
assert!(matches!(
mcpjson::probe(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap(),
BackendState::Healthy
));
let drifted = stdio("ez-fixture", "host_fixture", &["mcp", "--v2"]);
assert!(matches!(
mcpjson::probe(&path, &["mcpServers"], std::slice::from_ref(&drifted), ServerShape::plain()).unwrap(),
BackendState::NeedsRepair
));
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn mcp_remove_deletes_only_ours_and_preserves_a_user_entry() {
let path = scratch("mcp.json");
std::fs::write(&path, r#"{"telemetry":false,"mcpServers":{"theirs":{"command":"their-server","args":[]}}}"#).unwrap();
let server = stdio("ez-fixture", "host_fixture", &["mcp"]);
mcpjson::reconcile(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap();
let root = read(&path);
assert!(root["mcpServers"]["ez-fixture"].is_object(), "our server was not written");
assert!(root["mcpServers"]["theirs"].is_object(), "the user's server was clobbered on reconcile");
let out = mcpjson::remove(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap();
assert_eq!(out, Outcome::Removed);
let root = read(&path);
assert!(root["mcpServers"].get("ez-fixture").is_none(), "our server survived remove");
assert_eq!(root["mcpServers"]["theirs"], json!({ "command": "their-server", "args": [] }), "user's server was touched");
assert_eq!(root["telemetry"], json!(false), "unrelated top-level key was touched");
assert_eq!(mcpjson::remove(&path, &["mcpServers"], std::slice::from_ref(&server), ServerShape::plain()).unwrap(), Outcome::NoOp);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn map_event_is_identity_for_the_full_cc_set_and_none_otherwise() {
for e in [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Notification",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
] {
assert_eq!(map_event(e), Some(e), "droid hosts every CC event under its own name");
}
assert_eq!(map_event("PreCompress"), None, "an event droid does not host must be skipped, not guessed");
}
#[test]
fn reconcile_hooks_writes_cc_wrapper_shape_and_second_is_noop() {
let path = scratch("hooks.json");
let hook = HookBinding { event: "SessionStart".into(), matcher: None, command: "host_fixture self-heal".into() };
let changed = reconcile_hooks(&path, std::slice::from_ref(&hook)).unwrap();
assert!(changed, "first reconcile writes the hook");
let root = read(&path);
assert_eq!(root["hooks"]["SessionStart"], json!([{ "hooks": [{ "type": "command", "command": "host_fixture self-heal" }] }]));
let changed = reconcile_hooks(&path, std::slice::from_ref(&hook)).unwrap();
assert!(!changed, "second reconcile of an already-present hook must be a NoOp");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn hook_portability_matches_mcp_server_rule() {
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));
}
#[test]
fn reconcile_hooks_skips_non_portable_and_remove_leaves_a_same_name_survivor() {
let path = scratch("hooks.json");
let rooted = HookBinding { event: "SessionStart".into(), matcher: None, command: "${CLAUDE_PLUGIN_ROOT}/hooks/self-heal.sh".into() };
let changed = reconcile_hooks(&path, std::slice::from_ref(&rooted)).unwrap();
assert!(!changed, "a non-portable hook must not be written");
assert!(!path.exists(), "reconcile_hooks must not create a file for zero writable hooks");
std::fs::write(&path, format!(r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}"}}]}}]}}}}"#, rooted.command))
.unwrap();
let before = std::fs::read_to_string(&path).unwrap();
let removed = remove_hooks(&path, std::slice::from_ref(&rooted)).unwrap();
assert!(!removed, "remove_hooks must not remove a hook it never wrote");
assert_eq!(std::fs::read_to_string(&path).unwrap(), before, "seeded hook survived byte-for-byte");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn doc_filename_flattens_nested_paths_and_prefixes_the_plugin() {
assert_eq!(doc_filename("ez", "commands/hello.md", "commands/"), "ez-hello.md");
assert_eq!(doc_filename("ez", "commands/sub/foo.md", "commands/"), "ez-sub-foo.md");
assert_eq!(doc_filename("ez", "agents/helper.md", "agents/"), "ez-helper.md");
assert_eq!(namespaced("ez", "agents/helper.md", "agents/"), "ez-helper");
}
#[test]
fn render_droid_namespaces_name_and_passes_frontmatter_through() {
let mut frontmatter = BTreeMap::new();
frontmatter.insert("name".to_string(), Value::from("helper"));
frontmatter.insert("description".to_string(), Value::from("a fixture helper"));
frontmatter.insert("model".to_string(), Value::from("sonnet"));
let doc =
MarkdownDoc { name: "helper".into(), rel: "agents/helper.md".into(), frontmatter, body: "Do the thing.".into(), raw: Vec::new() };
let rendered = render_droid("myplug", &doc.rel, &doc);
assert!(rendered.contains("name: myplug-helper"), "custom-droid name must be plugin-namespaced:\n{rendered}");
assert!(!rendered.contains("name: helper\n"), "the original bare name must be overridden:\n{rendered}");
assert!(rendered.contains("model: sonnet"), "model frontmatter must pass through:\n{rendered}");
assert!(rendered.contains("description: a fixture helper"), "description frontmatter must pass through:\n{rendered}");
assert!(rendered.contains("Do the thing."), "the body must be carried into the system prompt:\n{rendered}");
assert_eq!(rendered, render_droid("myplug", &doc.rel, &doc));
}