pub mod claude_code;
pub mod cursor;
pub mod gemini_cli;
pub mod session_banner;
pub(crate) mod synth;
pub mod types;
pub mod windsurf;
pub(crate) trait PayloadAdapter {
type Raw: serde::de::DeserializeOwned;
const PARSE_LABEL: &'static str;
fn into_canonical(raw: Self::Raw) -> Result<types::HookEvent, String>;
fn parse_stdin_default(raw: &str) -> Result<types::HookEvent, String> {
let payload: Self::Raw = serde_json::from_str(raw.trim())
.map_err(|e| format!("invalid {} hook JSON: {e}", Self::PARSE_LABEL))?;
Self::into_canonical(payload)
}
}
pub trait PlatformAdapter: Send + Sync {
fn name(&self) -> &'static str;
fn parse_stdin(&self, raw: &str) -> Result<types::HookEvent, String>;
fn format_output(&self, result: types::HookResult) -> String;
fn classify_error(&self, err: &anyhow::Error) -> types::ErrorClass {
default_classify_error(err)
}
}
pub fn default_classify_error(err: &anyhow::Error) -> types::ErrorClass {
use types::ErrorClass;
for cause in err.chain() {
if let Some(re) = cause.downcast_ref::<reqwest::Error>() {
if re.is_timeout() || re.is_connect() {
return ErrorClass::Transport;
}
if let Some(status) = re.status() {
if status.is_server_error() {
return ErrorClass::Transport;
}
if status.as_u16() == 429 || status.as_u16() == 408 {
return ErrorClass::Transport;
}
if status.is_client_error() {
return ErrorClass::Client;
}
}
}
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, NotConnected, TimedOut};
if matches!(
io.kind(),
ConnectionRefused | TimedOut | ConnectionReset | NotConnected
) {
return ErrorClass::Transport;
}
}
if cause.downcast_ref::<serde_json::Error>().is_some() {
return ErrorClass::Client;
}
}
ErrorClass::Fatal
}
#[cfg(test)]
mod classifier_tests {
use super::*;
use types::ErrorClass;
#[test]
fn io_kinds_map_to_expected_class() {
use std::io::ErrorKind;
let cases: &[(ErrorKind, ErrorClass)] = &[
(ErrorKind::ConnectionRefused, ErrorClass::Transport),
(ErrorKind::TimedOut, ErrorClass::Transport),
(ErrorKind::ConnectionReset, ErrorClass::Transport),
(ErrorKind::NotConnected, ErrorClass::Transport),
(ErrorKind::PermissionDenied, ErrorClass::Fatal),
];
for (kind, want) in cases {
let err: anyhow::Error = std::io::Error::new(*kind, "x").into();
assert_eq!(default_classify_error(&err), *want, "for {kind:?}");
}
}
#[test]
fn serde_parse_error_is_client_and_plain_anyhow_is_fatal() {
let parse_err = serde_json::from_str::<serde_json::Value>("{not json").unwrap_err();
let err: anyhow::Error = parse_err.into();
assert_eq!(default_classify_error(&err), ErrorClass::Client);
let err = anyhow::anyhow!("something exploded");
assert_eq!(default_classify_error(&err), ErrorClass::Fatal);
}
#[test]
fn wrapped_io_transport_still_classifies_through_context() {
let root: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "down").into();
let wrapped = root
.context("fetch relevant rules")
.context("hook dispatch");
assert_eq!(default_classify_error(&wrapped), ErrorClass::Transport);
}
}
pub fn get_platform_adapter(client_name: &str) -> Box<dyn PlatformAdapter> {
let normalized = client_name.to_ascii_lowercase();
match normalized.as_str() {
"cursor" => Box::new(cursor::CursorAdapter),
"gemini-cli" | "gemini_cli" | "gemini" => Box::new(gemini_cli::GeminiCliAdapter),
"windsurf" => Box::new(windsurf::WindsurfAdapter),
_ => Box::new(claude_code::ClaudeCodeAdapter),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_routes_aliases_and_unknown_falls_back_to_claude_code() {
let cases: &[(&str, &str)] = &[
("claude-code", "claude-code"),
("claude_code", "claude-code"),
("claude", "claude-code"),
("cursor", "cursor"),
("Cursor", "cursor"),
("gemini-cli", "gemini-cli"),
("gemini_cli", "gemini-cli"),
("gemini", "gemini-cli"),
("Gemini-CLI", "gemini-cli"),
("windsurf", "windsurf"),
("Windsurf", "windsurf"),
("definitely-not-a-real-client", "claude-code"),
];
for (input, want) in cases {
assert_eq!(
get_platform_adapter(input).name(),
*want,
"alias {input} misrouted"
);
}
}
}