use std::collections::BTreeMap;
use std::path::PathBuf;
use serde_json::Value;
use super::{hook_is_portable, map_event, portable_names, reconcile_settings, remove_from_settings};
use crate::agents::BackendState;
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-augment-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() }
}
fn read(path: &PathBuf) -> Value {
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
#[test]
fn portable_names_excludes_claude_plugin_root_servers() {
let servers = [server("ez-fixture", "host_fixture"), server("rooted", "${CLAUDE_PLUGIN_ROOT}/bin/leaky")];
assert_eq!(portable_names(&servers), vec!["ez-fixture"]);
}
#[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_covers_every_event_augment_hosts() {
assert_eq!(map_event("SessionStart"), Some("SessionStart"));
assert_eq!(map_event("SessionEnd"), Some("SessionEnd"));
assert_eq!(map_event("PreToolUse"), Some("PreToolUse"));
assert_eq!(map_event("PostToolUse"), Some("PostToolUse"));
assert_eq!(map_event("Stop"), Some("Stop"));
assert_eq!(map_event("Notification"), Some("Notification"));
assert_eq!(map_event("UserPromptSubmit"), Some("PromptSubmit"));
assert_eq!(map_event("PreCompact"), None);
assert_eq!(map_event("SubagentStop"), None);
const ACCEPTED: [&str; 7] = ["PreToolUse", "PostToolUse", "Stop", "SessionStart", "SessionEnd", "Notification", "PromptSubmit"];
for cc in [
"SessionStart",
"SessionEnd",
"PreToolUse",
"PostToolUse",
"Stop",
"Notification",
"UserPromptSubmit",
"PreCompact",
"SubagentStop",
] {
if let Some(mapped) = map_event(cc) {
assert!(ACCEPTED.contains(&mapped), "{cc} maps to `{mapped}`, which augment's validator rejects");
}
}
}
#[test]
fn reconcile_settings_writes_mcp_and_hooks_in_one_file_and_second_reconcile_noops() {
let path = scratch("settings.json");
let servers = [server("ez-fixture", "host_fixture")];
let matched = HookBinding { event: "PreToolUse".into(), matcher: Some("Bash".into()), command: "host_fixture guard".into() };
let hooks = [
hook("SessionStart", "host_fixture self-heal"),
matched,
hook("UserPromptSubmit", "host_fixture check-restart"),
hook("PreCompact", "host_fixture squeeze"),
];
let changed = reconcile_settings(&path, &servers, &hooks).unwrap();
assert!(changed, "first reconcile must write");
let root = read(&path);
let entry = &root["mcpServers"]["ez-fixture"];
assert_eq!(entry["command"], Value::from("host_fixture"), "mcp command wrong:\n{root:#}");
assert!(entry.get("type").is_none(), "Plain shape must not emit a `type` key:\n{root:#}");
let session = root["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(session[0]["hooks"][0]["command"], Value::from("host_fixture self-heal"), "SessionStart command missing:\n{root:#}");
assert_eq!(session[0]["hooks"][0]["type"], Value::from("command"), "hook handler must be a command type:\n{root:#}");
let pretool = root["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pretool[0]["matcher"], Value::from("Bash"), "matcher missing for the matched hook:\n{root:#}");
let prompt = root["hooks"]["PromptSubmit"].as_array().unwrap();
assert_eq!(prompt[0]["hooks"][0]["command"], Value::from("host_fixture check-restart"), "PromptSubmit command missing:\n{root:#}");
assert!(root["hooks"].get("UserPromptSubmit").is_none(), "CC's own event name must never be written:\n{root:#}");
assert!(root["hooks"].get("PreCompact").is_none(), "PreCompact must not be written:\n{root:#}");
assert!(!std::fs::read_to_string(&path).unwrap().contains("squeeze"), "the unmapped hook's command leaked in");
let bytes = std::fs::read(&path).unwrap();
let changed = reconcile_settings(&path, &servers, &hooks).unwrap();
assert!(!changed, "second reconcile must be a NoOp");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "second reconcile changed bytes");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn reconcile_settings_skips_non_portable_and_remove_leaves_a_same_command_survivor() {
let path = scratch("settings.json");
let rooted_server = server("rooted", "${CLAUDE_PLUGIN_ROOT}/bin/leaky");
let rooted_hook = hook("SessionStart", "${CLAUDE_PLUGIN_ROOT}/hooks/self-heal.sh");
let changed = reconcile_settings(&path, std::slice::from_ref(&rooted_server), std::slice::from_ref(&rooted_hook)).unwrap();
assert!(!changed, "non-portable entries must not be written");
assert!(!path.exists(), "reconcile_settings must not create a file for zero writable entries");
let seed = format!(
r#"{{"mcpServers":{{"rooted":{{"command":"user-owned"}}}},"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}"}}]}}]}}}}"#,
rooted_hook.command
);
std::fs::write(&path, &seed).unwrap();
let before = std::fs::read_to_string(&path).unwrap();
let removed =
remove_from_settings(&path, &portable_names(std::slice::from_ref(&rooted_server)), std::slice::from_ref(&rooted_hook)).unwrap();
assert!(!removed, "remove must not touch entries it never wrote");
assert_eq!(std::fs::read_to_string(&path).unwrap(), before, "seeded entries survived byte-for-byte");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn remove_from_settings_strips_ours_keeps_user_entries() {
let path = scratch("settings.json");
let seed = r#"{
"theme": "dark",
"mcpServers": { "theirs": { "command": "their-server", "args": [] } },
"hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "their-session-hook" }] }] }
}"#;
std::fs::write(&path, seed).unwrap();
let servers = [server("ez-fixture", "host_fixture")];
let hooks = [hook("SessionStart", "host_fixture self-heal")];
assert!(reconcile_settings(&path, &servers, &hooks).unwrap(), "our entries should be added");
let root = read(&path);
assert!(root["mcpServers"]["ez-fixture"].is_object() && root["mcpServers"]["theirs"].is_object(), "both servers coexist");
assert_eq!(root["hooks"]["SessionStart"].as_array().unwrap().len(), 2, "both SessionStart hook groups coexist");
assert!(remove_from_settings(&path, &portable_names(&servers), &hooks).unwrap(), "our entries should be removed");
let root = read(&path);
assert!(root["mcpServers"].get("ez-fixture").is_none(), "our server survived removal:\n{root:#}");
assert!(root["mcpServers"]["theirs"].is_object(), "the user's server was removed:\n{root:#}");
assert_eq!(root["theme"], Value::from("dark"), "an unrelated top-level key was clobbered:\n{root:#}");
let session = root["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(session.len(), 1, "exactly the user's hook group remains:\n{root:#}");
assert_eq!(session[0]["hooks"][0]["command"], Value::from("their-session-hook"), "the user's hook was removed:\n{root:#}");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn mcp_probe_classifies_absent_healthy_and_needs_repair() {
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!(reconcile_settings(&path, &servers, &[]).unwrap());
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), 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(), BackendState::NeedsRepair));
let rooted = [server("rooted", "${CLAUDE_PLUGIN_ROOT}/bin/leaky")];
assert!(matches!(mcpjson::probe(&path, &["mcpServers"], &rooted, ServerShape::plain()).unwrap(), BackendState::Healthy));
let fresh = scratch("settings.json");
assert_eq!(mcpjson::reconcile(&fresh, &["mcpServers"], &servers, ServerShape::plain()).unwrap(), Outcome::Installed);
std::fs::remove_dir_all(fresh.parent().unwrap()).ok();
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::AugmentBackend;
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: &["augment"], 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);
}