1use std::collections::BTreeMap;
9
10use indexmap::IndexMap;
11use serde_json::Number;
12
13use crate::ordered::OrderedValue;
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct Handler {
18 pub kind: String,
19 pub command: Option<String>,
20 pub timeout: Option<Number>,
21 pub condition: Option<String>,
22 pub on_failure: Option<String>,
23 pub status_message: Option<String>,
24 pub is_async: Option<bool>,
25 pub extra: IndexMap<String, OrderedValue>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct MatcherGroup {
32 pub matcher: Option<String>,
33 pub handlers: Vec<Handler>,
34 pub extra: IndexMap<String, OrderedValue>,
35}
36
37#[derive(Debug, Clone, PartialEq, Default)]
39pub struct PortableHooks {
40 events: BTreeMap<String, Vec<MatcherGroup>>,
41}
42
43fn object(value: OrderedValue, at: &str) -> Result<IndexMap<String, OrderedValue>, String> {
44 match value {
45 OrderedValue::Object(map) => Ok(map),
46 _ => Err(format!("{at} must be a table")),
47 }
48}
49
50fn take_string(
51 map: &mut IndexMap<String, OrderedValue>,
52 key: &str,
53 at: &str,
54) -> Result<Option<String>, String> {
55 match map.shift_remove(key) {
56 None => Ok(None),
57 Some(OrderedValue::String(text)) => Ok(Some(text)),
58 Some(_) => Err(format!("{at}: `{key}` must be a string")),
59 }
60}
61
62const MAX_TIMEOUT_SECONDS: f64 = 2_147_483.0;
64
65pub(super) fn timeout_problem(timeout: &Number) -> Option<String> {
67 let seconds = timeout.as_f64().unwrap_or(f64::NAN);
68 (!(seconds > 0.0 && seconds <= MAX_TIMEOUT_SECONDS))
69 .then(|| format!("`timeout` must be more than 0 and at most {MAX_TIMEOUT_SECONDS} seconds"))
70}
71
72pub(super) const NO_COMMAND: &str = "a command handler needs a non-empty `command`";
73
74impl Handler {
75 pub(super) fn problem(&self) -> Option<String> {
79 if let Some(problem) = self.timeout.as_ref().and_then(timeout_problem) {
80 return Some(problem);
81 }
82 match self.on_failure.as_deref() {
83 None | Some("block" | "continue") => None,
84 Some(other) => Some(format!(
85 "`onFailure` must be `block` or `continue`, not `{other}`"
86 )),
87 }
88 }
89
90 pub(super) fn command_to_run(&self) -> Option<&str> {
93 self.command
94 .as_deref()
95 .filter(|command| self.kind == "command" && !command.trim().is_empty())
96 }
97
98 fn from_value(value: OrderedValue, at: &str) -> Result<Self, String> {
99 let mut map = object(value, at)?;
100 let kind = take_string(&mut map, "type", at)?
101 .ok_or_else(|| format!("{at}: `type` is required"))?;
102 let timeout = match map.shift_remove("timeout") {
103 None => None,
104 Some(OrderedValue::Number(number)) => Some(number),
105 Some(_) => return Err(format!("{at}: `timeout` must be a number")),
106 };
107 let is_async = match map.shift_remove("async") {
108 None => None,
109 Some(OrderedValue::Bool(flag)) => Some(flag),
110 Some(_) => return Err(format!("{at}: `async` must be a boolean")),
111 };
112 Ok(Self {
113 command: take_string(&mut map, "command", at)?,
114 condition: take_string(&mut map, "if", at)?,
115 on_failure: take_string(&mut map, "onFailure", at)?,
116 status_message: take_string(&mut map, "statusMessage", at)?,
117 kind,
118 timeout,
119 is_async,
120 extra: map,
121 })
122 }
123
124 pub(super) fn to_value(&self) -> OrderedValue {
125 let mut map = IndexMap::new();
126 let mut put = |key: &str, value: Option<OrderedValue>| {
127 if let Some(value) = value {
128 map.insert(key.to_owned(), value);
129 }
130 };
131 put("type", Some(OrderedValue::String(self.kind.clone())));
132 put("command", self.command.clone().map(OrderedValue::String));
133 put("timeout", self.timeout.clone().map(OrderedValue::Number));
134 put("if", self.condition.clone().map(OrderedValue::String));
135 put(
136 "onFailure",
137 self.on_failure.clone().map(OrderedValue::String),
138 );
139 put(
140 "statusMessage",
141 self.status_message.clone().map(OrderedValue::String),
142 );
143 put("async", self.is_async.map(OrderedValue::Bool));
144 map.extend(self.extra.clone());
145 OrderedValue::Object(map)
146 }
147}
148
149impl MatcherGroup {
150 fn from_value(value: OrderedValue, at: &str) -> Result<Self, String> {
151 let mut map = object(value, at)?;
152 let matcher = take_string(&mut map, "matcher", at)?;
153 let handlers = match map.shift_remove("hooks") {
154 Some(OrderedValue::Array(handlers)) => handlers
155 .into_iter()
156 .enumerate()
157 .map(|(i, handler)| Handler::from_value(handler, &format!("{at}.hooks[{i}]")))
158 .collect::<Result<_, _>>()?,
159 Some(_) => return Err(format!("{at}: `hooks` must be an array of tables")),
160 None => return Err(format!("{at}: `hooks` is required")),
161 };
162 Ok(Self {
163 matcher,
164 handlers,
165 extra: map,
166 })
167 }
168
169 pub(super) fn to_value(&self, handlers: &[Handler]) -> OrderedValue {
170 let mut map = IndexMap::new();
171 if let Some(matcher) = &self.matcher {
172 map.insert("matcher".to_owned(), OrderedValue::String(matcher.clone()));
173 }
174 map.insert(
175 "hooks".to_owned(),
176 OrderedValue::Array(handlers.iter().map(Handler::to_value).collect()),
177 );
178 map.extend(self.extra.clone());
179 OrderedValue::Object(map)
180 }
181
182 pub fn to_claude(&self) -> OrderedValue {
184 self.to_value(&self.handlers)
185 }
186}
187
188fn from_toml(value: &toml::Value) -> OrderedValue {
190 match value {
191 toml::Value::String(text) => OrderedValue::String(text.clone()),
192 toml::Value::Integer(number) => OrderedValue::Number((*number).into()),
193 toml::Value::Float(number) => Number::from_f64(*number).map_or_else(
194 || OrderedValue::String(number.to_string()),
195 OrderedValue::Number,
196 ),
197 toml::Value::Boolean(flag) => OrderedValue::Bool(*flag),
198 toml::Value::Datetime(date) => OrderedValue::String(date.to_string()),
199 toml::Value::Array(values) => OrderedValue::Array(values.iter().map(from_toml).collect()),
200 toml::Value::Table(table) => OrderedValue::Object(
201 table
202 .iter()
203 .map(|(key, value)| (key.clone(), from_toml(value)))
204 .collect(),
205 ),
206 }
207}
208
209fn to_toml_value(value: &OrderedValue) -> toml::Value {
211 match value {
212 OrderedValue::Null => unreachable!("a portable hook holds no null"),
213 OrderedValue::Bool(flag) => toml::Value::Boolean(*flag),
214 OrderedValue::Number(number) => number
215 .as_i64()
216 .map(toml::Value::Integer)
217 .or_else(|| number.as_f64().map(toml::Value::Float))
218 .expect("a JSON number is an integer or a float"),
219 OrderedValue::String(text) => toml::Value::String(text.clone()),
220 OrderedValue::Array(values) => {
221 toml::Value::Array(values.iter().map(to_toml_value).collect())
222 }
223 OrderedValue::Object(map) => toml::Value::Table(
224 map.iter()
225 .map(|(key, value)| (key.clone(), to_toml_value(value)))
226 .collect(),
227 ),
228 }
229}
230
231fn drop_nulls(value: &mut OrderedValue, at: &str, dropped: &mut Vec<String>) {
233 match value {
234 OrderedValue::Array(values) => {
235 let mut index = 0;
236 values.retain_mut(|value| {
237 let here = format!("{at}[{index}]");
238 index += 1;
239 if *value == OrderedValue::Null {
240 dropped.push(here);
241 return false;
242 }
243 drop_nulls(value, &here, dropped);
244 true
245 });
246 }
247 OrderedValue::Object(map) => {
248 map.retain(|key, value| {
249 let here = format!("{at}.{key}");
250 if *value == OrderedValue::Null {
251 dropped.push(here);
252 return false;
253 }
254 drop_nulls(value, &here, dropped);
255 true
256 });
257 }
258 _ => {}
259 }
260}
261
262pub(super) fn unwrap_hooks_object(value: OrderedValue) -> OrderedValue {
265 if let OrderedValue::Object(mut object) = value {
266 let wrapper = object
267 .keys()
268 .all(|key| matches!(key.as_str(), "hooks" | "version" | "description"));
269 if wrapper && let Some(inner) = object.shift_remove("hooks") {
270 return inner;
271 }
272 return OrderedValue::Object(object);
273 }
274 value
275}
276
277impl PortableHooks {
278 pub fn from_ordered(mut value: OrderedValue) -> Result<(Self, Vec<String>), String> {
281 let mut dropped = Vec::new();
282 drop_nulls(&mut value, "hooks", &mut dropped);
283 let mut events = BTreeMap::new();
284 for (event, groups) in object(value, "`hooks`")? {
285 let OrderedValue::Array(groups) = groups else {
286 return Err(format!("`hooks.{event}` must be an array of tables"));
287 };
288 let groups = groups
289 .into_iter()
290 .enumerate()
291 .map(|(i, group)| MatcherGroup::from_value(group, &format!("hooks.{event}[{i}]")))
292 .collect::<Result<Vec<_>, _>>()?;
293 events.insert(event, groups);
294 }
295 Ok((Self { events }, dropped))
296 }
297
298 pub fn from_toml(value: &toml::Value) -> Result<Self, String> {
300 Self::from_ordered(from_toml(value)).map(|(hooks, _)| hooks)
301 }
302
303 pub fn from_claude_json(text: &str) -> Result<(Self, Vec<String>), String> {
308 let value: OrderedValue =
309 serde_json::from_str(text).map_err(|error| format!("not valid JSON: {error}"))?;
310 Self::from_ordered(unwrap_hooks_object(value))
311 }
312
313 pub fn to_claude_json(&self) -> OrderedValue {
315 OrderedValue::Object(
316 self.events
317 .iter()
318 .map(|(event, groups)| {
319 (
320 event.clone(),
321 OrderedValue::Array(groups.iter().map(MatcherGroup::to_claude).collect()),
322 )
323 })
324 .collect(),
325 )
326 }
327
328 pub fn to_toml(&self) -> String {
330 let mut root = toml::map::Map::new();
331 root.insert("hooks".into(), to_toml_value(&self.to_claude_json()));
332 toml::to_string(&toml::Value::Table(root)).expect("hooks are TOML-representable")
333 }
334
335 pub fn events(&self) -> impl Iterator<Item = (&str, &[MatcherGroup])> {
336 self.events
337 .iter()
338 .map(|(event, groups)| (event.as_str(), groups.as_slice()))
339 }
340
341 pub fn declares(&self, event: &str) -> bool {
342 self.events.contains_key(event)
343 }
344
345 pub fn is_empty(&self) -> bool {
346 self.events.is_empty()
347 }
348
349 pub(super) fn remove_event(&mut self, event: &str) {
350 self.events.remove(event);
351 }
352
353 pub(super) fn handler_problem(&self) -> Option<String> {
355 self.events.iter().find_map(|(event, groups)| {
356 groups.iter().enumerate().find_map(|(g, group)| {
357 group.handlers.iter().enumerate().find_map(|(h, handler)| {
358 let problem = handler.problem()?;
359 Some(format!(
360 "`{event}` matcher group {g}, handler {h}: {problem}"
361 ))
362 })
363 })
364 })
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 const CLAUDE_JSON: &str = r#"{"hooks":{
373 "PreToolUse":[{"matcher":"Bash","hooks":[
374 {"type":"command","command":"bash run.sh","timeout":10,"if":"Bash(*tmux*)",
375 "onFailure":"block","statusMessage":"checking","async":false,
376 "commandWindows":"run.cmd","shell":"bash"},
377 {"type":"http","url":"https://example.test/hook","headers":{"X-A":"b"}}],
378 "note":"kept"}],
379 "SessionStart":[{"hooks":[{"type":"command","command":"echo hi","timeout":1.5}]}]}}"#;
380
381 #[test]
382 fn claude_json_round_trips_through_toml() {
383 let (hooks, dropped) = PortableHooks::from_claude_json(CLAUDE_JSON).unwrap();
384 assert!(dropped.is_empty());
385 let toml_text = hooks.to_toml();
386 assert!(toml_text.contains("[[hooks.PreToolUse]]"), "{toml_text}");
387 assert!(
388 toml_text.contains("[[hooks.PreToolUse.hooks]]"),
389 "{toml_text}"
390 );
391 let value: toml::Value = toml::from_str(&toml_text).unwrap();
392 let again = PortableHooks::from_toml(value.get("hooks").unwrap()).unwrap();
393 assert_eq!(again, hooks);
394 let original: OrderedValue = serde_json::from_str(CLAUDE_JSON).unwrap();
396 assert_eq!(
397 again.to_claude_json().to_json(),
398 unwrap_hooks_object(original).to_json()
399 );
400 }
401
402 #[test]
403 fn unknown_fields_are_kept_and_known_ones_typed() {
404 let (hooks, _) = PortableHooks::from_claude_json(CLAUDE_JSON).unwrap();
405 let (_, groups) = hooks.events().next().unwrap();
406 assert_eq!(groups[0].extra["note"], OrderedValue::String("kept".into()));
407 let command = &groups[0].handlers[0];
408 assert_eq!(command.on_failure.as_deref(), Some("block"));
409 assert_eq!(
410 command.extra["commandWindows"],
411 OrderedValue::String("run.cmd".into())
412 );
413 assert!(!command.extra.contains_key("timeout"));
414 assert!(groups[0].handlers[1].extra.contains_key("url"));
415 }
416
417 #[test]
418 fn json_null_is_dropped_and_reported() {
419 let (hooks, dropped) = PortableHooks::from_claude_json(
420 r#"{"Stop":[{"hooks":[{"type":"mcp_tool","server":"s","tool":"t",
421 "input":{"a":null,"b":[1,null]},"statusMessage":null}]}]}"#,
422 )
423 .unwrap();
424 assert_eq!(
425 dropped,
426 [
427 "hooks.Stop[0].hooks[0].input.a",
428 "hooks.Stop[0].hooks[0].input.b[1]",
429 "hooks.Stop[0].hooks[0].statusMessage",
430 ]
431 );
432 assert!(!hooks.to_toml().contains("null"));
433 }
434
435 #[test]
436 fn mistyped_known_fields_are_refused() {
437 for (json, needle) in [
438 (
439 r#"{"Stop":[{"hooks":[{"type":"command","timeout":"5"}]}]}"#,
440 "`timeout` must be a number",
441 ),
442 (
443 r#"{"Stop":[{"hooks":[{"command":"x"}]}]}"#,
444 "`type` is required",
445 ),
446 (
447 r#"{"Stop":[{"matcher":1,"hooks":[]}]}"#,
448 "`matcher` must be a string",
449 ),
450 (r#"{"Stop":[{"matcher":"x"}]}"#, "`hooks` is required"),
451 (r#"{"Stop":{}}"#, "must be an array of tables"),
452 ] {
453 let error = PortableHooks::from_claude_json(json).unwrap_err();
454 assert!(error.contains(needle), "{json}: {error}");
455 }
456 }
457}