use std::collections::BTreeMap;
use std::fs;
use super::{agent_file, command_rel, hook_is_portable, reconcile_hooks, remove_hooks, render_agent};
use crate::agents::BackendState;
use crate::agents::mcpjson::{self, ServerShape};
use crate::components::{HookBinding, MarkdownDoc, McpKind, McpServer};
use crate::host::Outcome;
use serde_json::Value;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = crate::scratch::path("ez-qwen-unit");
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::new(), env: BTreeMap::new() }
}
fn doc(rel: &str) -> MarkdownDoc {
MarkdownDoc { name: "x".into(), rel: rel.into(), frontmatter: BTreeMap::new(), body: String::new(), raw: Vec::new() }
}
#[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_writes_cc_nested_shape_with_identity_events_and_is_idempotent() {
let path = scratch("settings.json");
let start = HookBinding { event: "SessionStart".into(), matcher: None, command: "host_fixture self-heal".into() };
let pre = HookBinding { event: "PreToolUse".into(), matcher: Some("Bash".into()), command: "host_fixture guard".into() };
let changed = reconcile_hooks(&path, &[start.clone(), pre.clone()]).unwrap();
assert!(changed, "first reconcile must write");
let v: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
let group = &v["hooks"]["SessionStart"][0];
assert_eq!(group["hooks"][0]["type"], "command");
assert_eq!(group["hooks"][0]["command"], "host_fixture self-heal");
let pre_group = &v["hooks"]["PreToolUse"][0];
assert_eq!(pre_group["matcher"], "Bash");
assert_eq!(pre_group["hooks"][0]["command"], "host_fixture guard");
assert!(!reconcile_hooks(&path, &[start, pre]).unwrap(), "second reconcile must be a no-op");
fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn subagent_start_maps_identically() {
let path = scratch("settings.json");
let sub = HookBinding { event: "SubagentStart".into(), matcher: None, command: "host_fixture note".into() };
assert!(reconcile_hooks(&path, std::slice::from_ref(&sub)).unwrap(), "SubagentStart hook must be written");
let v: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(
v["hooks"]["SubagentStart"][0]["hooks"][0]["command"], "host_fixture note",
"SubagentStart must map 1:1 and land under hooks.SubagentStart:\n{v}"
);
fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn reconcile_hooks_skips_non_portable_and_remove_leaves_a_same_name_survivor() {
let path = scratch("settings.json");
let rooted = HookBinding { event: "SessionStart".into(), matcher: None, command: "${CLAUDE_PLUGIN_ROOT}/hooks/self-heal.sh".into() };
assert!(!reconcile_hooks(&path, std::slice::from_ref(&rooted)).unwrap());
assert!(!path.exists(), "reconcile_hooks must not create a file for zero writable hooks");
fs::write(&path, format!(r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}"}}]}}]}}}}"#, rooted.command))
.unwrap();
let before = fs::read_to_string(&path).unwrap();
assert!(!remove_hooks(&path, std::slice::from_ref(&rooted)).unwrap(), "remove must not touch a hook we never wrote");
assert_eq!(fs::read_to_string(&path).unwrap(), before, "seeded hook survived byte-for-byte");
fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn mcp_reconcile_writes_plain_shape_and_probe_classifies_lifecycle() {
let path = scratch("settings.json");
let servers = [server("ez-fixture", "host_fixture")];
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), BackendState::Absent));
assert_eq!(mcpjson::reconcile(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), Outcome::Installed);
let v: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
let entry = &v["mcpServers"]["ez-fixture"];
assert_eq!(entry["command"], "host_fixture");
assert!(entry.get("type").is_none(), "a qwen stdio server must carry no `type` field: {entry}");
assert!(entry.get("args").is_some() && entry.get("env").is_some());
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), BackendState::Healthy));
assert_eq!(mcpjson::reconcile(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), Outcome::NoOp);
let drifted = [server("ez-fixture", "some-other-cmd")];
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &drifted, ServerShape::plain()).unwrap(), BackendState::NeedsRepair));
fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn mcp_non_portable_is_skipped_and_probe_stays_healthy() {
let path = scratch("settings.json");
fs::write(&path, "{}").unwrap();
let rooted = server("rooted", "${CLAUDE_PLUGIN_ROOT}/bin/x");
mcpjson::reconcile(&path, &["mcpServers"], std::slice::from_ref(&rooted), ServerShape::plain()).unwrap();
let v: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert!(v["mcpServers"].get("rooted").is_none(), "a non-portable server must never be written:\n{v}");
assert!(matches!(
mcpjson::probe(&path, &["mcpServers"], std::slice::from_ref(&rooted), ServerShape::plain()).unwrap(),
BackendState::Healthy
));
assert_eq!(mcpjson::reconcile(&path, &["mcpServers"], std::slice::from_ref(&rooted), ServerShape::plain()).unwrap(), Outcome::NoOp);
fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn mcp_remove_deletes_only_ours_and_keeps_a_user_entry() {
let path = scratch("settings.json");
fs::write(&path, r#"{"mcpServers":{"ez-fixture":{"command":"host_fixture","args":["mcp"]},"theirs":{"command":"their-server"}}}"#)
.unwrap();
let declared = [server("ez-fixture", "host_fixture"), server("rooted", "${CLAUDE_PLUGIN_ROOT}/x")];
assert_eq!(mcpjson::remove(&path, &["mcpServers"], &declared, ServerShape::plain()).unwrap(), Outcome::Removed);
let v: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert!(v["mcpServers"].get("ez-fixture").is_none(), "our server should be gone");
assert!(v["mcpServers"].get("theirs").is_some(), "the user's server must survive");
fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn render_agent_namespaces_name_and_drops_model() {
let mut fm = BTreeMap::new();
fm.insert("name".to_string(), Value::from("ez-helper"));
fm.insert("description".to_string(), Value::from("a fixture helper"));
fm.insert("model".to_string(), Value::from("sonnet"));
let agent = MarkdownDoc {
name: "ez-helper".into(),
rel: "agents/ez-helper.md".into(),
frontmatter: fm,
body: " Body text. ".into(),
raw: Vec::new(),
};
let out = render_agent("ez-fixture-plugin", &agent);
assert!(out.contains(r#"name: "ez-fixture-plugin-ez-helper""#), "name must be plugin-prefixed and quoted:\n{out}");
assert!(out.contains("description:"), "description must carry over:\n{out}");
assert!(!out.contains("model:") && !out.contains("sonnet"), "the CC model alias must be dropped:\n{out}");
assert!(out.ends_with("Body text.\n"), "body copies through, trimmed:\n{out}");
}
#[test]
fn render_agent_quotes_yaml_special_name() {
let mut fm = BTreeMap::new();
fm.insert("name".to_string(), Value::from("weird: @agent"));
let agent = MarkdownDoc { name: "weird".into(), rel: "agents/weird.md".into(), frontmatter: fm, body: "Body.".into(), raw: Vec::new() };
let out = render_agent("plug", &agent);
assert!(out.contains("\nname: \"plug-weird: @agent\"\n"), "a YAML-special name must be double-quoted:\n{out}");
let mut fm2 = BTreeMap::new();
fm2.insert("name".to_string(), Value::from(r#"a"b"#));
let agent2 = MarkdownDoc { name: "a".into(), rel: "agents/a.md".into(), frontmatter: fm2, body: String::new(), raw: Vec::new() };
assert!(render_agent("p", &agent2).contains("\nname: \"p-a\\\"b\"\n"), "an embedded quote must be escaped");
}
#[test]
fn command_and_agent_path_helpers() {
assert_eq!(command_rel(&doc("commands/hello.md")), "hello.md");
assert_eq!(command_rel(&doc("commands/git/commit.md")), "git/commit.md");
assert_eq!(agent_file("ez-fixture-plugin", &doc("agents/ez-helper.md")), "ez-fixture-plugin-ez-helper.md");
assert_eq!(agent_file("ez-fixture-plugin", &doc("agents/deep/nested.md")), "ez-fixture-plugin-deep-nested.md");
}
fn agentgear_token_dir_contains(root: &std::path::Path, needle: &str) -> bool {
let Ok(rd) = std::fs::read_dir(root) else { return false };
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
if agentgear_token_dir_contains(&p, needle) {
return true;
}
} else if std::fs::read_to_string(&p).is_ok_and(|s| s.contains(needle)) {
return true;
}
}
false
}
#[test]
fn agentgear_client_token_expands_to_this_backend_id() {
use crate::agents::{AgentBackend, BackendState};
use crate::host::{Desired, Plugin, Scope, Source};
let backend = super::QwenCodeBackend;
let id = backend.id();
let src = crate::scratch::path("ez-cidtok-src");
std::fs::create_dir_all(src.join(".claude-plugin")).unwrap();
std::fs::create_dir_all(src.join("hooks")).unwrap();
std::fs::write(
src.join(".claude-plugin").join("plugin.json"),
r#"{"name":"ez-cid","version":"0.1.0","mcpServers":{"srv":{"command":"host_fixture","args":["cid=${AGENTGEAR_CLIENT}"]}}}"#,
)
.unwrap();
std::fs::write(
src.join("hooks").join("hooks.json"),
r#"{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"host_fixture up --client ${AGENTGEAR_CLIENT}"}]}]}}"#,
)
.unwrap();
let plugin = Plugin { name: "ez-cid", marketplace: "ez-mkt", version: "0.1.0", agents: &["qwen-code"], instructions: None, blob: &[] };
let project = crate::scratch::path("ez-cidtok-dst");
let scope = Scope::Project { path: project.clone() };
let source = Source::Path(src.clone());
backend.reconcile(&plugin, &Desired { source: source.clone(), reenable: true }, &scope).unwrap();
assert!(agentgear_token_dir_contains(&project, &format!("cid={id}")), "the mcp arg token did not expand to `{id}`");
assert!(!agentgear_token_dir_contains(&project, "${AGENTGEAR_CLIENT}"), "a raw ${{AGENTGEAR_CLIENT}} token leaked to disk");
assert!(
matches!(backend.probe(&plugin, &scope, &source).unwrap(), BackendState::Healthy),
"a token-bearing install must probe Healthy, not churn"
);
backend.remove(&plugin, &scope, &source).unwrap();
assert!(!agentgear_token_dir_contains(&project, &format!("cid={id}")), "remove left the substituted mcp entry behind");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&project);
}