use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use super::{Harness, Wiring};
#[derive(Debug, Clone)]
pub struct Hooks {
pub harness: &'static str,
pub events: &'static [(&'static str, &'static str)],
pub timeouts: &'static [(&'static str, u64)],
pub retired: &'static [(&'static str, &'static str)],
pub file: String,
}
fn shell_quote(s: &str) -> String {
if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-+:@%".contains(c)) {
s.to_string()
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
fn unquote(s: &str) -> String {
let s = s.trim();
match s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
Some(inner) => inner.replace(r"'\''", "'"),
None => s.to_string(),
}
}
pub fn named_borg(bin: &Path) -> bool {
let base = bin.file_name().map(|b| b.to_string_lossy().into_owned()).unwrap_or_default();
base == "borg" || base == "borg.exe"
}
pub fn render(m: Map<String, Value>, original: Option<&str>) -> String {
let mut s = serde_json::to_string_pretty(&Value::Object(m)).unwrap_or_default();
if original.is_none_or(|o| o.trim().is_empty() || o.ends_with('\n')) {
s.push('\n');
}
s
}
impl Hooks {
pub fn of(h: &dyn Harness) -> Option<Hooks> {
let Wiring::CommandHooks { events, timeouts, retired, .. } = h.wiring() else { return None };
let file = h.target(Path::new(""), &|_| None).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
Some(Hooks { harness: h.name(), events, timeouts, retired, file })
}
pub fn command(&self, bin: &Path, event: &str) -> String {
format!("{} hook {} {event}", shell_quote(&bin.display().to_string()), self.harness)
}
pub fn recognise(&self, cmd: &str) -> Option<(PathBuf, &'static str)> {
self.events.iter().chain(self.retired).find_map(|(_, ev)| {
let prog = cmd.trim().strip_suffix(&format!(" hook {} {ev}", self.harness))?;
let prog = PathBuf::from(unquote(prog));
named_borg(&prog).then_some((prog, *ev))
})
}
fn entry(&self, bin: &Path, ev: &str) -> Value {
let mut e = json!({"type": "command", "command": self.command(bin, ev)});
if let Some((_, s)) = self.timeouts.iter().find(|(x, _)| *x == ev) {
e["timeout"] = json!(s);
}
e
}
pub fn parse(&self, text: Option<&str>) -> Result<Map<String, Value>, String> {
let file = &self.file;
match text.filter(|t| !t.trim().is_empty()) {
None => Ok(Map::new()),
Some(t) => match serde_json::from_str(t) {
Ok(Value::Object(m)) => Ok(m),
Ok(_) => Err(format!("{file} is not a JSON object")),
Err(e) => Err(format!("{file} is not valid JSON ({e}); fix it first, nothing was changed")),
},
}
}
fn remove_borg(&self, groups: &mut Vec<Value>, ev: &str) -> usize {
let mut removed = 0;
groups.retain_mut(|g| {
let Some(hooks) = g.get_mut("hooks").and_then(Value::as_array_mut) else { return true };
let n = hooks.len();
hooks.retain(|h| h.get("command").and_then(Value::as_str).and_then(|c| self.recognise(c)).is_none_or(|(_, e)| e != ev));
removed += n - hooks.len();
!(hooks.is_empty() && n > 0)
});
removed
}
fn slot_of<'a>(&self, groups: &'a mut [Value], ev: &str) -> Option<&'a mut Value> {
groups
.iter_mut()
.filter_map(|g| g.get_mut("hooks").and_then(Value::as_array_mut))
.flatten()
.find(|h| h.get("command").and_then(Value::as_str).and_then(|c| self.recognise(c)).is_some_and(|(_, e)| e == ev))
}
pub fn install(&self, text: Option<&str>, bin: &Path) -> Result<Option<String>, String> {
if !named_borg(bin) {
return Err(format!(
"{} is not named borg, so its hooks could not be recognised again (duplicates, no uninstall); point BORG_BIN at a binary named borg (a symlink will do)",
bin.display()
));
}
let file = &self.file;
let mut m = self.parse(text)?;
let orig = m.clone();
let hooks = m.entry("hooks").or_insert_with(|| json!({}));
let Some(hooks) = hooks.as_object_mut() else { return Err(format!("{file}: \"hooks\" is not an object")) };
for (event, ev) in self.events {
let want = self.entry(bin, ev);
let groups = hooks.entry(*event).or_insert_with(|| json!([]));
let Some(groups) = groups.as_array_mut() else { return Err(format!("{file}: hooks.{event} is not an array")) };
let ours: Vec<Value> = groups
.iter()
.flat_map(|g| g.get("hooks").and_then(Value::as_array).cloned().unwrap_or_default())
.filter(|h| h.get("command").and_then(Value::as_str).and_then(|c| self.recognise(c)).is_some_and(|(_, e)| e == *ev))
.collect();
if ours == [want.clone()] {
continue; }
if ours.len() == 1 {
if let Some(slot) = self.slot_of(groups, ev) {
*slot = want;
continue;
}
}
self.remove_borg(groups, ev); groups.push(json!({"hooks": [want]}));
}
self.remove_retired(hooks);
Ok((m != orig).then(|| render(m, text)))
}
fn remove_retired(&self, hooks: &mut Map<String, Value>) -> usize {
let mut removed = 0;
for (event, ev) in self.retired {
let Some(groups) = hooks.get_mut(*event).and_then(Value::as_array_mut) else { continue };
let n = self.remove_borg(groups, ev);
if n > 0 && groups.is_empty() {
hooks.shift_remove(*event);
}
removed += n;
}
removed
}
pub fn uninstall(&self, text: Option<&str>) -> Result<Option<Option<String>>, String> {
if text.is_none() {
return Ok(None);
}
let mut m = self.parse(text)?;
let Some(hooks) = m.get_mut("hooks").and_then(Value::as_object_mut) else { return Ok(None) };
let mut removed = 0;
for (event, ev) in self.events {
let Some(groups) = hooks.get_mut(*event).and_then(Value::as_array_mut) else { continue };
let n = self.remove_borg(groups, ev);
if n > 0 && groups.is_empty() {
hooks.shift_remove(*event);
}
removed += n;
}
removed += self.remove_retired(hooks);
if removed == 0 {
return Ok(None);
}
if hooks.is_empty() {
m.shift_remove("hooks");
}
Ok(Some((!m.is_empty()).then(|| render(m, text))))
}
pub fn wired(&self, text: Option<&str>) -> Vec<(&'static str, PathBuf)> {
let Ok(m) = self.parse(text) else { return Vec::new() };
let mut out = Vec::new();
for (event, _) in self.events {
for g in m.get("hooks").and_then(|h| h.get(*event)).and_then(Value::as_array).into_iter().flatten() {
for h in g.get("hooks").and_then(Value::as_array).into_iter().flatten() {
if let Some((bin, ev)) = h.get("command").and_then(Value::as_str).and_then(|c| self.recognise(c)) {
out.push((ev, bin));
}
}
}
}
out
}
pub fn leftover(&self, text: Option<&str>) -> Vec<&'static str> {
let Ok(m) = self.parse(text) else { return Vec::new() };
let ours = |event: &str, ev: &str| {
m.get("hooks").and_then(|h| h.get(event)).and_then(Value::as_array).into_iter().flatten().any(|g| {
g.get("hooks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|h| h.get("command").and_then(Value::as_str).and_then(|c| self.recognise(c)).is_some_and(|(_, e)| e == ev))
})
};
self.retired.iter().filter(|(event, ev)| ours(event, ev)).map(|(_, ev)| *ev).collect()
}
pub fn missing(&self, wired: &[(&'static str, PathBuf)]) -> Vec<&'static str> {
self.events.iter().map(|(_, e)| *e).filter(|e| !wired.iter().any(|(x, _)| x == e)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_command_carries_the_harness_name_and_only_its_own_is_recognised() {
let sim = Hooks::of(&crate::harness::sim::Sim).unwrap();
assert_eq!(sim.file, "hooks.json");
let bin = Path::new("/opt/borg dir/borg");
assert_eq!(sim.command(bin, "stop"), "'/opt/borg dir/borg' hook sim stop");
assert_eq!(sim.recognise("'/opt/borg dir/borg' hook sim stop"), Some((bin.to_path_buf(), "stop")));
assert_eq!(sim.recognise("/b/borg hook claude stop"), None, "another harness's hook is not this one's");
let text = sim.install(None, Path::new("/b/borg")).unwrap().unwrap();
assert!(text.contains("\"SimStop\"") && text.contains("/b/borg hook sim stop"), "{text}");
assert_eq!(sim.missing(&sim.wired(Some(&text))), Vec::<&str>::new());
assert_eq!(sim.uninstall(Some(&text)).unwrap(), Some(None), "nothing else was in it: the file goes");
}
#[test]
fn each_event_named_in_the_timeouts_gets_its_own() {
let k = Hooks { timeouts: &[("prompt", 30), ("stop", 90)], ..Hooks::of(&crate::harness::sim::Sim).unwrap() };
let v: Value = serde_json::from_str(&k.install(None, Path::new("/b/borg")).unwrap().unwrap()).unwrap();
let timeout = |native: &str| v["hooks"][native][0]["hooks"][0].get("timeout").cloned();
assert_eq!((timeout("SimPrompt"), timeout("SimStop"), timeout("SimEnd")), (Some(json!(30)), Some(json!(90)), None));
}
#[test]
fn a_reinstall_from_another_path_replaces_borgs_hook_where_it_stands() {
let sim = Hooks::of(&crate::harness::sim::Sim).unwrap();
let text = sim.install(None, Path::new("/old/borg")).unwrap().unwrap();
let mut v: Value = serde_json::from_str(&text).unwrap();
v["hooks"]["SimStop"].as_array_mut().unwrap().push(json!({"hooks": [{"type": "command", "command": "echo user-after"}]}));
let text = serde_json::to_string_pretty(&v).unwrap();
let v: Value = serde_json::from_str(&sim.install(Some(&text), Path::new("/new/borg")).unwrap().unwrap()).unwrap();
let commands = |native: &str| -> Vec<String> {
v["hooks"][native].as_array().unwrap().iter().map(|g| g["hooks"][0]["command"].as_str().unwrap().to_string()).collect()
};
assert_eq!(commands("SimStop"), ["/new/borg hook sim stop", "echo user-after"], "the user's hook keeps its index");
assert_eq!(commands("SimEnd"), ["/new/borg hook sim end"]);
}
#[test]
fn a_retired_hook_goes_at_install_and_uninstall_and_someone_elses_stays() {
let k = Hooks { retired: &[("SimStart", "start")], ..Hooks::of(&crate::harness::sim::Sim).unwrap() };
let mut v: Value = serde_json::from_str(&k.install(None, Path::new("/b/borg")).unwrap().unwrap()).unwrap();
v["hooks"]["SimStart"] = json!([{"hooks": [{"type": "command", "command": "/old/borg hook sim start"}]}]);
let old = serde_json::to_string_pretty(&v).unwrap();
let up: Value = serde_json::from_str(&k.install(Some(&old), Path::new("/b/borg")).unwrap().unwrap()).unwrap();
assert!(up["hooks"].get("SimStart").is_none(), "{up}");
assert_eq!(k.install(Some(&render(up.as_object().unwrap().clone(), None)), Path::new("/b/borg")).unwrap(), None, "then nothing to do");
v["hooks"]["SimStart"][0]["hooks"].as_array_mut().unwrap().push(json!({"type": "command", "command": "echo mine"}));
let mixed = serde_json::to_string_pretty(&v).unwrap();
let up: Value = serde_json::from_str(&k.install(Some(&mixed), Path::new("/b/borg")).unwrap().unwrap()).unwrap();
assert_eq!(up["hooks"]["SimStart"][0]["hooks"], json!([{"type": "command", "command": "echo mine"}]));
let down: Value = serde_json::from_str(&k.uninstall(Some(&mixed)).unwrap().unwrap().unwrap()).unwrap();
assert_eq!(down["hooks"].as_object().unwrap().keys().collect::<Vec<_>>(), ["SimStart"], "{down}");
assert_eq!(k.missing(&k.wired(Some(&old))), Vec::<&str>::new(), "a retired hook is never missing");
assert_eq!((k.leftover(Some(&old)), k.leftover(Some(&k.install(Some(&old), Path::new("/b/borg")).unwrap().unwrap()))), (vec!["start"], vec![]));
}
}