Skip to main content

mars_schema/hooks/
portable.rs

1//! The portable `[hooks]` table: Claude's `"hooks"` object, written as TOML.
2//!
3//! `[[hooks.<Event>]]` is a matcher group and `[[hooks.<Event>.hooks]]` a handler, with
4//! Claude's names spelled exactly. The fields mars acts on are typed; every other handler
5//! and group field is kept and travels to Claude unchanged. Claude JSON and this TOML
6//! convert into each other; JSON `null`, which TOML cannot hold, is dropped and reported.
7
8use std::collections::BTreeMap;
9
10use indexmap::IndexMap;
11use serde_json::Number;
12
13use crate::ordered::OrderedValue;
14
15/// One handler. Claude's `type` is [`Self::kind`] because `type` is a Rust keyword.
16#[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    /// Every other field, in authored order.
26    pub extra: IndexMap<String, OrderedValue>,
27}
28
29/// One matcher group: a `matcher` and the handlers it guards.
30#[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/// A hook's `[hooks]` table: the matcher groups of each event.
38#[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
62/// Longest `timeout`, in seconds, that every target holds: Node's timers stop at 2^31 - 1 ms.
63const MAX_TIMEOUT_SECONDS: f64 = 2_147_483.0;
64
65/// What is wrong with a `timeout`: the one rule the reader and the Codex renderer share.
66pub(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    /// What is wrong with this handler's `timeout` or `onFailure`, if anything. A hook with
76    /// such a handler fails whole: a hard error for the project's own, `hook-invalid-kept`
77    /// for a dependency's.
78    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    /// The command of a `command` handler that has one. Claude takes the table as written;
91    /// every other renderer skips a `command` handler without one, with `NO_COMMAND`.
92    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    /// This group as Claude's JSON entry.
183    pub fn to_claude(&self) -> OrderedValue {
184        self.to_value(&self.handlers)
185    }
186}
187
188/// A TOML value as the JSON-shaped value. A datetime becomes its text, as JSON has none.
189fn 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
209/// An ordered value as TOML. `null` never reaches here: the model holds none.
210fn 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
231/// Remove every `null` (an array element, or an object key), saying where each was.
232fn 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
262/// `{"hooks": …}` (a settings file or plugin `hooks.json`, with only `hooks`, `version` and
263/// `description` beside it) is its inner object; any other object is itself an event table.
264pub(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    /// An event table already parsed. `null`s are dropped (TOML cannot hold them); the
279    /// second value says where each was, as `hooks.<Event>[i]…`.
280    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    /// The `[hooks]` table of a `hook.toml`.
299    pub fn from_toml(value: &toml::Value) -> Result<Self, String> {
300        Self::from_ordered(from_toml(value)).map(|(hooks, _)| hooks)
301    }
302
303    /// Claude's hooks JSON: an event table, or a settings file or plugin `hooks.json`
304    /// wrapping one. TOML cannot hold `null`, so each is dropped; the second value says
305    /// where. This is the entry point for the Claude importer (0.26) that turns a settings
306    /// file's `hooks` into a `hook.toml`; tests drive it until then.
307    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    /// Claude's event table: what Claude reads under `"hooks"`.
314    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    /// The `[[hooks.<Event>]]` text of a `hook.toml` holding just these hooks.
329    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    /// The first handler `timeout` or `onFailure` that is out of range, located.
354    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        // Both ways land on the JSON we started from.
395        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}