use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde_json::{json, Map, Value};
use crate::install::io;
use crate::install::target::MergeOutcome;
use crate::install::verify;
const SENTINEL_KEY: &str = "_pixtuoid";
const CURSOR_EVENTS: &[&str] = &[
"sessionStart",
"preToolUse",
"postToolUse",
"stop",
"sessionEnd",
];
pub fn default_config_path() -> Result<PathBuf> {
io::home_relative_checked(".cursor/hooks.json")
}
pub fn detect_installed() -> bool {
io::home_relative(".cursor").exists()
}
pub fn hook_command(resolved: &Path, _explicit: bool) -> Result<String> {
let p = resolved
.to_str()
.ok_or_else(|| anyhow!("pixtuoid-hook path is non-UTF-8: {}", resolved.display()))?;
crate::install::hook_cmd::shell_hook_command(p, "cursor")
}
pub fn merge_install(content: &str, hook_cmd: &str) -> Result<MergeOutcome> {
let doc = verify::parse_json_or_empty(content)?;
if !doc.is_object() && !doc.is_null() {
anyhow::bail!("hooks.json is valid JSON but not an object — refusing to overwrite");
}
let merged = json_merge_install(doc.clone(), hook_cmd);
let changed = merged != doc;
Ok(MergeOutcome {
content: serde_json::to_string_pretty(&merged)?,
changed,
})
}
pub fn merge_uninstall(content: &str) -> Result<MergeOutcome> {
let doc = verify::parse_json_or_empty(content)?;
let cleaned = verify::flat_json_merge_uninstall(doc.clone(), SENTINEL_KEY);
let changed = cleaned != doc;
Ok(MergeOutcome {
content: serde_json::to_string_pretty(&cleaned)?,
changed,
})
}
fn managed_entry(hook_command: &str) -> Value {
json!({
SENTINEL_KEY: true,
"command": hook_command
})
}
pub fn verify_schema(content: &str) -> crate::install::verify::SchemaParse {
crate::install::verify::flat_json_verify(content, CURSOR_EVENTS, SENTINEL_KEY)
}
fn json_merge_install(doc: Value, hook_command: &str) -> Value {
let mut root: Map<String, Value> = doc.as_object().cloned().unwrap_or_default();
root.entry("version".to_string())
.or_insert_with(|| json!(1));
verify::flat_json_merge_install(
Value::Object(root),
CURSOR_EVENTS,
SENTINEL_KEY,
managed_entry,
hook_command,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn json_merge_uninstall(doc: Value) -> Value {
verify::flat_json_merge_uninstall(doc, SENTINEL_KEY)
}
#[test]
fn install_creates_flat_entries_for_all_events_with_version() {
let doc = json_merge_install(json!({}), "PIXTUOID_SOURCE=cursor '/opt/pixtuoid-hook'");
assert_eq!(doc["version"], json!(1), "Cursor requires a version field");
let hooks = doc.get("hooks").and_then(|v| v.as_object()).unwrap();
for ev in CURSOR_EVENTS {
let arr = hooks.get(*ev).and_then(|v| v.as_array()).unwrap();
assert_eq!(arr.len(), 1, "event {ev}");
let entry = &arr[0];
assert_eq!(
entry["command"].as_str().unwrap(),
"PIXTUOID_SOURCE=cursor '/opt/pixtuoid-hook'"
);
assert!(entry[SENTINEL_KEY].as_bool().unwrap());
assert!(
entry.get("hooks").is_none() && entry.get("type").is_none(),
"must not write CC-style nested groups"
);
}
}
#[test]
fn install_preserves_existing_version() {
let doc = json_merge_install(json!({"version": 2}), "/x");
assert_eq!(
doc["version"],
json!(2),
"must not clobber a user's version"
);
}
#[test]
fn install_is_idempotent_and_replaces_across_paths() {
let a = json_merge_install(json!({}), "PIXTUOID_SOURCE=cursor '/opt/a/pixtuoid-hook'");
let b = json_merge_install(a.clone(), "PIXTUOID_SOURCE=cursor '/opt/a/pixtuoid-hook'");
assert_eq!(a, b, "same command re-install is a no-op");
let c = json_merge_install(a, "PIXTUOID_SOURCE=cursor '/opt/b/pixtuoid-hook'");
for ev in CURSOR_EVENTS {
assert_eq!(
c["hooks"][*ev].as_array().unwrap().len(),
1,
"event {ev} duplicated on path change"
);
}
}
#[test]
fn install_preserves_user_entries() {
let initial = json!({
"version": 1,
"hooks": {"preToolUse": [ { "command": "my-guard.sh" } ]},
"other": "setting"
});
let merged = json_merge_install(initial, "/x");
let arr = merged["hooks"]["preToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["command"], json!("my-guard.sh"));
assert_eq!(merged["other"], json!("setting"));
}
#[test]
fn uninstall_removes_only_managed_entries_and_empty_maps() {
let installed = json_merge_install(
json!({"hooks": {"preToolUse": [ { "command": "my-guard.sh" } ]}}),
"/x",
);
let cleaned = json_merge_uninstall(installed);
let arr = cleaned["hooks"]["preToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["command"], json!("my-guard.sh"));
for ev in CURSOR_EVENTS.iter().filter(|e| **e != "preToolUse") {
assert!(
cleaned["hooks"].get(*ev).is_none(),
"event {ev} should be dropped once empty"
);
}
assert_eq!(cleaned["version"], json!(1));
}
#[test]
fn uninstall_all_managed_drops_hooks_but_keeps_version() {
let installed = json_merge_install(json!({}), "/x");
let cleaned = json_merge_uninstall(installed);
assert!(cleaned.get("hooks").is_none(), "got {cleaned}");
assert_eq!(cleaned["version"], json!(1), "got {cleaned}");
}
#[test]
fn uninstall_preserves_a_users_version_only_file() {
let installed = json_merge_install(json!({"version": 3}), "/x");
let cleaned = json_merge_uninstall(installed);
assert_eq!(
cleaned,
json!({"version": 3}),
"a user's version must not be lost on uninstall: {cleaned}"
);
}
#[test]
fn merge_install_idempotent_reports_unchanged() {
let first = merge_install("", "/x").unwrap();
assert!(first.changed);
let second = merge_install(&first.content, "/x").unwrap();
assert!(!second.changed, "second install is a semantic no-op");
}
#[test]
fn merge_uninstall_no_pixtuoid_hooks_reports_unchanged() {
let user = r#"{ "version": 1, "hooks": { "stop": [ { "command": "notify done" } ] } }"#;
let out = merge_uninstall(user).unwrap();
assert!(!out.changed, "no managed entries → semantic no-op");
}
#[test]
fn merge_install_rejects_valid_json_that_is_not_an_object() {
assert!(merge_install("[1, 2, 3]", "/x").is_err());
assert!(merge_install("42", "/x").is_err());
}
#[test]
fn merge_install_rejects_invalid_json() {
assert!(merge_install("{not json", "/x").is_err());
}
#[test]
fn install_coerces_non_object_hooks_and_non_array_events() {
let doc = json_merge_install(json!({"hooks": "garbage"}), "/x");
assert!(doc["hooks"].is_object());
let doc = json_merge_install(json!({"hooks": {"stop": 42}}), "/x");
assert_eq!(doc["hooks"]["stop"].as_array().unwrap().len(), 1);
}
#[cfg(unix)]
#[test]
fn hook_command_stamps_source_and_quotes() {
let cmd = hook_command(Path::new("/Users/Jane Doe/bin/pixtuoid-hook"), false).unwrap();
assert_eq!(
cmd,
"PIXTUOID_SOURCE=cursor '/Users/Jane Doe/bin/pixtuoid-hook'"
);
}
#[test]
#[cfg(windows)]
fn hook_command_emits_bare_exec_form_with_source_flag_on_windows() {
let cmd = hook_command(Path::new(r"C:\tools\pixtuoid-hook.exe"), false).unwrap();
assert_eq!(cmd, r"C:\tools\pixtuoid-hook.exe --source cursor");
}
#[test]
#[cfg(windows)]
fn hook_command_rejects_cmd_unsafe_path_on_windows() {
assert!(hook_command(Path::new(r"C:\Program Files\pixtuoid-hook.exe"), false).is_err());
}
#[test]
#[cfg(unix)]
fn hook_command_errors_on_non_utf8_path() {
use std::os::unix::ffi::OsStrExt;
let bad = Path::new(std::ffi::OsStr::from_bytes(b"/x/\xff/pixtuoid-hook"));
assert!(hook_command(bad, false).is_err());
}
#[test]
fn every_registered_cursor_event_decodes() {
use pixtuoid_core::source::decoder::decode_hook_payload;
for ev in CURSOR_EVENTS {
let payload = serde_json::json!({
"hook_event_name": ev,
"cwd": "/repo",
"_pixtuoid_source": "cursor",
});
assert!(
decode_hook_payload(payload).is_ok(),
"registered Cursor hook {ev:?} has no decoder arm — it would bail \
as unsupported. Add an arm in pixtuoid-core source/cursor.rs."
);
}
}
}