use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::core::hook_state::marker::OpenlatchMarker;
pub fn detect() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let claude_dir = home.join(".claude");
if claude_dir.is_dir() {
Some(claude_dir)
} else {
None
}
}
pub fn settings_json_path(claude_dir: &Path) -> PathBuf {
claude_dir.join("settings.json")
}
pub fn build_hook_entry(
event_type: &str,
_port: u16,
token_env_var: &str,
binary_path: &Path,
marker: &OpenlatchMarker,
) -> Value {
let wire_event = pascal_to_snake(event_type);
let binary_str = binary_path.display().to_string();
let command = format!(r#""{binary_str}" --agent claude-code --event {wire_event}"#);
let hook_inner = json!({
"type": "command",
"command": command,
"timeout": 10,
"allowedEnvVars": [token_env_var, "OPENLATCH_PORT"]
});
let marker_value =
serde_json::to_value(marker).expect("OpenlatchMarker is always serializable");
if event_type == "PreToolUse" {
json!({
"matcher": "",
"_openlatch": marker_value,
"hooks": [hook_inner]
})
} else {
json!({
"_openlatch": marker_value,
"hooks": [hook_inner]
})
}
}
fn pascal_to_snake(event: &str) -> &'static str {
match event {
"PreToolUse" => "pre_tool_use",
"PostToolUse" => "post_tool_use",
"UserPromptSubmit" => "user_prompt_submit",
"Notification" => "notification",
"Stop" => "stop",
"SubagentStop" => "subagent_stop",
"PreCompact" => "pre_compact",
"SessionStart" => "session_start",
"SessionEnd" => "session_end",
_ => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_bin() -> PathBuf {
PathBuf::from("/opt/openlatch/bin/openlatch-hook")
}
fn test_marker() -> OpenlatchMarker {
OpenlatchMarker {
v: 1,
id: "test-marker-id".into(),
installed_at: chrono::DateTime::parse_from_rfc3339("2026-04-16T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
hmac: Some("test-hmac".into()),
}
}
#[test]
fn test_build_hook_entry_pre_tool_use_has_matcher() {
let entry = build_hook_entry(
"PreToolUse",
7443,
"OPENLATCH_TOKEN",
&test_bin(),
&test_marker(),
);
assert_eq!(entry["matcher"], "");
assert!(
entry["_openlatch"].is_object(),
"_openlatch must be an object marker"
);
assert_eq!(entry["_openlatch"]["v"], 1);
assert_eq!(entry["_openlatch"]["id"], "test-marker-id");
let cmd = entry["hooks"][0]["command"].as_str().unwrap();
assert!(cmd.contains("openlatch-hook"));
assert!(cmd.contains("--agent claude-code"));
assert!(cmd.contains("--event pre_tool_use"));
}
#[test]
fn test_build_hook_entry_user_prompt_submit_no_matcher() {
let entry = build_hook_entry(
"UserPromptSubmit",
7443,
"OPENLATCH_TOKEN",
&test_bin(),
&test_marker(),
);
assert!(
entry.get("matcher").is_none(),
"UserPromptSubmit must not have matcher field"
);
assert!(entry["_openlatch"].is_object());
let cmd = entry["hooks"][0]["command"].as_str().unwrap();
assert!(cmd.contains("--event user_prompt_submit"));
}
#[test]
fn test_build_hook_entry_stop_no_matcher() {
let entry = build_hook_entry("Stop", 7443, "OPENLATCH_TOKEN", &test_bin(), &test_marker());
assert!(entry.get("matcher").is_none());
assert!(entry["_openlatch"].is_object());
let cmd = entry["hooks"][0]["command"].as_str().unwrap();
assert!(cmd.contains("--event stop"));
}
#[test]
fn test_build_hook_entry_uses_command_type() {
let entry = build_hook_entry(
"PreToolUse",
7443,
"OPENLATCH_TOKEN",
&test_bin(),
&test_marker(),
);
assert_eq!(
entry["hooks"][0]["type"], "command",
"post-migration hooks must use command type (Mode A), not http"
);
}
#[test]
fn test_build_hook_entry_never_writes_token_value() {
let entry = build_hook_entry(
"PreToolUse",
7443,
"OPENLATCH_TOKEN",
&test_bin(),
&test_marker(),
);
let json = serde_json::to_string(&entry).unwrap();
assert!(
!json.contains("Bearer "),
"rendered hook must not contain an inline bearer prefix"
);
assert!(entry["hooks"][0]["allowedEnvVars"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "OPENLATCH_TOKEN"));
}
#[test]
fn test_build_hook_entry_openlatch_marker_is_object() {
for event_type in &["PreToolUse", "UserPromptSubmit", "Stop", "SessionEnd"] {
let entry = build_hook_entry(
event_type,
7443,
"OPENLATCH_TOKEN",
&test_bin(),
&test_marker(),
);
assert!(
entry["_openlatch"].is_object(),
"{event_type} entry must carry _openlatch object marker"
);
assert_eq!(entry["_openlatch"]["v"], 1);
}
}
#[test]
fn test_pascal_to_snake_covers_canonical_vocabulary() {
assert_eq!(pascal_to_snake("PreToolUse"), "pre_tool_use");
assert_eq!(pascal_to_snake("PostToolUse"), "post_tool_use");
assert_eq!(pascal_to_snake("UserPromptSubmit"), "user_prompt_submit");
assert_eq!(pascal_to_snake("Notification"), "notification");
assert_eq!(pascal_to_snake("Stop"), "stop");
assert_eq!(pascal_to_snake("SubagentStop"), "subagent_stop");
assert_eq!(pascal_to_snake("PreCompact"), "pre_compact");
assert_eq!(pascal_to_snake("SessionStart"), "session_start");
assert_eq!(pascal_to_snake("SessionEnd"), "session_end");
}
}