use std::path::Path;
use anyhow::{Context, Result};
use serde_json::{json, Map, Value};
use super::Installed;
pub const SETTINGS: &str = ".cursor/hooks.json";
const EVENTS: &[&str] = &["preToolUse"];
fn entry() -> Value {
json!({
"command": "ralon hook check",
"type": "command",
"failClosed": true,
"timeout": 10
})
}
fn is_ours(candidate: &Value) -> bool {
candidate
.get("command")
.and_then(Value::as_str)
.is_some_and(|command| command.contains("ralon hook check"))
}
pub fn install(root: &Path, dry_run: bool) -> Result<Installed> {
let path = root.join(SETTINGS);
let mut settings: Value = if path.is_file() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON, so it will not be modified",
path.display()
)
})?
} else {
json!({ "version": 1 })
};
let Some(root_object) = settings.as_object_mut() else {
anyhow::bail!("{} does not contain a JSON object", path.display());
};
root_object.entry("version").or_insert_with(|| json!(1));
let events = root_object
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
let Some(events) = events.as_object_mut() else {
anyhow::bail!("{}: `hooks` is not an object", path.display());
};
let mut replaced = false;
for event in EVENTS {
let list = events
.entry(*event)
.or_insert_with(|| Value::Array(Vec::new()));
let Some(list) = list.as_array_mut() else {
anyhow::bail!("{}: `hooks.{event}` is not an array", path.display());
};
match list.iter().position(is_ours) {
Some(index) => {
list[index] = entry();
replaced = true;
}
None => list.push(entry()),
}
}
let rendered = format!("{}\n", serde_json::to_string_pretty(&settings)?);
if dry_run {
print!("{rendered}");
} else {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&path, rendered)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(Installed { path, replaced })
}