use serde_json::{Map, Value, json};
use super::Tokens;
use super::portable::{MatcherGroup, NO_COMMAND};
use super::render::{Output, Rendered, Skip};
use super::targets::{self, Profile};
const CORE: &str = include_str!("typescript/core.ts");
#[derive(Debug, PartialEq, Eq)]
pub struct Harness {
pub name: &'static str,
glue: &'static str,
unsupported_events: &'static [(&'static str, &'static str)],
answer_limits: &'static [(&'static str, &'static str)],
native_hook_needs_default_export: bool,
}
pub(super) const OPENCODE: Harness = Harness {
name: "OpenCode",
glue: include_str!("typescript/opencode.ts"),
unsupported_events: &[
(
"SessionStart",
"OpenCode has no per-session start hook (a plugin's setup runs once per project)",
),
(
"Stop",
"OpenCode's end of a turn is a bus event that is not awaited",
),
(
"PostCompact",
"OpenCode reports a finished compaction only on its bus",
),
(
"SessionEnd",
"OpenCode reports a deleted session only on its bus",
),
],
answer_limits: &[
(
"UserPromptSubmit",
"a block answer stops the prompt, but OpenCode shows a generic error instead of the reason",
),
(
"PreCompact",
"OpenCode cannot cancel a compaction: the script runs and a block answer is ignored",
),
],
native_hook_needs_default_export: true,
};
pub(super) const PI: Harness = Harness {
name: "Pi",
glue: include_str!("typescript/pi.ts"),
unsupported_events: &[("PermissionRequest", "Pi has no permission system")],
answer_limits: &[(
"UserPromptSubmit",
"a block answer stops the prompt, but Pi shows the reason only in its UI: print mode shows nothing",
)],
native_hook_needs_default_export: false,
};
impl Harness {
pub(super) fn native_hook_problem(&self, content: &str) -> Option<&'static str> {
if !self.native_hook_needs_default_export {
return None;
}
let tokens: Vec<&str> = content
.split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '$')))
.filter(|token| !token.is_empty())
.collect();
let has_default_export = tokens
.windows(2)
.any(|pair| matches!(pair, ["export", "default"] | ["as", "default"]));
(!has_default_export)
.then_some("has no recognizable default export; add a default `{ id, setup }` export")
}
}
fn default_timeout(claude: &targets::Event) -> serde_json::Number {
let ms = claude.default_timeout_ms;
if ms.is_multiple_of(1000) {
(ms / 1000).into()
} else {
serde_json::Number::from_f64(f64::from(ms) / 1000.0).expect("finite")
}
}
pub(super) fn render(
table: &[(&str, &[MatcherGroup])],
profile: &Profile,
harness: &Harness,
tokens: &Tokens<'_>,
) -> Rendered {
let harness_name = harness.name;
let mut skips = Vec::new();
let mut answer_limits = Vec::new();
let mut events = Map::new();
let mut skip = |event: &str, reason: String| {
skips.push(Skip {
event: Some(event.to_owned()),
reason,
})
};
for &(event, groups) in table {
let claude = targets::claude_event(event).expect("render passes only Claude's events");
let Some(row) = profile.event(event) else {
let reason = harness
.unsupported_events
.iter()
.find(|(name, _)| *name == event)
.map_or_else(
|| format!("{harness_name} has no such event"),
|(_, reason)| (*reason).to_owned(),
);
skip(event, reason);
continue;
};
let mut kept_groups = Vec::new();
for (g, group) in groups.iter().enumerate() {
let matcher = group
.matcher
.as_deref()
.filter(|m| !m.is_empty() && *m != "*");
if let (Some(matcher), Some(field), None) = (matcher, claude.matcher, row.matcher) {
skip(
event,
format!(
"matcher group {g}: {harness_name} gives `{event}` no `{field}` to test `{matcher}` against"
),
);
continue;
}
let mut kept = Vec::new();
for (h, handler) in group.handlers.iter().enumerate() {
let at = format!("matcher group {g}, handler {h}");
if handler.kind != "command" {
skip(
event,
format!(
"{at}: {harness_name} runs `command` handlers, not `{}`",
handler.kind
),
);
continue;
}
let Some(command) = handler.command_to_run() else {
skip(event, format!("{at}: {NO_COMMAND}"));
continue;
};
if handler.condition.is_some() && !claude.is_tool_event() {
skip(
event,
format!("{at}: Claude never runs a handler with `if` on a non-tool event"),
);
continue;
}
let windows = match handler.extra.get("commandWindows") {
Some(crate::ordered::OrderedValue::String(text)) => {
Some(tokens.substitute(text))
}
_ => None,
};
kept.push(json!({
"command": tokens.substitute(command),
"commandWindows": windows,
"timeout": if handler.is_async == Some(true) {
None
} else {
Some(handler.timeout.clone().unwrap_or_else(|| default_timeout(claude)))
},
"if": handler.condition.as_deref().map(|c| tokens.substitute(c)),
"onFailure": handler.on_failure,
"async": handler.is_async.unwrap_or(false),
}));
}
if !kept.is_empty() {
kept_groups.push(json!({
"matcher": group.matcher.as_deref().map(|m| tokens.substitute(m)),
"handlers": kept,
}));
}
}
if kept_groups.is_empty() {
continue;
}
if let Some((_, limit)) = harness
.answer_limits
.iter()
.find(|(name, _)| *name == event)
{
answer_limits.push(Skip {
event: Some(event.to_owned()),
reason: (*limit).to_owned(),
});
}
events.insert(event.to_owned(), Value::Array(kept_groups));
}
let text = (!events.is_empty()).then(|| {
let verified = profile.verified_against.unwrap_or(harness_name);
let hook = json!({
"name": tokens.hook_name,
"harness": harness_name,
"verified": verified,
"events": events,
});
format!(
"// Generated by mars from hook `{}`. Mars rewrites this file on every sync: edit the hook, not this file.\nconst HOOK: any = {};\n\n{CORE}{}",
tokens.hook_name,
serde_json::to_string_pretty(&hook).expect("a JSON value serializes"),
harness.glue
)
});
Rendered {
output: Output::File { text },
skips,
answer_limits,
}
}