use std::collections::BTreeMap;
use std::path::PathBuf;
use super::{hook_is_portable, hook_present, map_event, reconcile_hooks, remove_hooks};
use crate::agents::mcpjson::{self, ServerShape};
use crate::components::{HookBinding, McpKind, McpServer};
use crate::host::Outcome;
fn scratch(name: &str) -> PathBuf {
let dir = crate::scratch::path("ez-kimi-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::new(), env: BTreeMap::new() }
}
fn hook(event: &str, command: &str) -> HookBinding {
HookBinding { event: event.into(), matcher: None, command: command.into() }
}
#[test]
fn hook_portability_matches_mcp_server_rule() {
let portable = hook("SessionStart", "host_fixture self-heal");
let rooted = hook("SessionStart", "${CLAUDE_PLUGIN_ROOT}/hooks/self-heal.sh");
assert!(hook_is_portable(&portable));
assert!(!hook_is_portable(&rooted));
}
#[test]
fn map_event_passes_kimi_events_and_skips_the_unknown() {
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("PreToolFictional"), None);
}
#[test]
fn reconcile_hooks_writes_expected_toml_shape_and_second_reconcile_noops() {
let path = scratch("config.toml");
let matched = HookBinding { event: "PreToolUse".into(), matcher: Some("Bash".into()), command: "host_fixture guard".into() };
let hooks = [hook("SessionStart", "host_fixture self-heal"), matched];
let changed = reconcile_hooks(&path, &hooks).unwrap();
assert!(changed, "first reconcile must write");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[[hooks]]"), "no array-of-tables emitted:\n{text}");
assert!(text.contains("event = \"SessionStart\""), "SessionStart event missing:\n{text}");
assert!(text.contains("command = \"host_fixture self-heal\""), "SessionStart command missing:\n{text}");
assert!(text.contains("event = \"PreToolUse\""), "PreToolUse event missing:\n{text}");
assert!(text.contains("matcher = \"Bash\""), "matcher missing for the matched hook:\n{text}");
assert!(text.contains("command = \"host_fixture guard\""), "PreToolUse command missing:\n{text}");
assert!(!text.contains("matcher = \"\""), "an empty matcher key leaked in:\n{text}");
let doc: toml_edit::DocumentMut = text.parse().unwrap();
assert_eq!(doc["hooks"].as_array_of_tables().unwrap().len(), 2, "expected two hook tables");
let changed = reconcile_hooks(&path, &hooks).unwrap();
assert!(!changed, "second reconcile must be a NoOp");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "second reconcile changed bytes");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn reconcile_hooks_skips_non_portable_and_remove_hooks_leaves_a_same_command_survivor() {
let path = scratch("config.toml");
let rooted = hook("SessionStart", "${CLAUDE_PLUGIN_ROOT}/hooks/self-heal.sh");
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");
let seed = format!("[[hooks]]\nevent = \"SessionStart\"\ncommand = \"{}\"\n", rooted.command);
std::fs::write(&path, &seed).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(), seed, "seeded hook survived byte-for-byte");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn remove_hooks_strips_ours_keeps_user_under_a_colliding_event() {
let path = scratch("config.toml");
let seed = "[[hooks]]\nevent = \"SessionStart\"\ncommand = \"their-session-hook\"\n";
std::fs::write(&path, seed).unwrap();
let ours = hook("SessionStart", "host_fixture self-heal");
assert!(reconcile_hooks(&path, std::slice::from_ref(&ours)).unwrap(), "our hook should be appended");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("their-session-hook") && text.contains("host_fixture self-heal"), "both hooks should coexist:\n{text}");
assert!(remove_hooks(&path, std::slice::from_ref(&ours)).unwrap(), "our hook should be removed");
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("host_fixture self-heal"), "our hook survived removal:\n{text}");
assert!(text.contains("their-session-hook"), "user's same-event hook was removed:\n{text}");
let doc: toml_edit::DocumentMut = text.parse().unwrap();
assert_eq!(doc["hooks"].as_array_of_tables().unwrap().len(), 1, "only the user hook should remain");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn hook_present_matches_on_event_matcher_and_command() {
let path = scratch("config.toml");
let matched = HookBinding { event: "PreToolUse".into(), matcher: Some("Bash".into()), command: "host_fixture guard".into() };
reconcile_hooks(&path, std::slice::from_ref(&matched)).unwrap();
let doc: toml_edit::DocumentMut = std::fs::read_to_string(&path).unwrap().parse().unwrap();
let arr = doc["hooks"].as_array_of_tables().unwrap();
assert!(hook_present(arr, "PreToolUse", &matched), "identical entry must count as present");
let other_matcher = HookBinding { matcher: Some("Edit".into()), ..matched.clone() };
assert!(!hook_present(arr, "PreToolUse", &other_matcher), "a differing matcher must not match");
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 servers = [server("ez-fixture", "host_fixture")];
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), crate::agents::BackendState::Absent));
assert_eq!(mcpjson::reconcile(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), Outcome::Installed);
assert!(matches!(
mcpjson::probe(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(),
crate::agents::BackendState::Healthy
));
std::fs::write(&path, r#"{"mcpServers":{"ez-fixture":{"command":"tampered","args":[],"env":{}}}}"#).unwrap();
assert!(matches!(
mcpjson::probe(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(),
crate::agents::BackendState::NeedsRepair
));
let rooted = [server("rooted", "${CLAUDE_PLUGIN_ROOT}/bin/leaky")];
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &rooted, ServerShape::plain()).unwrap(), crate::agents::BackendState::Healthy));
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
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::KimiBackend;
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: &["kimi"], 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);
}