ccd-cli 1.0.0-beta.4

Bootstrap and validate Continuous Context Development repositories
//! Additive merge for `.claude/settings.json` — §4.2.

use anyhow::{Context, Result};
use serde_json::{json, Value};

use super::CCD_CLAUDE_COMMAND_PREFIX;

/// Each CCD-managed Claude event: (Claude-side event name, CCD --hook argument, matcher pattern).
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", "*"),
];

/// Merge CCD-managed hook entries into the given settings JSON. Returns `Some(updated)`
/// on success, or `None` if the existing top-level structure is not what Claude Code
/// expects — specifically if `hooks` exists but is not an object, or a managed event
/// value exists but is not an array. Callers should treat `None` as malformed input.
///
/// Algorithm (spec §4.2):
/// 1. Ensure `hooks` object exists at top level. Preserve all other top-level keys.
/// 2. For each CCD-managed event, ensure a matcher entry exists. Within its `hooks`
///    array, remove any entry whose `command` starts with the CCD sentinel prefix OR
///    references the pre-v1 `ccd-hook.py` bridge (migration per §4.2 fine-print).
///    Append the current CCD-managed entry.
/// 3. Non-CCD entries within the same matcher are preserved in original order.
/// 4. The retired CCD-managed `TaskCompleted` entry is removed when it still
///    points at a CCD command or the pre-v1 Python bridge. User-owned
///    `TaskCompleted` hooks are preserved.
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()?; // malformed if not an object
    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, // managed event exists but is not an array → malformed
        };

        // Find a matcher entry whose pattern equals ours; create one if none exists.
        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()
            }
        };

        // If the matcher entry exists but its `hooks` field is not an array, treat as malformed.
        let hooks_inside = match matcher_entry.get_mut("hooks").and_then(Value::as_array_mut) {
            Some(arr) => arr,
            None => return None,
        };

        // Remove CCD-managed entries (by sentinel prefix) AND any entry referencing the
        // legacy Python bridge (pre-v1 migration per §4.2 fine-print).
        hooks_inside.retain(|entry| !is_ccd_managed_or_legacy_python_hook(entry));

        // Append the current CCD-managed 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");
    }
}

/// Parse-merge-write the `.claude/settings.json` file at `path`. Returns `Ok(Some(written))`
/// when the merge ran successfully; `Ok(None)` when the existing file is malformed and the
/// caller should warn + skip (unless `force` is set, in which case a fresh merge is used);
/// `Err(_)` only for filesystem errors.
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 => {
            // Shape mismatch with --force: rebuild from scratch.
            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() {
        // Pathological user file: hooks is an array instead of 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() {
        // Pathological user file: Stop is an object instead of 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() {
        // Non-object root + force → merge_settings_file rebuilds fresh.
        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");
        // Without --force, malformed top-level → Ok(None) so caller skips.
        assert!(merge_settings_file(&path, false).expect("ok").is_none());
    }
}