use std::path::{Path, PathBuf};
use crate::error::OlError;
pub const LOAD_BEARING_EVENTS: [&str; 3] = ["PreToolUse", "UserPromptSubmit", "Stop"];
#[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) -> 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 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) -> 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))
}
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 tempfile::TempDir;
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);
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);
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()
)));
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);
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);
assert_eq!(health.commands, 0);
assert!(health.missing_bin.is_empty());
assert!(health.needs_reinstall(), "no OpenLatch entries at all");
}
#[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);
}
}