use std::path::Path;
use crate::error::OlError;
pub use crate::error::{ERR_HOOK_AGENT_NOT_FOUND, ERR_HOOK_MALFORMED_JSONC, ERR_HOOK_WRITE_FAILED};
pub fn read_or_create_settings(path: &Path) -> Result<String, OlError> {
if path.exists() {
std::fs::read_to_string(path).map_err(|e| {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot read settings.json: {e}"),
)
.with_suggestion("Check file permissions.")
.with_docs("https://docs.openlatch.ai/errors/OL-1401")
})
} else {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot create settings directory: {e}"),
)
.with_suggestion("Check file permissions.")
.with_docs("https://docs.openlatch.ai/errors/OL-1401")
})?;
}
std::fs::write(path, "{}").map_err(|e| {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot create settings.json: {e}"),
)
.with_suggestion("Check file permissions.")
.with_docs("https://docs.openlatch.ai/errors/OL-1401")
})?;
Ok("{}".to_string())
}
}
fn serde_to_cst_input(v: serde_json::Value) -> jsonc_parser::cst::CstInputValue {
use jsonc_parser::cst::CstInputValue;
match v {
serde_json::Value::Null => CstInputValue::Null,
serde_json::Value::Bool(b) => CstInputValue::Bool(b),
serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()),
serde_json::Value::String(s) => CstInputValue::String(s),
serde_json::Value::Array(arr) => {
CstInputValue::Array(arr.into_iter().map(serde_to_cst_input).collect())
}
serde_json::Value::Object(map) => {
let props: Vec<(String, CstInputValue)> = map
.into_iter()
.map(|(k, v)| (k, serde_to_cst_input(v)))
.collect();
CstInputValue::Object(props)
}
}
}
pub fn insert_hook_entries(
raw_jsonc: &str,
entries: &[(String, serde_json::Value)],
) -> Result<(String, Vec<super::HookAction>), OlError> {
use jsonc_parser::cst::{CstContainerNode, CstNode, CstRootNode};
use jsonc_parser::ParseOptions;
let root = CstRootNode::parse(raw_jsonc, &ParseOptions::default()).map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("Cannot parse settings.json as JSONC: {e}"),
)
.with_suggestion("Fix the JSON syntax in your settings.json file.")
.with_docs("https://docs.openlatch.ai/errors/OL-1402")
})?;
let root_obj = root.object_value_or_set();
let hooks_obj = root_obj.object_value_or_set("hooks");
let mut actions = Vec::new();
for (event_type, entry_value) in entries {
let arr = hooks_obj.array_value_or_set(event_type);
let cst_value = serde_to_cst_input(entry_value.clone());
let existing_index = find_openlatch_element_index(&arr);
if let Some(idx) = existing_index {
let elements = arr.elements();
let old_el = &elements[idx];
match old_el {
CstNode::Container(c) => {
match c {
CstContainerNode::Object(obj) => {
obj.clone().replace_with(cst_value);
}
CstContainerNode::Array(a) => {
a.clone().replace_with(cst_value);
}
_ => {
old_el.clone().remove();
arr.insert(idx, cst_value);
}
}
}
CstNode::Leaf(_) => {
old_el.clone().remove();
arr.insert(idx, cst_value);
}
}
actions.push(super::HookAction::Replaced);
} else {
arr.append(cst_value);
actions.push(super::HookAction::Added);
}
}
Ok((root.to_string(), actions))
}
pub fn insert_hook_entries_cst(
root: &jsonc_parser::cst::CstRootNode,
entries: &[(String, serde_json::Value)],
) -> Result<Vec<super::HookAction>, OlError> {
use jsonc_parser::cst::CstNode;
let root_obj = root.object_value_or_set();
let hooks_obj = root_obj.object_value_or_set("hooks");
let mut actions = Vec::new();
for (event_type, entry_value) in entries {
let arr = hooks_obj.array_value_or_set(event_type);
let to_remove: Vec<CstNode> = arr
.elements()
.into_iter()
.filter(is_openlatch_owned_node)
.collect();
let had_existing = !to_remove.is_empty();
for el in to_remove {
el.remove();
}
let cst_value = serde_to_cst_input(entry_value.clone());
arr.append(cst_value);
actions.push(if had_existing {
super::HookAction::Replaced
} else {
super::HookAction::Added
});
}
Ok(actions)
}
fn is_openlatch_owned_node(el: &jsonc_parser::cst::CstNode) -> bool {
if is_openlatch_node(el) {
return true;
}
match el.to_serde_value() {
Some(serde_json::Value::Object(map)) => map
.get("hooks")
.and_then(|h| h.as_array())
.map(|arr| {
arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|s| s.contains("openlatch-hook"))
})
})
.unwrap_or(false),
_ => false,
}
}
pub fn set_env_var_cst(
root: &jsonc_parser::cst::CstRootNode,
key: &str,
value: &str,
) -> Result<(), OlError> {
let root_obj = root.object_value_or_set();
let env_obj = root_obj.object_value_or_set("env");
let string_value = jsonc_parser::cst::CstInputValue::String(value.to_string());
if let Some(prop) = env_obj.get(key) {
prop.set_value(string_value);
} else {
env_obj.append(key, string_value);
}
Ok(())
}
pub fn remove_owned_entries_cst(root: &jsonc_parser::cst::CstRootNode) -> Result<(), OlError> {
use jsonc_parser::cst::{CstContainerNode, CstNode};
let root_obj = match root.object_value() {
Some(obj) => obj,
None => return Ok(()),
};
let hooks_prop = match root_obj.get("hooks") {
Some(p) => p,
None => return Ok(()),
};
let hooks_obj = match hooks_prop.value() {
Some(CstNode::Container(CstContainerNode::Object(obj))) => obj,
_ => return Ok(()),
};
for event_prop in hooks_obj.properties() {
let arr = match event_prop.value() {
Some(CstNode::Container(CstContainerNode::Array(a))) => a,
_ => continue,
};
let to_remove: Vec<CstNode> = arr
.elements()
.into_iter()
.filter(is_openlatch_node)
.collect();
for el in to_remove {
el.remove();
}
}
Ok(())
}
pub fn remove_owned_entries(raw_jsonc: &str) -> Result<String, OlError> {
use jsonc_parser::cst::{CstContainerNode, CstNode, CstRootNode};
use jsonc_parser::ParseOptions;
let root = CstRootNode::parse(raw_jsonc, &ParseOptions::default()).map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("Cannot parse settings.json as JSONC: {e}"),
)
.with_suggestion("Fix the JSON syntax in your settings.json file.")
.with_docs("https://docs.openlatch.ai/errors/OL-1402")
})?;
let root_obj = match root.object_value() {
Some(obj) => obj,
None => return Ok(root.to_string()),
};
let hooks_prop = match root_obj.get("hooks") {
Some(p) => p,
None => return Ok(root.to_string()),
};
let hooks_obj = match hooks_prop.value() {
Some(CstNode::Container(CstContainerNode::Object(obj))) => obj,
_ => return Ok(root.to_string()),
};
for event_prop in hooks_obj.properties() {
let arr = match event_prop.value() {
Some(CstNode::Container(CstContainerNode::Array(a))) => a,
_ => continue,
};
let to_remove: Vec<CstNode> = arr
.elements()
.into_iter()
.filter(is_openlatch_node)
.collect();
for el in to_remove {
el.remove();
}
}
Ok(root.to_string())
}
pub fn parse_settings_value(raw_jsonc: &str) -> Result<serde_json::Value, OlError> {
use jsonc_parser::cst::CstRootNode;
use jsonc_parser::ParseOptions;
let root = CstRootNode::parse(raw_jsonc, &ParseOptions::default()).map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("Cannot parse settings.json as JSONC: {e}"),
)
.with_suggestion("Fix the JSON syntax in your settings.json file.")
.with_docs("https://docs.openlatch.ai/errors/OL-1402")
})?;
root.to_serde_value().ok_or_else(|| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
"settings.json is empty or invalid",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1402")
})
}
pub fn set_env_var(raw_jsonc: &str, key: &str, value: &str) -> Result<String, OlError> {
use jsonc_parser::cst::CstRootNode;
use jsonc_parser::ParseOptions;
let root = CstRootNode::parse(raw_jsonc, &ParseOptions::default()).map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("Cannot parse settings.json as JSONC: {e}"),
)
.with_suggestion("Fix the JSON syntax in your settings.json file.")
.with_docs("https://docs.openlatch.ai/errors/OL-1402")
})?;
let root_obj = root.object_value_or_set();
let env_obj = root_obj.object_value_or_set("env");
let string_value = jsonc_parser::cst::CstInputValue::String(value.to_string());
if let Some(prop) = env_obj.get(key) {
prop.set_value(string_value);
} else {
env_obj.append(key, string_value);
}
Ok(root.to_string())
}
fn find_openlatch_element_index(arr: &jsonc_parser::cst::CstArray) -> Option<usize> {
arr.elements().iter().position(is_openlatch_node)
}
fn is_openlatch_node(el: &jsonc_parser::cst::CstNode) -> bool {
match el.to_serde_value() {
Some(serde_json::Value::Object(map)) => matches!(
map.get("_openlatch"),
Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::Object(_))
),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_read_or_create_creates_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
assert!(!path.exists());
let content = read_or_create_settings(&path).unwrap();
assert_eq!(content, "{}");
assert!(path.exists());
}
#[test]
fn test_read_or_create_reads_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, r#"{ "key": "value" }"#).unwrap();
let content = read_or_create_settings(&path).unwrap();
assert_eq!(content, r#"{ "key": "value" }"#);
}
fn make_entries() -> Vec<(String, serde_json::Value)> {
vec![
(
"PreToolUse".to_string(),
json!({ "_openlatch": true, "matcher": "", "hooks": [] }),
),
(
"UserPromptSubmit".to_string(),
json!({ "_openlatch": true, "hooks": [] }),
),
(
"Stop".to_string(),
json!({ "_openlatch": true, "hooks": [] }),
),
]
}
#[test]
fn test_insert_into_empty_settings_creates_hooks_object() {
let raw = "{}";
let entries = make_entries();
let (result, actions) = insert_hook_entries(raw, &entries).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed["hooks"]["PreToolUse"].is_array());
assert!(parsed["hooks"]["UserPromptSubmit"].is_array());
assert!(parsed["hooks"]["Stop"].is_array());
assert_eq!(parsed["hooks"]["PreToolUse"][0]["_openlatch"], true);
assert_eq!(actions.len(), 3);
assert!(actions
.iter()
.all(|a| *a == super::super::HookAction::Added));
}
#[test]
fn test_insert_preserves_existing_non_openlatch_hooks() {
let raw = r#"{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo hi" }] }
]
}
}"#;
let entries = vec![(
"PreToolUse".to_string(),
json!({ "_openlatch": true, "matcher": "", "hooks": [] }),
)];
let (result, _) = insert_hook_entries(raw, &entries).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2, "Expected 2 entries, got {}", arr.len());
assert_eq!(arr[0]["hooks"][0]["command"], "echo hi");
assert_eq!(arr[1]["_openlatch"], true);
}
#[test]
fn test_insert_is_idempotent_replaces_existing_openlatch_entry() {
let raw = r#"{
"hooks": {
"PreToolUse": [
{ "_openlatch": true, "matcher": "", "hooks": [{"type": "http", "url": "http://localhost:7443/hooks/pre-tool-use"}] }
]
}
}"#;
let new_entry = json!({
"_openlatch": true,
"matcher": "",
"hooks": [{ "type": "http", "url": "http://localhost:9000/hooks/pre-tool-use" }]
});
let entries = vec![("PreToolUse".to_string(), new_entry)];
let (result, actions) = insert_hook_entries(raw, &entries).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(
arr.len(),
1,
"Idempotent install should not duplicate entries"
);
let url = arr[0]["hooks"][0]["url"].as_str().unwrap();
assert!(url.contains("9000"), "Expected updated URL, got: {url}");
assert_eq!(actions[0], super::super::HookAction::Replaced);
}
#[test]
fn test_insert_preserves_jsonc_comments() {
let raw_with_comment = r#"{
"hooks": {
"Stop": [
// existing user hook
{ "type": "command", "command": "echo stop" }
]
}
}"#;
let entries = vec![(
"PreToolUse".to_string(),
json!({ "_openlatch": true, "matcher": "", "hooks": [] }),
)];
let (result, _) = insert_hook_entries(raw_with_comment, &entries).unwrap();
assert!(
result.contains("// existing user hook"),
"Comment was removed: {result}"
);
use jsonc_parser::cst::CstRootNode;
use jsonc_parser::ParseOptions;
let verify_root = CstRootNode::parse(&result, &ParseOptions::default())
.expect("result must be valid JSONC");
let root_obj = verify_root.object_value().expect("root must be object");
let hooks_obj = root_obj
.object_value("hooks")
.expect("hooks key must exist");
let pre_arr = hooks_obj
.array_value("PreToolUse")
.expect("PreToolUse must be array");
let first_el = pre_arr
.elements()
.into_iter()
.next()
.expect("must have element");
let first_val = first_el.to_serde_value().expect("must be a value");
assert_eq!(first_val["_openlatch"], json!(true));
}
#[test]
fn test_malformed_jsonc_returns_ol_1402() {
let raw = "{ this is not json }";
let entries = make_entries();
let err = insert_hook_entries(raw, &entries).unwrap_err();
assert_eq!(err.code, ERR_HOOK_MALFORMED_JSONC);
}
#[test]
fn test_remove_owned_entries_removes_openlatch_entries() {
let raw = r#"{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo hi" }] },
{ "_openlatch": true, "matcher": "", "hooks": [] }
]
}
}"#;
let result = remove_owned_entries(raw).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let arr = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(
arr.len(),
1,
"Expected 1 entry after removal, got {}",
arr.len()
);
assert!(arr[0].get("_openlatch").is_none());
}
#[test]
fn test_remove_owned_entries_no_hooks_key_is_noop() {
let raw = r#"{"key": "value"}"#;
let result = remove_owned_entries(raw).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["key"], "value");
assert!(parsed.get("hooks").is_none());
}
}