use std::fmt;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{ConfigError, MarsError};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum UniversalEvent {
SessionStart,
SessionEnd,
ToolPre,
ToolPost,
}
impl UniversalEvent {
pub fn parse(s: &str) -> Result<Self, MarsError> {
match s {
"session.start" => Ok(Self::SessionStart),
"session.end" => Ok(Self::SessionEnd),
"tool.pre" => Ok(Self::ToolPre),
"tool.post" => Ok(Self::ToolPost),
other => Err(MarsError::Config(ConfigError::Invalid {
message: format!(
"unknown or unsupported hook event `{other}` — \
V0 events are: session.start, session.end, tool.pre, tool.post"
),
})),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::SessionStart => "session.start",
Self::SessionEnd => "session.end",
Self::ToolPre => "tool.pre",
Self::ToolPost => "tool.post",
}
}
}
impl fmt::Display for UniversalEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind")]
pub enum HookAction {
#[serde(rename = "script")]
Script {
path: String,
},
}
#[derive(Debug, Deserialize)]
struct RawHookDef {
name: String,
event: String,
#[serde(default = "default_visibility")]
visibility: String,
#[serde(default)]
targets: Vec<String>,
action: HookAction,
#[serde(default)]
order: i32,
}
fn default_visibility() -> String {
"local".to_string()
}
#[derive(Debug, Clone)]
pub struct HookDef {
pub name: String,
pub event: UniversalEvent,
pub visibility: String,
pub targets: Vec<String>,
pub action: HookAction,
pub order: i32,
}
#[derive(Debug, Clone)]
pub struct ParsedHookItem {
pub def: HookDef,
pub source_name: String,
pub package_depth: usize,
pub decl_order: usize,
pub package_root: PathBuf,
}
fn validate_path_component(name: &str) -> Result<(), &'static str> {
if name.contains('\0') {
return Err("contains null byte");
}
for component in Path::new(name).components() {
use std::path::Component;
match component {
Component::ParentDir => return Err("contains `..` component"),
Component::RootDir | Component::Prefix(_) => {
return Err("must not be an absolute path");
}
_ => {}
}
}
Ok(())
}
fn validate_hook_script_path(path: &str) -> Result<(), &'static str> {
if path.contains('\0') {
return Err("contains null byte");
}
use std::path::Component;
for component in Path::new(path).components() {
match component {
Component::ParentDir => return Err("contains `..` component"),
Component::RootDir | Component::Prefix(_) => {
return Err("must not be an absolute path");
}
_ => {}
}
}
Ok(())
}
pub fn discover_hook_items(
package_root: &Path,
source_name: &str,
package_depth: usize,
decl_order: usize,
) -> Result<Vec<ParsedHookItem>, MarsError> {
let hooks_dir = package_root.join("hooks");
if !hooks_dir.is_dir() {
return Ok(Vec::new());
}
let mut items = Vec::new();
let mut entries: Vec<_> = std::fs::read_dir(&hooks_dir)
.map_err(MarsError::from)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let dir_name = entry.file_name();
let hook_dir_name = dir_name.to_string_lossy();
if hook_dir_name.starts_with('.') {
continue;
}
let toml_path = entry.path().join("hook.toml");
if !toml_path.is_file() {
continue;
}
let raw = std::fs::read_to_string(&toml_path).map_err(MarsError::from)?;
let raw_def: RawHookDef = toml::from_str(&raw).map_err(|e| {
MarsError::Config(ConfigError::Invalid {
message: format!("failed to parse {}: {e}", toml_path.display()),
})
})?;
let event = UniversalEvent::parse(&raw_def.event)?;
if let Err(msg) = validate_path_component(&raw_def.name) {
return Err(MarsError::Config(ConfigError::Invalid {
message: format!(
"hook in {}: invalid name `{}`: {msg}",
toml_path.display(),
raw_def.name
),
}));
}
{
let HookAction::Script {
path: ref script_path,
} = raw_def.action;
if let Err(msg) = validate_hook_script_path(script_path) {
return Err(MarsError::Config(ConfigError::Invalid {
message: format!(
"hook `{}` in {}: invalid script path `{script_path}`: {msg}",
raw_def.name,
toml_path.display()
),
}));
}
}
items.push(ParsedHookItem {
def: HookDef {
name: raw_def.name,
event,
visibility: raw_def.visibility,
targets: raw_def.targets,
action: raw_def.action,
order: raw_def.order,
},
source_name: source_name.to_string(),
package_depth,
decl_order,
package_root: package_root.to_path_buf(),
});
}
Ok(items)
}
#[derive(Debug, Clone)]
pub struct OrderedHook {
pub item: ParsedHookItem,
pub sort_key: (usize, usize, i32, String),
}
pub fn order_hooks(items: Vec<ParsedHookItem>) -> Vec<OrderedHook> {
let mut ordered: Vec<OrderedHook> = items
.into_iter()
.map(|item| {
let sort_key = (
item.package_depth,
item.decl_order,
item.def.order,
item.def.name.clone(),
);
OrderedHook { item, sort_key }
})
.collect();
ordered.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
ordered
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LossinessKind {
Exact,
Approximate,
Dropped,
}
#[derive(Debug, Clone)]
pub struct TranslatedHook {
pub hook: OrderedHook,
pub lossiness: LossinessKind,
pub native_event: Option<String>,
}
pub fn translate_hook_for_target(hook: OrderedHook, target_root: &str) -> TranslatedHook {
let (lossiness, native_event) = classify_for_target(hook.item.def.event.clone(), target_root);
TranslatedHook {
hook,
lossiness,
native_event,
}
}
fn classify_for_target(
event: UniversalEvent,
target_root: &str,
) -> (LossinessKind, Option<String>) {
match target_root {
".claude" => match event {
UniversalEvent::SessionStart => {
(LossinessKind::Exact, Some("SessionStart".to_string()))
}
UniversalEvent::SessionEnd => {
(LossinessKind::Approximate, Some("SessionStop".to_string()))
}
UniversalEvent::ToolPre => (LossinessKind::Exact, Some("PreToolUse".to_string())),
UniversalEvent::ToolPost => (LossinessKind::Exact, Some("PostToolUse".to_string())),
},
".codex" => {
let codex_event = match event {
UniversalEvent::SessionStart => "start",
UniversalEvent::SessionEnd => "stop",
UniversalEvent::ToolPre => "pre-exec",
UniversalEvent::ToolPost => "post-exec",
};
(LossinessKind::Approximate, Some(codex_event.to_string()))
}
".opencode" => {
let opencode_event = match event {
UniversalEvent::SessionStart => "session:start",
UniversalEvent::SessionEnd => "session:end",
UniversalEvent::ToolPre => "tool:before",
UniversalEvent::ToolPost => "tool:after",
};
(LossinessKind::Approximate, Some(opencode_event.to_string()))
}
".cursor" | ".pi" => {
(LossinessKind::Dropped, None)
}
_ => (LossinessKind::Dropped, None),
}
}
pub fn translate_hooks_for_target(
ordered: Vec<OrderedHook>,
target_root: &str,
) -> Vec<TranslatedHook> {
ordered
.into_iter()
.filter(|h| {
h.item.def.targets.is_empty() || h.item.def.targets.iter().any(|t| t == target_root)
})
.map(|h| translate_hook_for_target(h, target_root))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_hook_toml_dir(dir: &Path, hook_name: &str, toml: &str) {
let hook_dir = dir.join("hooks").join(hook_name);
std::fs::create_dir_all(&hook_dir).unwrap();
std::fs::write(hook_dir.join("hook.toml"), toml).unwrap();
}
fn make_script_hook(dir: &Path, hook_name: &str, event: &str) {
make_hook_toml_dir(
dir,
hook_name,
&format!(
r#"
name = "{hook_name}"
event = "{event}"
[action]
kind = "script"
path = "./run.sh"
"#
),
);
}
#[test]
fn discover_finds_hook_items() {
let tmp = TempDir::new().unwrap();
make_script_hook(tmp.path(), "audit", "tool.pre");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].def.name, "audit");
assert_eq!(items[0].def.event, UniversalEvent::ToolPre);
}
#[test]
fn discover_empty_when_no_hooks_dir() {
let tmp = TempDir::new().unwrap();
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
assert!(items.is_empty());
}
#[test]
fn discover_rejects_non_v0_event() {
let tmp = TempDir::new().unwrap();
make_hook_toml_dir(
tmp.path(),
"bad-hook",
r#"
name = "bad"
event = "spawn.created"
[action]
kind = "script"
path = "./run.sh"
"#,
);
let result = discover_hook_items(tmp.path(), "base", 0, 0);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("spawn.created"));
}
#[test]
fn universal_event_parse_accepts_all_v0() {
assert!(UniversalEvent::parse("session.start").is_ok());
assert!(UniversalEvent::parse("session.end").is_ok());
assert!(UniversalEvent::parse("tool.pre").is_ok());
assert!(UniversalEvent::parse("tool.post").is_ok());
}
#[test]
fn universal_event_parse_rejects_unknown() {
let err = UniversalEvent::parse("work.start").unwrap_err();
assert!(err.to_string().contains("work.start"));
}
#[test]
fn order_hooks_depth_first() {
let tmp_root = TempDir::new().unwrap();
let tmp_dep = TempDir::new().unwrap();
make_script_hook(tmp_root.path(), "root-hook", "tool.pre");
make_script_hook(tmp_dep.path(), "dep-hook", "tool.pre");
let mut root_items = discover_hook_items(tmp_root.path(), "root", 0, 0).unwrap();
let dep_items = discover_hook_items(tmp_dep.path(), "dep", 1, 0).unwrap();
root_items.extend(dep_items);
let ordered = order_hooks(root_items);
assert_eq!(ordered[0].item.def.name, "root-hook");
assert_eq!(ordered[1].item.def.name, "dep-hook");
}
#[test]
fn order_hooks_explicit_order_field() {
let tmp = TempDir::new().unwrap();
make_hook_toml_dir(
tmp.path(),
"hook-b",
r#"
name = "hook-b"
event = "tool.pre"
order = 10
[action]
kind = "script"
path = "./b.sh"
"#,
);
make_hook_toml_dir(
tmp.path(),
"hook-a",
r#"
name = "hook-a"
event = "tool.pre"
order = 5
[action]
kind = "script"
path = "./a.sh"
"#,
);
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let ordered = order_hooks(items);
assert_eq!(ordered[0].item.def.name, "hook-a");
assert_eq!(ordered[1].item.def.name, "hook-b");
}
#[test]
fn order_hooks_name_as_tiebreaker() {
let tmp = TempDir::new().unwrap();
make_script_hook(tmp.path(), "zebra", "tool.pre");
make_script_hook(tmp.path(), "alpha", "tool.pre");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let ordered = order_hooks(items);
assert_eq!(ordered[0].item.def.name, "alpha");
assert_eq!(ordered[1].item.def.name, "zebra");
}
#[test]
fn translate_claude_tool_pre_is_exact() {
let tmp = TempDir::new().unwrap();
make_script_hook(tmp.path(), "audit", "tool.pre");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let ordered = order_hooks(items);
let translated = translate_hook_for_target(ordered.into_iter().next().unwrap(), ".claude");
assert_eq!(translated.lossiness, LossinessKind::Exact);
assert_eq!(translated.native_event.as_deref(), Some("PreToolUse"));
}
#[test]
fn translate_claude_session_end_is_approximate() {
let tmp = TempDir::new().unwrap();
make_script_hook(tmp.path(), "cleanup", "session.end");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let ordered = order_hooks(items);
let translated = translate_hook_for_target(ordered.into_iter().next().unwrap(), ".claude");
assert_eq!(translated.lossiness, LossinessKind::Approximate);
assert_eq!(translated.native_event.as_deref(), Some("SessionStop"));
}
#[test]
fn translate_cursor_is_dropped() {
let tmp = TempDir::new().unwrap();
make_script_hook(tmp.path(), "hook", "tool.pre");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let ordered = order_hooks(items);
let translated = translate_hook_for_target(ordered.into_iter().next().unwrap(), ".cursor");
assert_eq!(translated.lossiness, LossinessKind::Dropped);
assert!(translated.native_event.is_none());
}
#[test]
fn translate_hooks_filters_by_target() {
let tmp = TempDir::new().unwrap();
make_hook_toml_dir(
tmp.path(),
"claude-only",
r#"
name = "claude-only"
event = "tool.pre"
targets = [".claude"]
[action]
kind = "script"
path = "./run.sh"
"#,
);
make_script_hook(tmp.path(), "all-targets", "tool.post");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let ordered = order_hooks(items);
let claude_hooks = translate_hooks_for_target(ordered.clone(), ".claude");
assert_eq!(claude_hooks.len(), 2);
let codex_hooks = translate_hooks_for_target(ordered, ".codex");
assert_eq!(codex_hooks.len(), 1);
assert_eq!(codex_hooks[0].hook.item.def.name, "all-targets");
}
#[test]
fn ordering_is_deterministic_across_multiple_calls() {
let tmp = TempDir::new().unwrap();
make_script_hook(tmp.path(), "c-hook", "tool.pre");
make_script_hook(tmp.path(), "a-hook", "session.start");
make_script_hook(tmp.path(), "b-hook", "tool.post");
let items = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let first: Vec<String> = order_hooks(items.clone())
.iter()
.map(|h| h.item.def.name.clone())
.collect();
for _ in 0..5 {
let items2 = discover_hook_items(tmp.path(), "base", 0, 0).unwrap();
let current: Vec<String> = order_hooks(items2)
.iter()
.map(|h| h.item.def.name.clone())
.collect();
assert_eq!(first, current);
}
}
#[test]
fn discover_rejects_dotdot_in_script_path() {
let tmp = TempDir::new().unwrap();
make_hook_toml_dir(
tmp.path(),
"bad-hook",
r#"
name = "bad"
event = "tool.pre"
[action]
kind = "script"
path = "../../etc/passwd"
"#,
);
let result = discover_hook_items(tmp.path(), "base", 0, 0);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains(".."), "expected traversal error, got: {msg}");
}
#[test]
fn discover_rejects_absolute_script_path() {
let tmp = TempDir::new().unwrap();
make_hook_toml_dir(
tmp.path(),
"bad-hook",
r#"
name = "bad"
event = "tool.pre"
[action]
kind = "script"
path = "/etc/passwd"
"#,
);
let result = discover_hook_items(tmp.path(), "base", 0, 0);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("absolute"),
"expected absolute-path error, got: {msg}"
);
}
#[test]
fn validate_path_component_rejects_dotdot() {
assert!(validate_path_component("..").is_err());
assert!(validate_path_component("../foo").is_err());
}
#[test]
fn validate_path_component_accepts_normal_name() {
assert!(validate_path_component("my-hook").is_ok());
assert!(validate_path_component("audit_v2").is_ok());
}
#[test]
fn validate_hook_script_path_rejects_null_byte() {
assert!(validate_hook_script_path("run\0.sh").is_err());
}
#[test]
fn validate_hook_script_path_accepts_relative() {
assert!(validate_hook_script_path("./run.sh").is_ok());
assert!(validate_hook_script_path("run.sh").is_ok());
assert!(validate_hook_script_path("scripts/run.sh").is_ok());
}
}