1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
16pub struct Skip {
17 pub event: Option<String>,
18 pub reason: String,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct Rendered {
24 pub output: Output,
25 pub skips: Vec<Skip>,
26 pub answer_limits: Vec<Skip>,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum Output {
33 Merge {
36 events: BTreeMap<String, Vec<OrderedValue>>,
37 unchecked_events: Vec<String>,
39 },
40 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 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 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 pub fn render(&self, profile: &Profile, tokens: &Tokens<'_>) -> Rendered {
73 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 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 pub fn renders_on(&self, profile: &Profile) -> bool {
125 !self.render(profile, &Tokens::default()).is_empty()
126 }
127
128 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
144fn 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
221fn 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
247fn 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
300fn 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 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 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 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 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}