use anyhow::{Context, Result};
use serde_json::{json, Value};
use super::CCD_CLAUDE_COMMAND_PREFIX;
const CCD_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
(
"SessionStart",
"on-session-start",
"startup|resume|clear|compact",
),
("UserPromptSubmit", "before-prompt-build", "*"),
("PreCompact", "on-compaction-notice", "*"),
("Stop", "on-agent-end", "*"),
("SessionEnd", "on-session-end", "*"),
];
pub(crate) fn merge_ccd_hooks_into_settings(mut settings: Value) -> Option<Value> {
let hooks_entry = settings
.as_object_mut()?
.entry("hooks")
.or_insert_with(|| Value::Object(Default::default()));
let hooks_obj = hooks_entry.as_object_mut()?; scrub_retired_task_completed_event(hooks_obj);
for (event, hook_arg, matcher) in CCD_MANAGED_EVENTS {
let event_entry = hooks_obj
.entry(*event)
.or_insert_with(|| Value::Array(Vec::new()));
let event_array = match event_entry.as_array_mut() {
Some(arr) => arr,
None => return None, };
let matcher_idx = event_array.iter().position(|entry| {
entry
.get("matcher")
.and_then(Value::as_str)
.map(|s| s == *matcher)
.unwrap_or(false)
});
let matcher_entry = match matcher_idx {
Some(idx) => &mut event_array[idx],
None => {
event_array.push(json!({ "matcher": matcher, "hooks": [] }));
event_array.last_mut().unwrap()
}
};
let hooks_inside = match matcher_entry.get_mut("hooks").and_then(Value::as_array_mut) {
Some(arr) => arr,
None => return None,
};
hooks_inside.retain(|entry| !is_ccd_managed_or_legacy_python_hook(entry));
hooks_inside.push(json!({
"type": "command",
"command": format!("{CCD_CLAUDE_COMMAND_PREFIX}{hook_arg}"),
}));
}
Some(settings)
}
fn is_ccd_managed_or_legacy_python_hook(entry: &Value) -> bool {
let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
cmd.starts_with(CCD_CLAUDE_COMMAND_PREFIX) || cmd.contains("ccd-hook.py")
}
fn scrub_retired_task_completed_event(hooks_obj: &mut serde_json::Map<String, Value>) {
let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
return;
};
let Some(event_array) = task_completed.as_array_mut() else {
return;
};
event_array.retain_mut(|entry| {
let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
return true;
};
hooks_inside.retain(|hook| !is_ccd_managed_or_legacy_python_hook(hook));
!hooks_inside.is_empty()
});
if event_array.is_empty() {
hooks_obj.remove("TaskCompleted");
}
}
pub(crate) fn merge_settings_file(path: &std::path::Path, force: bool) -> Result<Option<String>> {
let existing = match std::fs::read_to_string(path) {
Ok(s) => Some(s),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(err).context(format!("reading {}", path.display())),
};
let parsed = match existing {
None => Value::Object(Default::default()),
Some(body) => match serde_json::from_str::<Value>(&body) {
Ok(v) => v,
Err(_) if force => Value::Object(Default::default()),
Err(_) => return Ok(None),
},
};
let root = if parsed.is_object() {
parsed
} else if force {
Value::Object(Default::default())
} else {
return Ok(None);
};
let merged = match merge_ccd_hooks_into_settings(root) {
Some(v) => v,
None if force => {
merge_ccd_hooks_into_settings(Value::Object(Default::default()))
.expect("empty object is always a valid base")
}
None => return Ok(None),
};
let rendered =
serde_json::to_string_pretty(&merged).context("serializing merged settings.json")?;
Ok(Some(rendered))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_into_empty_object_produces_all_ccd_events() {
let merged =
merge_ccd_hooks_into_settings(json!({})).expect("merge should succeed on empty");
let hooks = &merged["hooks"];
for (event, _, _) in CCD_MANAGED_EVENTS {
assert!(hooks.get(event).is_some(), "missing event {event}");
}
assert!(hooks.get("TaskCompleted").is_none());
}
#[test]
fn preserves_unrelated_top_level_keys() {
let input = json!({
"theme": "dark",
"hooks": {
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
}
});
let merged = merge_ccd_hooks_into_settings(input).expect("merge should succeed");
assert_eq!(merged["theme"], Value::String("dark".to_owned()));
let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
assert_eq!(stop_hooks.len(), 2);
assert_eq!(
stop_hooks[0]["command"],
Value::String("echo user".to_owned())
);
assert!(stop_hooks[1]["command"]
.as_str()
.unwrap()
.starts_with(CCD_CLAUDE_COMMAND_PREFIX));
}
#[test]
fn idempotent_across_two_merges() {
let input = json!({ "theme": "dark" });
let once = merge_ccd_hooks_into_settings(input).expect("first merge");
let twice = merge_ccd_hooks_into_settings(once.clone()).expect("second merge");
assert_eq!(
serde_json::to_string_pretty(&once).unwrap(),
serde_json::to_string_pretty(&twice).unwrap(),
);
}
#[test]
fn replaces_pre_v1_python_entries() {
let input = json!({
"hooks": {
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-agent-end"
}]
}]
}
});
let merged = merge_ccd_hooks_into_settings(input).expect("merge should succeed");
let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
assert_eq!(stop_hooks.len(), 1);
assert!(stop_hooks[0]["command"]
.as_str()
.unwrap()
.starts_with(CCD_CLAUDE_COMMAND_PREFIX));
}
#[test]
fn removes_retired_task_completed_python_bridge_entries() {
let input = json!({
"hooks": {
"TaskCompleted": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-agent-end"
}]
}],
"Stop": [{
"matcher": "*",
"hooks": []
}]
}
});
let merged = merge_ccd_hooks_into_settings(input).expect("merge should succeed");
assert!(
merged["hooks"].get("TaskCompleted").is_none(),
"retired CCD TaskCompleted entry should be removed: {merged}"
);
}
#[test]
fn preserves_user_owned_task_completed_entries() {
let input = json!({
"hooks": {
"TaskCompleted": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "echo user-task-completed"
}]
}]
}
});
let merged = merge_ccd_hooks_into_settings(input).expect("merge should succeed");
let hooks = merged["hooks"]["TaskCompleted"][0]["hooks"]
.as_array()
.unwrap();
assert_eq!(hooks[0]["command"], "echo user-task-completed");
}
#[test]
fn returns_none_when_hooks_is_not_an_object() {
let input = json!({ "hooks": [1, 2, 3] });
assert!(merge_ccd_hooks_into_settings(input).is_none());
}
#[test]
fn returns_none_when_managed_event_is_not_an_array() {
let input = json!({ "hooks": { "Stop": { "unexpected": "shape" } } });
assert!(merge_ccd_hooks_into_settings(input).is_none());
}
#[test]
fn force_rebuilds_when_input_is_malformed_top_level() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
std::fs::write(&path, "[1, 2, 3]").expect("write");
let out = merge_settings_file(&path, true).expect("ok").expect("some");
let parsed: Value = serde_json::from_str(&out).expect("parseable");
assert!(
parsed["hooks"]["Stop"].is_array(),
"rebuild should populate CCD events"
);
}
#[test]
fn no_force_returns_none_when_file_is_malformed() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
std::fs::write(&path, "[1, 2, 3]").expect("write");
assert!(merge_settings_file(&path, false).expect("ok").is_none());
}
}