use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::core::hook_state::marker::OpenlatchMarker;
pub(crate) const CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR";
#[cfg(test)]
pub(crate) static CONFIG_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) fn config_dir() -> Option<PathBuf> {
match relocated_dir() {
Some(relocated) => Some(relocated),
None => Some(dirs::home_dir()?.join(".claude")),
}
}
pub(crate) fn state_dir() -> Option<PathBuf> {
relocated_dir().or_else(dirs::home_dir)
}
fn relocated_dir() -> Option<PathBuf> {
std::env::var_os(CONFIG_DIR_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
pub fn detect() -> Option<PathBuf> {
let claude_dir = config_dir()?;
claude_dir.is_dir().then_some(claude_dir)
}
pub fn config_is_machine_global() -> bool {
let Some(resolved) = config_dir() else {
return true;
};
let Some(default) = dirs::home_dir().map(|home| home.join(".claude")) else {
return true;
};
let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canonical(&resolved) == canonical(&default)
}
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 timeout = if event_type == "PreToolUse" { 900 } else { 10 };
let hook_inner = json!({
"type": "command",
"command": command,
"timeout": timeout,
"allowedEnvVars": [token_env_var, super::OPENLATCH_PORT_ENV]
});
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]
})
}
}
pub(crate) fn pascal_to_snake(event: &str) -> &'static str {
match event {
"PreToolUse" => "pre_tool_use",
"PostToolUse" => "post_tool_use",
"PostToolUseFailure" => "post_tool_use_failure",
"UserPromptSubmit" => "user_prompt_submit",
"Notification" => "notification",
"Stop" => "stop",
"SubagentStop" => "subagent_stop",
"PreCompact" => "pre_compact",
"SessionStart" => "session_start",
"SessionEnd" => "session_end",
"ConfigChange" => "config_change",
"InstructionsLoaded" => "instructions_loaded",
"FileChanged" => "file_changed",
"PermissionRequest" => "permission_request",
"PostCompact" => "post_compact",
"SubagentStart" => "subagent_start",
"Interrupt" => "interrupt",
"TaskStart" => "task_start",
"TaskResume" => "task_resume",
"TaskCancel" => "task_cancel",
"TaskComplete" => "task_complete",
"TaskError" => "task_error",
"SessionShutdown" => "session_shutdown",
_ => "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 only_pre_tool_use_receives_the_hold_timeout() {
for event in ["PreToolUse", "PostToolUse", "SessionStart", "Stop"] {
let entry =
build_hook_entry(event, 7443, "OPENLATCH_TOKEN", &test_bin(), &test_marker());
assert_eq!(
entry["hooks"][0]["timeout"].as_u64(),
Some(if event == "PreToolUse" { 900 } else { 10 }),
"event={event}"
);
}
}
#[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");
assert_eq!(pascal_to_snake("PermissionRequest"), "permission_request");
assert_eq!(pascal_to_snake("PostCompact"), "post_compact");
assert_eq!(pascal_to_snake("SubagentStart"), "subagent_start");
assert_eq!(pascal_to_snake("Interrupt"), "interrupt");
}
#[test]
fn every_cline_event_maps_to_a_wire_name() {
let expected = [
("TaskStart", "task_start"),
("TaskResume", "task_resume"),
("TaskCancel", "task_cancel"),
("TaskComplete", "task_complete"),
("TaskError", "task_error"),
("PreToolUse", "pre_tool_use"),
("PostToolUse", "post_tool_use"),
("UserPromptSubmit", "user_prompt_submit"),
("PreCompact", "pre_compact"),
("SessionShutdown", "session_shutdown"),
];
assert_eq!(expected.len(), 10, "Cline installs exactly ten hook files");
let mut named: Vec<&str> = expected.iter().map(|(name, _)| *name).collect();
let mut installed: Vec<&str> = crate::hooks::hook_files::CLINE_HOOK_FILES.to_vec();
named.sort_unstable();
installed.sort_unstable();
assert_eq!(
named, installed,
"this list and the installer's have drifted apart"
);
for (file_name, wire) in expected {
let mapped = pascal_to_snake(file_name);
assert_ne!(
mapped, "unknown",
"{file_name} would install as `--event unknown`"
);
assert_eq!(mapped, wire, "{file_name} maps to the wrong wire name");
assert!(
crate::core::envelope::KNOWN_HOOK_EVENT_TYPES.contains(&mapped),
"{mapped} is not in the wire vocabulary — the platform would \
record it as unknown even though this map is right"
);
}
assert_ne!(
pascal_to_snake("SessionShutdown"),
pascal_to_snake("SessionEnd")
);
}
}