use std::collections::BTreeMap;
use serde_json::Number;
use super::Tokens;
use super::portable::{Handler, MatcherGroup, NO_COMMAND, PortableHooks};
use super::targets::{self, Profile, Rules};
use crate::ordered::OrderedValue;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Skip {
pub event: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Rendered {
pub output: Output,
pub skips: Vec<Skip>,
pub answer_limits: Vec<Skip>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Output {
Merge {
events: BTreeMap<String, Vec<OrderedValue>>,
unchecked_events: Vec<String>,
},
File { text: Option<String> },
}
impl Rendered {
fn skip(&mut self, event: &str, reason: String) {
self.skips.push(Skip {
event: Some(event.to_owned()),
reason,
});
}
pub fn is_empty(&self) -> bool {
match &self.output {
Output::Merge { events, .. } => events.is_empty(),
Output::File { text } => text.is_none(),
}
}
pub fn events(&self) -> &BTreeMap<String, Vec<OrderedValue>> {
static NONE: BTreeMap<String, Vec<OrderedValue>> = BTreeMap::new();
match &self.output {
Output::Merge { events, .. } => events,
Output::File { .. } => &NONE,
}
}
}
impl PortableHooks {
pub fn render(&self, profile: &Profile, tokens: &Tokens<'_>) -> Rendered {
let mut events: Vec<_> = self.events().collect();
let mut unknown = Vec::new();
if matches!(profile.rules, Rules::Codex | Rules::TypeScript(_)) {
events.retain(|(event, _)| {
let known = targets::claude_event(event).is_some();
if !known {
unknown.push(Skip {
event: Some((*event).to_owned()),
reason: "not an event mars knows, so `unchecked` renders it on Claude only"
.into(),
});
}
known
});
}
let mut rendered = match profile.rules {
Rules::Claude => render_claude(&events, profile),
Rules::Codex => render_codex(&events, profile),
Rules::Withheld { reason } => Rendered {
output: Output::Merge {
events: BTreeMap::new(),
unchecked_events: Vec::new(),
},
skips: vec![Skip {
event: None,
reason: reason.into(),
}],
answer_limits: Vec::new(),
},
Rules::TypeScript(harness) => {
super::typescript::render(&events, profile, harness, tokens)
}
};
if !unknown.is_empty() {
rendered.skips.extend(unknown);
rendered.skips.sort_by(|a, b| a.event.cmp(&b.event));
}
if let Output::Merge { events, .. } = &mut rendered.output {
for entries in events.values_mut() {
entries
.iter_mut()
.for_each(|entry| tokens.substitute_value(entry));
}
}
rendered
}
pub fn renders_on(&self, profile: &Profile) -> bool {
!self.render(profile, &Tokens::default()).is_empty()
}
pub fn conditions_on_non_tool_events(&self) -> Vec<&str> {
self.events()
.filter(|(event, groups)| {
let tool_event = targets::claude_event(event).is_some_and(|e| e.is_tool_event());
!tool_event
&& groups
.iter()
.flat_map(|group| &group.handlers)
.any(|handler| handler.condition.is_some())
})
.map(|(event, _)| event)
.collect()
}
}
fn render_claude(table: &[(&str, &[MatcherGroup])], profile: &Profile) -> Rendered {
let mut events = BTreeMap::new();
let mut unchecked_events = Vec::new();
for &(event, groups) in table {
if profile.event(event).is_none() {
unchecked_events.push(event.to_owned());
}
events.insert(
event.to_owned(),
groups.iter().map(MatcherGroup::to_claude).collect(),
);
}
Rendered {
output: Output::Merge {
events,
unchecked_events,
},
skips: Vec::new(),
answer_limits: Vec::new(),
}
}
fn render_codex(table: &[(&str, &[MatcherGroup])], profile: &Profile) -> Rendered {
let mut rendered = Rendered {
output: Output::Merge {
events: BTreeMap::new(),
unchecked_events: Vec::new(),
},
skips: Vec::new(),
answer_limits: Vec::new(),
};
let mut rendered_events = BTreeMap::new();
for &(event, groups) in table {
let Some(row) = profile.event(event) else {
rendered.skip(event, "Codex has no such event".into());
continue;
};
let matched = row.matcher.is_some();
let mut entries = Vec::new();
for (g, group) in groups.iter().enumerate() {
if let (true, Some(matcher)) = (matched, &group.matcher)
&& let Err(reason) = codex_matcher(matcher)
{
rendered.skip(
event,
format!(
"matcher group {g}: Codex cannot compile matcher `{matcher}`: {reason}"
),
);
continue;
}
let mut kept = Vec::new();
for (h, handler) in group.handlers.iter().enumerate() {
match codex_handler(event, handler) {
Ok(handler) => kept.push(handler),
Err(reason) => {
rendered.skip(event, format!("matcher group {g}, handler {h}: {reason}"))
}
}
}
if !kept.is_empty() {
entries.push(group.to_value(&kept));
}
}
if !entries.is_empty() {
rendered_events.insert(event.to_owned(), entries);
}
}
rendered.output = Output::Merge {
events: rendered_events,
unchecked_events: Vec::new(),
};
rendered
}
fn codex_matcher(pattern: &str) -> Result<(), String> {
use regex_syntax::Error;
if pattern.is_empty()
|| pattern == "*"
|| pattern
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '|')
{
return Ok(());
}
regex_syntax::Parser::new()
.parse(pattern)
.map(drop)
.map_err(|error| match error {
Error::Parse(error) => error.kind().to_string(),
Error::Translate(error) => error.kind().to_string(),
other => other.to_string(),
})
}
fn codex_handler(event: &str, handler: &Handler) -> Result<Handler, String> {
let extra = |key: &str| handler.extra.get(key);
let text = |key: &str| match extra(key) {
None => Ok(None),
Some(OrderedValue::String(text)) => Ok(Some(text.as_str())),
Some(_) => Err(format!("`{key}` must be a string for Codex")),
};
match handler.kind.as_str() {
"command" => {
if handler.command_to_run().is_none() {
return Err(NO_COMMAND.into());
}
text("commandWindows")?;
match extra("additionalContextLimit") {
None => {}
Some(OrderedValue::Number(n)) if n.as_u64().is_some() => {}
Some(_) => return Err("`additionalContextLimit` must be a whole number".into()),
}
}
"mcp_tool" => {
if event == "SessionEnd" {
return Err("Codex runs no `mcp_tool` handler on SessionEnd".into());
}
for key in ["server", "tool"] {
if text(key)?.is_none_or(|value| value.trim().is_empty()) {
return Err(format!("an `mcp_tool` handler needs a non-empty `{key}`"));
}
}
if extra("input").is_some_and(|input| !matches!(input, OrderedValue::Object(_))) {
return Err("`input` must be a table".into());
}
}
"prompt" | "agent" => {
return Err(format!(
"Codex parses `{}` handlers but never runs them",
handler.kind
));
}
other => {
return Err(format!(
"Codex runs `command` and `mcp_tool` handlers, not `{other}`"
));
}
}
let mut handler = handler.clone();
handler.timeout = handler.timeout.as_ref().map(whole_seconds).transpose()?;
Ok(handler)
}
fn whole_seconds(timeout: &Number) -> Result<Number, String> {
if let Some(problem) = super::portable::timeout_problem(timeout) {
return Err(problem);
}
Ok(match timeout.as_u64() {
Some(seconds) => seconds.into(),
None => (timeout.as_f64().unwrap_or(0.0).ceil() as u64).into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(root: &str) -> Profile {
targets::target(root).unwrap().profile
}
fn hooks(json: &str) -> PortableHooks {
PortableHooks::from_claude_json(json).unwrap().0
}
fn tokens() -> Tokens<'static> {
Tokens {
hook_dir: "/p/.codex/hooks/audit",
hook_target: ".codex",
..Default::default()
}
}
const MIXED: &str = r#"{
"PreToolUse":[{"matcher":"Bash","hooks":[
{"type":"command","command":"bash ${MARS_HOOK_DIR}/run.sh ${MARS_HOOK_TARGET}",
"timeout":2.5,"if":"Bash(*tmux*)","onFailure":"block","statusMessage":"m"},
{"type":"http","url":"https://example.test"},
{"type":"prompt"}]}],
"SessionEnd":[{"hooks":[{"type":"mcp_tool","server":"s","tool":"t"},
{"type":"command","command":"echo bye"}]}],
"Notification":[{"hooks":[{"type":"command","command":"echo n"}]}]}"#;
#[test]
fn claude_gets_the_table_as_written_with_tokens_substituted() {
let rendered = hooks(MIXED).render(&profile(".claude"), &tokens());
assert!(rendered.skips.is_empty());
let json = serde_json::to_string(&rendered.events()["PreToolUse"][0].to_json()).unwrap();
assert!(
json.contains("bash /p/.codex/hooks/audit/run.sh .codex"),
"{json}"
);
assert!(json.contains(r#""timeout":2.5"#));
assert_eq!(rendered.events().len(), 3);
}
#[test]
fn codex_gets_what_codex_parses_and_reports_the_rest() {
let rendered = hooks(MIXED).render(&profile(".codex"), &tokens());
let pre = serde_json::to_string(
&rendered.events()["PreToolUse"]
.iter()
.map(OrderedValue::to_json)
.collect::<Vec<_>>(),
)
.unwrap();
assert_eq!(
pre,
r#"[{"hooks":[{"command":"bash /p/.codex/hooks/audit/run.sh .codex","if":"Bash(*tmux*)","onFailure":"block","statusMessage":"m","timeout":3,"type":"command"}],"matcher":"Bash"}]"#
);
let end = serde_json::to_string(&rendered.events()["SessionEnd"][0].to_json()).unwrap();
assert_eq!(
end,
r#"{"hooks":[{"command":"echo bye","type":"command"}]}"#
);
assert!(!rendered.events().contains_key("Notification"));
let reasons: Vec<_> = rendered
.skips
.iter()
.map(|s| format!("{}: {}", s.event.as_deref().unwrap(), s.reason))
.collect();
assert_eq!(
reasons,
[
"Notification: Codex has no such event",
"PreToolUse: matcher group 0, handler 1: Codex runs `command` and `mcp_tool` handlers, not `http`",
"PreToolUse: matcher group 0, handler 2: Codex parses `prompt` handlers but never runs them",
"SessionEnd: matcher group 0, handler 0: Codex runs no `mcp_tool` handler on SessionEnd",
]
);
}
#[test]
fn codex_skips_a_group_whose_matcher_it_cannot_compile() {
let table = hooks(
r#"{"PreToolUse":[
{"matcher":"^(?!Read).*","hooks":[{"type":"command","command":"a"}]},
{"matcher":"Bash|Edit","hooks":[{"type":"command","command":"b"}]},
{"matcher":"mcp__.*","hooks":[{"type":"command","command":"c"}]},
{"matcher":"*","hooks":[{"type":"command","command":"d"}]}],
"Stop":[{"matcher":"(?<=x)y","hooks":[{"type":"command","command":"e"}]}]}"#,
);
let rendered = table.render(&profile(".codex"), &tokens());
let commands: Vec<_> = rendered.events()["PreToolUse"]
.iter()
.map(|g| {
g.to_json()["hooks"][0]["command"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
assert_eq!(commands, ["b", "c", "d"]);
assert!(rendered.events().contains_key("Stop"));
assert_eq!(rendered.skips.len(), 1);
assert!(
rendered.skips[0].reason.contains("matcher group 0")
&& rendered.skips[0].reason.contains("look-around"),
"{:?}",
rendered.skips
);
assert_eq!(
table.render(&profile(".claude"), &tokens()).events()["PreToolUse"].len(),
4
);
}
#[test]
fn a_group_with_no_handler_left_is_left_out() {
let rendered = hooks(r#"{"Stop":[{"hooks":[{"type":"http","url":"u"}]}]}"#)
.render(&profile(".codex"), &tokens());
assert!(rendered.is_empty());
assert_eq!(rendered.skips.len(), 1);
}
#[test]
fn cursor_renders_nothing_and_says_why() {
let cursor = hooks(MIXED).render(&profile(".cursor"), &tokens());
assert!(cursor.is_empty());
assert_eq!(cursor.skips.len(), 1);
assert_eq!(cursor.skips[0].event, None);
}
#[test]
fn if_on_a_non_tool_event_is_named() {
let table = hooks(
r#"{"PreToolUse":[{"hooks":[{"type":"command","command":"x","if":"Bash(*)"}]}],
"Stop":[{"hooks":[{"type":"command","command":"x","if":"Bash(*)"}]}]}"#,
);
assert_eq!(table.conditions_on_non_tool_events(), ["Stop"]);
}
}