use serde_json::{json, Map, Value};
pub const SENTINEL_KEY: &str = "_ascii_agents";
pub const EVENTS: &[&str] = &[
"SessionStart",
"PreToolUse",
"PostToolUse",
"Notification",
"SessionEnd",
];
pub fn merge_install(doc: Value, hook_command: &str) -> Value {
let mut root: Map<String, Value> = doc.as_object().cloned().unwrap_or_default();
let hooks = root
.entry("hooks".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let hooks_obj = match hooks.as_object_mut() {
Some(o) => o,
None => {
*hooks = Value::Object(Map::new());
hooks.as_object_mut().expect("just stored Value::Object")
}
};
for ev in EVENTS {
let list = hooks_obj
.entry((*ev).to_string())
.or_insert_with(|| Value::Array(vec![]));
let arr = match list.as_array_mut() {
Some(a) => a,
None => {
*list = Value::Array(vec![]);
list.as_array_mut().expect("just stored Value::Array")
}
};
arr.retain(|entry| entry.get(SENTINEL_KEY).and_then(|v| v.as_bool()) != Some(true));
arr.push(json!({
SENTINEL_KEY: true,
"matcher": ".*",
"hooks": [
{ "type": "command", "command": hook_command }
]
}));
}
Value::Object(root)
}
pub fn merge_uninstall(mut doc: Value) -> Value {
let Some(root) = doc.as_object_mut() else {
return doc;
};
let Some(Value::Object(hooks_obj)) = root.get_mut("hooks") else {
return doc;
};
for (_ev, list) in hooks_obj.iter_mut() {
if let Some(arr) = list.as_array_mut() {
arr.retain(|entry| entry.get(SENTINEL_KEY).and_then(|v| v.as_bool()) != Some(true));
}
}
let to_remove: Vec<String> = hooks_obj
.iter()
.filter_map(|(k, v)| match v.as_array() {
Some(a) if a.is_empty() => Some(k.clone()),
_ => None,
})
.collect();
for k in to_remove {
hooks_obj.remove(&k);
}
if hooks_obj.is_empty() {
root.remove("hooks");
}
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_creates_entries_for_all_events() {
let doc = merge_install(json!({}), "/usr/local/bin/ascii-agents-hook");
let hooks = doc.get("hooks").and_then(|v| v.as_object()).unwrap();
for ev in EVENTS {
let arr = hooks.get(*ev).and_then(|v| v.as_array()).unwrap();
assert_eq!(arr.len(), 1, "event {ev}");
assert_eq!(arr[0][SENTINEL_KEY], json!(true));
assert_eq!(
arr[0]["hooks"][0]["command"],
json!("/usr/local/bin/ascii-agents-hook")
);
}
}
#[test]
fn install_is_idempotent() {
let d1 = merge_install(json!({}), "/x");
let d2 = merge_install(d1.clone(), "/x");
assert_eq!(d1, d2);
}
#[test]
fn install_preserves_unrelated_entries() {
let initial = json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Write", "hooks": [{"type":"command","command":"/other"}] }
]
},
"theme": "dark"
});
let merged = merge_install(initial, "/x");
let arr = merged["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(merged["theme"], json!("dark"));
}
#[test]
fn uninstall_removes_sentinel_entries_only() {
let installed = merge_install(
json!({
"hooks": { "PreToolUse": [
{ "matcher": "Write", "hooks": [{"type":"command","command":"/other"}] }
]}
}),
"/x",
);
let cleaned = merge_uninstall(installed);
let arr = cleaned["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0][SENTINEL_KEY], json!(null));
}
#[test]
fn uninstall_drops_empty_hooks_map() {
let installed = merge_install(json!({}), "/x");
let cleaned = merge_uninstall(installed);
assert!(cleaned.get("hooks").is_none(), "got {cleaned}");
}
}