Skip to main content

mars_schema/hooks/
render.rs

1//! A portable hook rendered for one target: finished entries per event, and what was left
2//! out. Renderers emit only what the target's parser accepts; they never pass Claude's
3//! JSON through blindly.
4
5use std::collections::BTreeMap;
6
7use serde_json::Number;
8
9use super::Tokens;
10use super::portable::{Handler, MatcherGroup, NO_COMMAND, PortableHooks};
11use super::targets::{self, Profile, Rules};
12use crate::ordered::OrderedValue;
13
14/// Something a target does not run. `event` is `None` when the whole target is skipped.
15#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
16pub struct Skip {
17    pub event: Option<String>,
18    pub reason: String,
19}
20
21/// One profile's rendering of a hook's `[hooks]` table.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Rendered {
24    pub output: Output,
25    pub skips: Vec<Skip>,
26    /// Events that render, but whose portable answers do not all carry over to the target.
27    pub answer_limits: Vec<Skip>,
28}
29
30/// What a rendering yields, in the shape the target's placement takes.
31#[derive(Debug, Clone, PartialEq)]
32pub enum Output {
33    /// Finished entries per native event, for a hook config file. An event with nothing left
34    /// is absent.
35    Merge {
36        events: BTreeMap<String, Vec<OrderedValue>>,
37        /// Events the target does not list that the hook's `unchecked = true` passed through.
38        unchecked_events: Vec<String>,
39    },
40    /// The text of the one file generated from the table; `None` when nothing is generated.
41    File { text: Option<String> },
42}
43
44impl Rendered {
45    fn skip(&mut self, event: &str, reason: String) {
46        self.skips.push(Skip {
47            event: Some(event.to_owned()),
48            reason,
49        });
50    }
51
52    /// Whether nothing was rendered.
53    pub fn is_empty(&self) -> bool {
54        match &self.output {
55            Output::Merge { events, .. } => events.is_empty(),
56            Output::File { text } => text.is_none(),
57        }
58    }
59
60    /// The merge shape's events; none for a file.
61    pub fn events(&self) -> &BTreeMap<String, Vec<OrderedValue>> {
62        static NONE: BTreeMap<String, Vec<OrderedValue>> = BTreeMap::new();
63        match &self.output {
64            Output::Merge { events, .. } => events,
65            Output::File { .. } => &NONE,
66        }
67    }
68}
69
70impl PortableHooks {
71    /// This table as `profile` takes it, with `tokens` substituted in every string.
72    pub fn render(&self, profile: &Profile, tokens: &Tokens<'_>) -> Rendered {
73        // Only Claude takes an event mars does not know (`unchecked`); every other target
74        // reports it as skipped.
75        let mut events: Vec<_> = self.events().collect();
76        let mut unknown = Vec::new();
77        if matches!(profile.rules, Rules::Codex | Rules::TypeScript(_)) {
78            events.retain(|(event, _)| {
79                let known = targets::claude_event(event).is_some();
80                if !known {
81                    unknown.push(Skip {
82                        event: Some((*event).to_owned()),
83                        reason: "not an event mars knows, so `unchecked` renders it on Claude only"
84                            .into(),
85                    });
86                }
87                known
88            });
89        }
90        let mut rendered = match profile.rules {
91            Rules::Claude => render_claude(&events, profile),
92            Rules::Codex => render_codex(&events, profile),
93            Rules::Withheld { reason } => Rendered {
94                output: Output::Merge {
95                    events: BTreeMap::new(),
96                    unchecked_events: Vec::new(),
97                },
98                skips: vec![Skip {
99                    event: None,
100                    reason: reason.into(),
101                }],
102                answer_limits: Vec::new(),
103            },
104            Rules::TypeScript(harness) => {
105                super::typescript::render(&events, profile, harness, tokens)
106            }
107        };
108        if !unknown.is_empty() {
109            // Skips follow the events' sorted order, as the renderers emit them.
110            rendered.skips.extend(unknown);
111            rendered.skips.sort_by(|a, b| a.event.cmp(&b.event));
112        }
113        if let Output::Merge { events, .. } = &mut rendered.output {
114            for entries in events.values_mut() {
115                entries
116                    .iter_mut()
117                    .for_each(|entry| tokens.substitute_value(entry));
118            }
119        }
120        rendered
121    }
122
123    /// Whether `render` yields anything under `profile`.
124    pub fn renders_on(&self, profile: &Profile) -> bool {
125        !self.render(profile, &Tokens::default()).is_empty()
126    }
127
128    /// Events on which a handler sets `if`, though Claude never runs such a handler there.
129    pub fn conditions_on_non_tool_events(&self) -> Vec<&str> {
130        self.events()
131            .filter(|(event, groups)| {
132                let tool_event = targets::claude_event(event).is_some_and(|e| e.is_tool_event());
133                !tool_event
134                    && groups
135                        .iter()
136                        .flat_map(|group| &group.handlers)
137                        .any(|handler| handler.condition.is_some())
138            })
139            .map(|(event, _)| event)
140            .collect()
141    }
142}
143
144/// Identity: Claude takes the table as written. An event the profile does not list is one
145/// the author passed with `unchecked = true`.
146fn render_claude(table: &[(&str, &[MatcherGroup])], profile: &Profile) -> Rendered {
147    let mut events = BTreeMap::new();
148    let mut unchecked_events = Vec::new();
149    for &(event, groups) in table {
150        if profile.event(event).is_none() {
151            unchecked_events.push(event.to_owned());
152        }
153        events.insert(
154            event.to_owned(),
155            groups.iter().map(MatcherGroup::to_claude).collect(),
156        );
157    }
158    Rendered {
159        output: Output::Merge {
160            events,
161            unchecked_events,
162        },
163        skips: Vec::new(),
164        answer_limits: Vec::new(),
165    }
166}
167
168fn render_codex(table: &[(&str, &[MatcherGroup])], profile: &Profile) -> Rendered {
169    let mut rendered = Rendered {
170        output: Output::Merge {
171            events: BTreeMap::new(),
172            unchecked_events: Vec::new(),
173        },
174        skips: Vec::new(),
175        answer_limits: Vec::new(),
176    };
177    let mut rendered_events = BTreeMap::new();
178    for &(event, groups) in table {
179        let Some(row) = profile.event(event) else {
180            rendered.skip(event, "Codex has no such event".into());
181            continue;
182        };
183        let matched = row.matcher.is_some();
184        let mut entries = Vec::new();
185        for (g, group) in groups.iter().enumerate() {
186            if let (true, Some(matcher)) = (matched, &group.matcher)
187                && let Err(reason) = codex_matcher(matcher)
188            {
189                rendered.skip(
190                    event,
191                    format!(
192                        "matcher group {g}: Codex cannot compile matcher `{matcher}`: {reason}"
193                    ),
194                );
195                continue;
196            }
197            let mut kept = Vec::new();
198            for (h, handler) in group.handlers.iter().enumerate() {
199                match codex_handler(event, handler) {
200                    Ok(handler) => kept.push(handler),
201                    Err(reason) => {
202                        rendered.skip(event, format!("matcher group {g}, handler {h}: {reason}"))
203                    }
204                }
205            }
206            if !kept.is_empty() {
207                entries.push(group.to_value(&kept));
208            }
209        }
210        if !entries.is_empty() {
211            rendered_events.insert(event.to_owned(), entries);
212        }
213    }
214    rendered.output = Output::Merge {
215        events: rendered_events,
216        unchecked_events: Vec::new(),
217    };
218    rendered
219}
220
221/// Whether Codex 0.164.0 admits a group's `matcher`. Codex compiles it at discovery, and a
222/// group whose matcher fails is skipped (`HookMatcher::new`, codex-rs/hooks/src/engine/
223/// matcher.rs; `append_matcher_groups`, discovery.rs L527-541): empty, `*` and plain
224/// `A|B` name lists are exact, anything else is a Rust `regex`, which has no look-around or
225/// backreferences although Claude's JavaScript regex does. Codex reads the matcher only on
226/// the events that have a matcher field (`matcher_pattern_for_event`, events/common.rs).
227fn codex_matcher(pattern: &str) -> Result<(), String> {
228    use regex_syntax::Error;
229    if pattern.is_empty()
230        || pattern == "*"
231        || pattern
232            .chars()
233            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '|')
234    {
235        return Ok(());
236    }
237    regex_syntax::Parser::new()
238        .parse(pattern)
239        .map(drop)
240        .map_err(|error| match error {
241            Error::Parse(error) => error.kind().to_string(),
242            Error::Translate(error) => error.kind().to_string(),
243            other => other.to_string(),
244        })
245}
246
247/// A handler as Codex 0.164.0 parses it, or why Codex would not run it. Codex rejects its
248/// whole `hooks.json` over one handler it cannot parse, so each field Codex types is checked
249/// here: `HookHandlerConfig`, codex-rs/config/src/hook_config.rs L180-221; skip rules in
250/// codex-rs/hooks/src/engine/discovery.rs L548-695.
251fn codex_handler(event: &str, handler: &Handler) -> Result<Handler, String> {
252    let extra = |key: &str| handler.extra.get(key);
253    let text = |key: &str| match extra(key) {
254        None => Ok(None),
255        Some(OrderedValue::String(text)) => Ok(Some(text.as_str())),
256        Some(_) => Err(format!("`{key}` must be a string for Codex")),
257    };
258    match handler.kind.as_str() {
259        "command" => {
260            if handler.command_to_run().is_none() {
261                return Err(NO_COMMAND.into());
262            }
263            text("commandWindows")?;
264            match extra("additionalContextLimit") {
265                None => {}
266                Some(OrderedValue::Number(n)) if n.as_u64().is_some() => {}
267                Some(_) => return Err("`additionalContextLimit` must be a whole number".into()),
268            }
269        }
270        "mcp_tool" => {
271            if event == "SessionEnd" {
272                return Err("Codex runs no `mcp_tool` handler on SessionEnd".into());
273            }
274            for key in ["server", "tool"] {
275                if text(key)?.is_none_or(|value| value.trim().is_empty()) {
276                    return Err(format!("an `mcp_tool` handler needs a non-empty `{key}`"));
277                }
278            }
279            if extra("input").is_some_and(|input| !matches!(input, OrderedValue::Object(_))) {
280                return Err("`input` must be a table".into());
281            }
282        }
283        "prompt" | "agent" => {
284            return Err(format!(
285                "Codex parses `{}` handlers but never runs them",
286                handler.kind
287            ));
288        }
289        other => {
290            return Err(format!(
291                "Codex runs `command` and `mcp_tool` handlers, not `{other}`"
292            ));
293        }
294    }
295    let mut handler = handler.clone();
296    handler.timeout = handler.timeout.as_ref().map(whole_seconds).transpose()?;
297    Ok(handler)
298}
299
300/// Codex reads `timeout` as an unsigned integer: a fractional one rounds up.
301fn whole_seconds(timeout: &Number) -> Result<Number, String> {
302    if let Some(problem) = super::portable::timeout_problem(timeout) {
303        return Err(problem);
304    }
305    Ok(match timeout.as_u64() {
306        Some(seconds) => seconds.into(),
307        None => (timeout.as_f64().unwrap_or(0.0).ceil() as u64).into(),
308    })
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn profile(root: &str) -> Profile {
316        targets::target(root).unwrap().profile
317    }
318
319    fn hooks(json: &str) -> PortableHooks {
320        PortableHooks::from_claude_json(json).unwrap().0
321    }
322
323    fn tokens() -> Tokens<'static> {
324        Tokens {
325            hook_dir: "/p/.codex/hooks/audit",
326            hook_target: ".codex",
327            ..Default::default()
328        }
329    }
330
331    const MIXED: &str = r#"{
332        "PreToolUse":[{"matcher":"Bash","hooks":[
333            {"type":"command","command":"bash ${MARS_HOOK_DIR}/run.sh ${MARS_HOOK_TARGET}",
334             "timeout":2.5,"if":"Bash(*tmux*)","onFailure":"block","statusMessage":"m"},
335            {"type":"http","url":"https://example.test"},
336            {"type":"prompt"}]}],
337        "SessionEnd":[{"hooks":[{"type":"mcp_tool","server":"s","tool":"t"},
338                                {"type":"command","command":"echo bye"}]}],
339        "Notification":[{"hooks":[{"type":"command","command":"echo n"}]}]}"#;
340
341    #[test]
342    fn claude_gets_the_table_as_written_with_tokens_substituted() {
343        let rendered = hooks(MIXED).render(&profile(".claude"), &tokens());
344        assert!(rendered.skips.is_empty());
345        let json = serde_json::to_string(&rendered.events()["PreToolUse"][0].to_json()).unwrap();
346        assert!(
347            json.contains("bash /p/.codex/hooks/audit/run.sh .codex"),
348            "{json}"
349        );
350        assert!(json.contains(r#""timeout":2.5"#));
351        assert_eq!(rendered.events().len(), 3);
352    }
353
354    #[test]
355    fn codex_gets_what_codex_parses_and_reports_the_rest() {
356        let rendered = hooks(MIXED).render(&profile(".codex"), &tokens());
357        let pre = serde_json::to_string(
358            &rendered.events()["PreToolUse"]
359                .iter()
360                .map(OrderedValue::to_json)
361                .collect::<Vec<_>>(),
362        )
363        .unwrap();
364        // The fractional timeout rounds up; `if` and `onFailure` stay (Codex ignores them).
365        assert_eq!(
366            pre,
367            r#"[{"hooks":[{"command":"bash /p/.codex/hooks/audit/run.sh .codex","if":"Bash(*tmux*)","onFailure":"block","statusMessage":"m","timeout":3,"type":"command"}],"matcher":"Bash"}]"#
368        );
369        // SessionEnd keeps the command and drops the mcp_tool; Notification has no Codex event.
370        let end = serde_json::to_string(&rendered.events()["SessionEnd"][0].to_json()).unwrap();
371        assert_eq!(
372            end,
373            r#"{"hooks":[{"command":"echo bye","type":"command"}]}"#
374        );
375        assert!(!rendered.events().contains_key("Notification"));
376        let reasons: Vec<_> = rendered
377            .skips
378            .iter()
379            .map(|s| format!("{}: {}", s.event.as_deref().unwrap(), s.reason))
380            .collect();
381        assert_eq!(
382            reasons,
383            [
384                "Notification: Codex has no such event",
385                "PreToolUse: matcher group 0, handler 1: Codex runs `command` and `mcp_tool` handlers, not `http`",
386                "PreToolUse: matcher group 0, handler 2: Codex parses `prompt` handlers but never runs them",
387                "SessionEnd: matcher group 0, handler 0: Codex runs no `mcp_tool` handler on SessionEnd",
388            ]
389        );
390    }
391
392    #[test]
393    fn codex_skips_a_group_whose_matcher_it_cannot_compile() {
394        let table = hooks(
395            r#"{"PreToolUse":[
396                {"matcher":"^(?!Read).*","hooks":[{"type":"command","command":"a"}]},
397                {"matcher":"Bash|Edit","hooks":[{"type":"command","command":"b"}]},
398                {"matcher":"mcp__.*","hooks":[{"type":"command","command":"c"}]},
399                {"matcher":"*","hooks":[{"type":"command","command":"d"}]}],
400               "Stop":[{"matcher":"(?<=x)y","hooks":[{"type":"command","command":"e"}]}]}"#,
401        );
402        let rendered = table.render(&profile(".codex"), &tokens());
403        let commands: Vec<_> = rendered.events()["PreToolUse"]
404            .iter()
405            .map(|g| {
406                g.to_json()["hooks"][0]["command"]
407                    .as_str()
408                    .unwrap()
409                    .to_owned()
410            })
411            .collect();
412        assert_eq!(commands, ["b", "c", "d"]);
413        // Codex reads no matcher on Stop, so that group stays.
414        assert!(rendered.events().contains_key("Stop"));
415        assert_eq!(rendered.skips.len(), 1);
416        assert!(
417            rendered.skips[0].reason.contains("matcher group 0")
418                && rendered.skips[0].reason.contains("look-around"),
419            "{:?}",
420            rendered.skips
421        );
422        // Claude takes all of it.
423        assert_eq!(
424            table.render(&profile(".claude"), &tokens()).events()["PreToolUse"].len(),
425            4
426        );
427    }
428
429    #[test]
430    fn a_group_with_no_handler_left_is_left_out() {
431        let rendered = hooks(r#"{"Stop":[{"hooks":[{"type":"http","url":"u"}]}]}"#)
432            .render(&profile(".codex"), &tokens());
433        assert!(rendered.is_empty());
434        assert_eq!(rendered.skips.len(), 1);
435    }
436
437    #[test]
438    fn cursor_renders_nothing_and_says_why() {
439        let cursor = hooks(MIXED).render(&profile(".cursor"), &tokens());
440        assert!(cursor.is_empty());
441        assert_eq!(cursor.skips.len(), 1);
442        assert_eq!(cursor.skips[0].event, None);
443    }
444
445    #[test]
446    fn if_on_a_non_tool_event_is_named() {
447        let table = hooks(
448            r#"{"PreToolUse":[{"hooks":[{"type":"command","command":"x","if":"Bash(*)"}]}],
449                "Stop":[{"hooks":[{"type":"command","command":"x","if":"Bash(*)"}]}]}"#,
450        );
451        assert_eq!(table.conditions_on_non_tool_events(), ["Stop"]);
452    }
453}