use anyhow::{Context, Result};
use serde_json::{json, Map, Value};
use std::path::PathBuf;
use super::HookEvent;
const HOOK_EVENTS: &[HookEvent] = &[
HookEvent::UserPromptSubmit,
HookEvent::PreToolUse,
HookEvent::PostToolUse,
HookEvent::Stop,
];
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))?;
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()))
}
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)
})
}
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()))
}