1use std::sync::LazyLock;
2
3pub mod claude;
4pub mod codex;
5pub(crate) mod common;
6pub(crate) mod redaction;
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ParsedHookEvent {
11 pub session_id: String,
12 pub cwd: Option<String>,
13 pub project: String,
14 pub reference_time_epoch: Option<i64>,
15 pub tool_name: String,
16 pub tool_input: Option<serde_json::Value>,
17 pub tool_response: Option<serde_json::Value>,
18}
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct EventSummary {
23 pub event_type: String,
24 pub summary: String,
25 pub detail: Option<String>,
26 pub files_json: Option<String>,
27 pub exit_code: Option<i32>,
28}
29
30pub trait ToolAdapter: Send + Sync {
33 fn name(&self) -> &str;
35
36 fn parse_hook(&self, raw_json: &str) -> Option<ParsedHookEvent>;
39
40 fn should_skip(&self, event: &ParsedHookEvent) -> bool;
42
43 fn should_skip_bash(&self, command: &str) -> bool;
45
46 fn classify_event(&self, event: &ParsedHookEvent) -> Option<EventSummary>;
48}
49
50static ADAPTERS: LazyLock<Vec<Box<dyn ToolAdapter>>> = LazyLock::new(|| {
51 vec![
52 Box::new(crate::adapter::claude::ClaudeCodeAdapter),
53 Box::new(crate::adapter::codex::CodexAdapter),
54 ]
55});
56
57pub fn detect_adapter(raw_json: &str) -> Option<(&'static dyn ToolAdapter, ParsedHookEvent)> {
59 if let Ok(name) = std::env::var("REMEM_HOOK_ADAPTER") {
60 if !name.trim().is_empty() {
61 return detect_adapter_by_name(raw_json, &name);
62 }
63 }
64
65 for adapter in ADAPTERS.iter() {
66 if let Some(event) = adapter.parse_hook(raw_json) {
67 return Some((adapter.as_ref(), event));
68 }
69 }
70 None
71}
72
73pub(crate) fn detect_adapter_by_name(
74 raw_json: &str,
75 name: &str,
76) -> Option<(&'static dyn ToolAdapter, ParsedHookEvent)> {
77 for adapter in ADAPTERS.iter() {
78 if adapter.name() == name {
79 return adapter
80 .parse_hook(raw_json)
81 .map(|event| (adapter.as_ref(), event));
82 }
83 }
84 None
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn detect_claude_code_input() {
93 let json = r#"{"session_id":"s1","cwd":"/tmp","tool_name":"Edit","tool_input":{"file_path":"x.rs"}}"#;
94 let result = detect_adapter(json);
95 assert!(result.is_some());
96 let (adapter, event) = result.unwrap();
97 assert_eq!(adapter.name(), "claude-code");
98 assert_eq!(event.session_id, "s1");
99 assert_eq!(event.tool_name, "Edit");
100 assert_eq!(event.reference_time_epoch, None);
101 }
102
103 #[test]
104 fn detect_codex_input_when_strategy_is_forced() {
105 let json = r#"{"session_id":"s1","cwd":"/tmp","hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"python test.py"},"tool_result":{"exitCode":0}}"#;
106 let result = detect_adapter_by_name(json, "codex-cli");
107 assert!(result.is_some());
108 let (adapter, event) = result.unwrap();
109 assert_eq!(adapter.name(), "codex-cli");
110 assert_eq!(event.session_id, "s1");
111 assert_eq!(event.tool_name, "Bash");
112 assert_eq!(event.tool_response.unwrap()["exitCode"], 0);
113 }
114
115 #[test]
116 fn forced_strategy_keeps_adapter_selection_isolated() {
117 let json = r#"{"session_id":"s1","cwd":"/tmp","tool_name":"Edit","tool_input":{"file_path":"x.rs"}}"#;
118
119 let (adapter, _) = detect_adapter_by_name(json, "codex-cli").unwrap();
120 assert_eq!(adapter.name(), "codex-cli");
121 let (adapter, _) = detect_adapter_by_name(json, "claude-code").unwrap();
122 assert_eq!(adapter.name(), "claude-code");
123 assert!(detect_adapter_by_name(json, "missing-adapter").is_none());
124 }
125
126 #[test]
127 fn detect_unknown_input_returns_none() {
128 let json = r#"{"unknown_field": true}"#;
129 assert!(detect_adapter(json).is_none());
130 }
131}