asurada 0.3.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
//! `~/.claude/settings.json` 의 hooks 섹션 자동 등록/제거.
//!
//! Claude Code 가 settings 를 읽는 곳에 우리의 hook 명령을 끼워 넣는다.
//! 자기 항목인지 식별은 command 문자열에 `"asurada hook check"` 가
//! 포함되는지로 판단 — 단순 + 깨지지 않음.

use anyhow::{Context, Result};
use serde_json::{json, Map, Value};
use std::path::PathBuf;

use super::HookEvent;

/// 등록할 이벤트들. SessionStart/End 는 Phase 2+ 에서 켤 예정이라 지금은 제외.
const HOOK_EVENTS: &[HookEvent] = &[
    HookEvent::UserPromptSubmit,
    HookEvent::PreToolUse,
    HookEvent::PostToolUse,
    HookEvent::Stop,
];

/// 우리가 만든 entry 인지 판별하는 sentinel substring.
/// 사용자가 다른 도구로 만든 hook 항목과 충돌하지 않게 한다.
const COMMAND_MARKER: &str = "asurada hook check";

pub fn settings_path() -> Result<PathBuf> {
    Ok(dirs::home_dir()
        .context("home directory not found")?
        .join(".claude/settings.json"))
}

pub fn install() -> Result<InstallReport> {
    let path = settings_path()?;
    let mut root = read_settings(&path)?;
    let bin = current_asurada_bin()?;

    if !root.is_object() {
        anyhow::bail!("{} 는 JSON 객체여야 합니다.", path.display());
    }

    let hooks = root
        .as_object_mut()
        .unwrap()
        .entry("hooks")
        .or_insert_with(|| Value::Object(Map::new()));
    let hooks_obj = hooks
        .as_object_mut()
        .context("`hooks` 는 JSON 객체여야 합니다.")?;

    let mut installed = Vec::new();
    for &event in HOOK_EVENTS {
        let key = event.as_settings_key();
        let arr = hooks_obj
            .entry(key)
            .or_insert_with(|| Value::Array(vec![]))
            .as_array_mut()
            .with_context(|| format!("`hooks.{}` 는 배열이어야 합니다.", key))?;

        // 기존 asurada 항목 제거 (재설치 시 중복 방지).
        arr.retain(|item| !is_asurada_entry(item));

        arr.push(json!({
            "matcher": "*",
            "hooks": [{
                "type": "command",
                "command": format!("{} hook check {}", bin, event.as_cli_arg()),
            }],
        }));
        installed.push(key.to_string());
    }

    write_settings(&path, &root)?;
    Ok(InstallReport {
        path,
        bin,
        events: installed,
    })
}

pub fn uninstall() -> Result<UninstallReport> {
    let path = settings_path()?;
    if !path.exists() {
        return Ok(UninstallReport {
            path,
            removed: vec![],
        });
    }
    let mut root = read_settings(&path)?;
    let mut removed = Vec::new();

    if let Some(hooks_obj) = root
        .as_object_mut()
        .and_then(|m| m.get_mut("hooks"))
        .and_then(|v| v.as_object_mut())
    {
        for &event in HOOK_EVENTS {
            let key = event.as_settings_key();
            if let Some(arr) = hooks_obj.get_mut(key).and_then(|v| v.as_array_mut()) {
                let before = arr.len();
                arr.retain(|item| !is_asurada_entry(item));
                if arr.len() < before {
                    removed.push(key.to_string());
                }
            }
        }
    }

    write_settings(&path, &root)?;
    Ok(UninstallReport { path, removed })
}

pub fn status() -> Result<Vec<String>> {
    let path = settings_path()?;
    if !path.exists() {
        return Ok(vec![]);
    }
    let root = read_settings(&path)?;
    let Some(hooks_obj) = root
        .as_object()
        .and_then(|m| m.get("hooks"))
        .and_then(|v| v.as_object())
    else {
        return Ok(vec![]);
    };
    let mut active = Vec::new();
    for &event in HOOK_EVENTS {
        let key = event.as_settings_key();
        if let Some(arr) = hooks_obj.get(key).and_then(|v| v.as_array()) {
            if arr.iter().any(is_asurada_entry) {
                active.push(key.to_string());
            }
        }
    }
    Ok(active)
}

#[derive(Debug)]
pub struct InstallReport {
    pub path: PathBuf,
    pub bin: String,
    pub events: Vec<String>,
}

#[derive(Debug)]
pub struct UninstallReport {
    pub path: PathBuf,
    pub removed: Vec<String>,
}

fn read_settings(path: &PathBuf) -> Result<Value> {
    if !path.exists() {
        return Ok(json!({}));
    }
    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    if body.trim().is_empty() {
        return Ok(json!({}));
    }
    serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
}

/// Atomic write: temp file + rename. 부분 쓰기로 settings.json 깨지는 것을 막는다.
fn write_settings(path: &PathBuf, value: &Value) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp = path.with_extension("json.asurada.tmp");
    let body = serde_json::to_string_pretty(value)?;
    std::fs::write(&tmp, body).with_context(|| format!("write tmp {}", tmp.display()))?;
    std::fs::rename(&tmp, path).with_context(|| format!("rename → {}", path.display()))?;
    Ok(())
}

fn is_asurada_entry(item: &Value) -> bool {
    let Some(hooks) = item.get("hooks").and_then(|v| v.as_array()) else {
        return false;
    };
    hooks.iter().any(|h| {
        h.get("command")
            .and_then(|v| v.as_str())
            .map(|cmd| cmd.contains(COMMAND_MARKER))
            .unwrap_or(false)
    })
}

/// `asurada hook install` 을 실행한 그 바이너리 자신의 절대 경로를 사용.
/// PATH 에 의존하지 않아 여러 버전이 공존해도 안전.
fn current_asurada_bin() -> Result<String> {
    Ok(std::env::current_exe()
        .ok()
        .and_then(|p| p.to_str().map(String::from))
        .unwrap_or_else(|| "asurada".to_string()))
}