use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::core::hook_state::{FileDescriptor, HookStateFile};
use crate::error::OlError;
use crate::hooks::binding::AgentBinding;
#[derive(Debug, Clone, Default)]
pub struct HookHealth {
pub expected_bin: PathBuf,
pub missing_events: Vec<String>,
pub missing_bin: Vec<String>,
pub drifted_bin: Vec<String>,
pub commands: usize,
}
impl HookHealth {
pub fn is_healthy(&self) -> bool {
self.commands > 0
&& self.missing_events.is_empty()
&& self.missing_bin.is_empty()
&& self.drifted_bin.is_empty()
}
pub fn needs_reinstall(&self) -> bool {
!self.is_healthy()
}
}
pub fn inspect(settings: &serde_json::Value, binding: &dyn AgentBinding) -> HookHealth {
let expected_bin = super::resolve_hook_binary_path();
let mut health = HookHealth {
expected_bin: expected_bin.clone(),
..Default::default()
};
let events = openlatch_hook_events(settings);
for required in binding.load_bearing_events() {
if !events.iter().any(|(event, _)| event == required) {
health.missing_events.push((*required).to_string());
}
}
for (_, command) in &events {
health.commands += 1;
let Some(bin) = extract_quoted_binary(command) else {
continue;
};
let bin_path = PathBuf::from(&bin);
if !bin_path.exists() {
health.missing_bin.push(bin);
} else if bin_path != expected_bin {
health.drifted_bin.push(bin);
}
}
health.missing_bin.sort();
health.missing_bin.dedup();
health.drifted_bin.sort();
health.drifted_bin.dedup();
health
}
pub fn inspect_file(
settings_path: &Path,
binding: &dyn AgentBinding,
) -> Result<HookHealth, OlError> {
let raw = std::fs::read_to_string(settings_path).map_err(|e| {
OlError::new(
crate::error::ERR_HOOK_WRITE_FAILED,
format!("Cannot read '{}': {e}", settings_path.display()),
)
})?;
let parsed = super::jsonc::parse_settings_value(&raw)?;
Ok(inspect(&parsed, binding))
}
#[derive(Debug, Clone, Default)]
pub struct DirectoryHookHealth {
pub hooks_dir: PathBuf,
pub missing_files: Vec<String>,
pub foreign_files: Vec<String>,
pub drifted_files: Vec<String>,
pub unverified_files: Vec<String>,
pub files: usize,
}
impl DirectoryHookHealth {
pub fn is_healthy(&self) -> bool {
self.files > 0
&& self.missing_files.is_empty()
&& self.foreign_files.is_empty()
&& self.drifted_files.is_empty()
}
pub fn needs_reinstall(&self) -> bool {
!self.is_healthy()
}
}
pub fn inspect_directory(
hooks_dir: &Path,
binding: &dyn AgentBinding,
expected: &BTreeMap<String, FileDescriptor>,
) -> DirectoryHookHealth {
let mut health = DirectoryHookHealth {
hooks_dir: hooks_dir.to_path_buf(),
..Default::default()
};
for event in binding.hook_event_types() {
let name = super::hook_files::hook_file_name(event);
let path = hooks_dir.join(&name);
let body = match std::fs::read(&path) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
health.missing_files.push(name);
continue;
}
Err(_) => {
health.foreign_files.push(name);
continue;
}
};
if !super::hook_files::is_ours(&body) {
health.foreign_files.push(name);
continue;
}
health.files += 1;
match expected.get(*event) {
None => health.unverified_files.push(name),
Some(descriptor) if descriptor.sha256 != super::hook_files::sha256_hex(&body) => {
health.drifted_files.push(name);
}
Some(_) => {}
}
}
health
}
pub fn tracked_descriptors(
state: &HookStateFile,
hooks_dir: &Path,
) -> BTreeMap<String, FileDescriptor> {
let surface_hash = crate::core::hook_state::hash_settings_path(hooks_dir);
state
.entries
.iter()
.filter(|entry| entry.settings_path_hash == surface_hash)
.filter_map(|entry| {
entry
.descriptor
.clone()
.map(|descriptor| (entry.hook_event.clone(), descriptor))
})
.collect()
}
pub fn openlatch_hook_events(settings: &serde_json::Value) -> Vec<(String, String)> {
let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else {
return Vec::new();
};
let mut out: Vec<(String, String)> = Vec::new();
for (event, entries) in hooks {
let Some(entries) = entries.as_array() else {
continue;
};
for entry in entries {
if !matches!(
entry.get("_openlatch"),
Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::Object(_))
) {
continue;
}
let Some(inner) = entry.get("hooks").and_then(|v| v.as_array()) else {
continue;
};
for h in inner {
if let Some(cmd) = h.get("command").and_then(|v| v.as_str()) {
out.push((event.clone(), cmd.to_string()));
}
}
}
}
out
}
pub fn extract_quoted_binary(command: &str) -> Option<String> {
let start = command.find('"')? + 1;
let end = command[start..].find('"')? + start;
if start == end {
return None;
}
Some(command[start..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::bindings::claude_code::ClaudeCodeBinding;
use tempfile::TempDir;
fn claude() -> ClaudeCodeBinding {
ClaudeCodeBinding {
claude_dir: PathBuf::from("/home/test/.claude"),
settings_path: PathBuf::from("/home/test/.claude/settings.json"),
}
}
fn settings_with(command: &str) -> serde_json::Value {
let entry = || {
serde_json::json!({
"matcher": "*",
"_openlatch": { "entry_id": "test" },
"hooks": [{ "type": "command", "command": command }]
})
};
serde_json::json!({
"hooks": {
"PreToolUse": [entry()],
"UserPromptSubmit": [entry()],
"Stop": [entry()],
}
})
}
#[test]
fn entries_present_but_binary_missing_is_not_healthy() {
let settings = settings_with("\"/nonexistent/openlatch-hook\" --event PreToolUse");
let health = inspect(&settings, &claude());
assert!(health.missing_events.is_empty(), "entries ARE present");
assert_eq!(health.commands, 3);
assert_eq!(health.missing_bin, vec!["/nonexistent/openlatch-hook"]);
assert!(!health.is_healthy());
assert!(health.needs_reinstall());
}
#[test]
fn bare_command_name_is_missing() {
let settings = settings_with("\"openlatch-hook\" --event PreToolUse");
let health = inspect(&settings, &claude());
assert_eq!(health.missing_bin, vec!["openlatch-hook"]);
assert!(health.needs_reinstall());
}
#[test]
fn existing_but_unexpected_binary_is_drift() {
let tmp = TempDir::new().unwrap();
let stale = tmp.path().join("openlatch-hook");
std::fs::write(&stale, b"x").unwrap();
let health = inspect(
&settings_with(&format!("\"{}\" --event x", stale.display())),
&claude(),
);
assert!(health.missing_bin.is_empty());
assert_eq!(health.drifted_bin.len(), 1);
assert!(health.needs_reinstall());
}
#[test]
fn absent_load_bearing_event_is_reported() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "*",
"_openlatch": true,
"hooks": [{ "type": "command", "command": "\"openlatch-hook\"" }]
}]
}
});
let health = inspect(&settings, &claude());
assert_eq!(health.missing_events, vec!["UserPromptSubmit", "Stop"]);
assert!(health.needs_reinstall());
}
#[test]
fn foreign_entries_are_ignored() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "\"/usr/bin/their-hook\"" }]
}]
}
});
let health = inspect(&settings, &claude());
assert_eq!(health.commands, 0);
assert!(health.missing_bin.is_empty());
assert!(health.needs_reinstall(), "no OpenLatch entries at all");
}
fn seed_our_script(dir: &Path, event: &str) -> crate::core::hook_state::FileDescriptor {
let bin = dir.join("openlatch-hook");
let body = crate::hooks::hook_files::shim_body(
&bin,
dir,
event,
"01997a1e-0000-7000-8000-00000000000a",
cfg!(windows),
);
let path = dir.join(crate::hooks::hook_files::hook_file_name(event));
std::fs::create_dir_all(dir).expect("the hooks directory");
std::fs::write(&path, &body).expect("write a seeded script");
crate::core::hook_state::FileDescriptor {
path: path.to_string_lossy().into_owned(),
sha256: crate::hooks::hook_files::sha256_hex(&body),
mode: 0o755,
}
}
#[test]
fn a_fully_written_directory_is_healthy() {
let tmp = TempDir::new().unwrap();
let binding = claude();
let expected: BTreeMap<String, crate::core::hook_state::FileDescriptor> = binding
.hook_event_types()
.iter()
.map(|event| ((*event).to_string(), seed_our_script(tmp.path(), event)))
.collect();
let health = inspect_directory(tmp.path(), &binding, &expected);
assert_eq!(health.files, binding.hook_event_types().len());
assert!(health.missing_files.is_empty());
assert!(health.foreign_files.is_empty());
assert!(health.drifted_files.is_empty());
assert!(health.unverified_files.is_empty());
assert!(health.is_healthy());
assert!(!health.needs_reinstall());
}
#[test]
fn missing_foreign_and_drifted_are_different_findings() {
let tmp = TempDir::new().unwrap();
let binding = claude();
let events = binding.hook_event_types();
let name = crate::hooks::hook_files::hook_file_name;
let mut expected = BTreeMap::new();
for event in events.iter().skip(1) {
expected.insert((*event).to_string(), seed_our_script(tmp.path(), event));
}
std::fs::write(tmp.path().join(name(events[1])), "#!/bin/sh\necho mine\n")
.expect("write their script");
let reinstalled = crate::hooks::hook_files::shim_body(
&tmp.path().join("some-other-openlatch-hook"),
tmp.path(),
events[2],
"01997a1e-0000-7000-8000-00000000000b",
cfg!(windows),
);
std::fs::write(tmp.path().join(name(events[2])), &reinstalled)
.expect("write a second install's shim");
let edited = format!(
"{}# appended by hand\n",
std::fs::read_to_string(tmp.path().join(name(events[3]))).expect("read ours")
);
std::fs::write(tmp.path().join(name(events[3])), edited).expect("edit ours");
let health = inspect_directory(tmp.path(), &binding, &expected);
assert_eq!(health.missing_files, vec![name(events[0])]);
assert_eq!(
health.foreign_files,
vec![name(events[1]), name(events[3])],
"a file failing the ownership predicate is the developer's — whether they wrote \
it from scratch or edited one of ours — and is REPORTED, never rewritten"
);
assert_eq!(
health.drifted_files,
vec![name(events[2])],
"recognisably ours, and not the bytes THIS state file recorded"
);
assert!(health.needs_reinstall());
}
#[test]
fn a_script_with_no_stored_descriptor_is_unverified_not_drifted() {
let tmp = TempDir::new().unwrap();
let binding = claude();
for event in binding.hook_event_types() {
seed_our_script(tmp.path(), event);
}
let health = inspect_directory(tmp.path(), &binding, &BTreeMap::new());
assert!(
health.drifted_files.is_empty(),
"no record is not a mismatch"
);
assert_eq!(
health.unverified_files.len(),
binding.hook_event_types().len()
);
assert!(
health.is_healthy(),
"a host whose hook-state.json was deleted is not a host with broken hooks"
);
}
#[test]
fn an_empty_directory_needs_a_reinstall() {
let tmp = TempDir::new().unwrap();
let health = inspect_directory(tmp.path(), &claude(), &BTreeMap::new());
assert_eq!(health.files, 0);
assert!(!health.is_healthy());
assert!(health.needs_reinstall());
}
#[test]
fn tracked_descriptors_are_scoped_to_one_directory() {
use crate::core::hook_state::{FileDescriptor, HookStateFile, StateEntry};
let mine = Path::new("/tmp/openlatch-test/Hooks");
let theirs = Path::new("/tmp/openlatch-test/OtherHooks");
let mut state = HookStateFile::new("kid-01".into());
let row = |dir: &Path, event: &str, descriptor: Option<FileDescriptor>| StateEntry {
id: format!("id-{event}"),
agent: "cline".into(),
settings_path_hash: crate::core::hook_state::hash_settings_path(dir),
hook_event: event.into(),
expected_entry_hmac: String::new(),
daemon_port_at_install: 7443,
daemon_token_fp: "fp".into(),
descriptor,
v: crate::core::hook_state::STATE_ENTRY_VERSION,
};
let descriptor = |sha: &str| FileDescriptor {
path: "/tmp/openlatch-test/Hooks/PreToolUse".into(),
sha256: sha.into(),
mode: 0o755,
};
state.upsert_entry(row(mine, "PreToolUse", Some(descriptor("aa"))));
state.upsert_entry(row(theirs, "PostToolUse", Some(descriptor("bb"))));
state.upsert_entry(row(mine, "Stop", None));
let found = tracked_descriptors(&state, mine);
assert_eq!(found.len(), 1);
assert_eq!(found["PreToolUse"].sha256, "aa");
}
#[test]
fn extract_quoted_binary_handles_spaces_and_rejects_empties() {
assert_eq!(
extract_quoted_binary("\"C:\\Program Files\\openlatch-hook.exe\" --event Stop"),
Some("C:\\Program Files\\openlatch-hook.exe".to_string())
);
assert_eq!(extract_quoted_binary("\"\" --event Stop"), None);
assert_eq!(extract_quoted_binary("openlatch-hook --event Stop"), None);
}
}