1use crate::executor::{HookExecutor, HookResult};
7use crate::models::{HookConfig, HookEvent};
8use serde_json::{Map, Value};
9use tracing::warn;
10
11#[derive(Debug, Clone, Default)]
13pub struct HookOutcome {
14 pub blocked: bool,
16 pub block_reason: String,
18 pub results: Vec<HookResult>,
20 pub additional_context: Option<String>,
22 pub updated_input: Option<Value>,
24 pub permission_decision: Option<String>,
26 pub decision: Option<String>,
28}
29
30impl HookOutcome {
31 pub fn allowed(&self) -> bool {
33 !self.blocked
34 }
35}
36
37pub struct HookManager {
43 config: HookConfig,
44 session_id: String,
45 cwd: String,
46 executor: HookExecutor,
47}
48
49impl HookManager {
50 pub fn new(config: HookConfig, session_id: impl Into<String>, cwd: impl Into<String>) -> Self {
55 Self {
56 config,
57 session_id: session_id.into(),
58 cwd: cwd.into(),
59 executor: HookExecutor::new(),
60 }
61 }
62
63 pub fn noop() -> Self {
65 Self::new(HookConfig::empty(), "", "")
66 }
67
68 pub fn has_hooks_for(&self, event: HookEvent) -> bool {
70 self.config.has_hooks_for(event)
71 }
72
73 pub async fn run_hooks(
82 &self,
83 event: HookEvent,
84 match_value: Option<&str>,
85 event_data: Option<&Value>,
86 ) -> HookOutcome {
87 let mut outcome = HookOutcome::default();
88
89 let matchers = self.config.get_matchers(event);
90 if matchers.is_empty() {
91 return outcome;
92 }
93
94 for matcher in matchers {
95 if !matcher.matches(match_value) {
96 continue;
97 }
98
99 let stdin_data = self.build_stdin(event, match_value, event_data);
100
101 for command in &matcher.hooks {
102 let result = self.executor.execute(command, &stdin_data).await;
103
104 if result.should_block() {
105 let parsed = result.parse_json_output();
106 outcome.block_reason = parsed
107 .get("reason")
108 .and_then(|v| v.as_str())
109 .map(|s| s.to_string())
110 .unwrap_or_else(|| {
111 let stderr = result.stderr.trim();
112 if stderr.is_empty() {
113 "Blocked by hook".to_string()
114 } else {
115 stderr.to_string()
116 }
117 });
118 outcome.decision = parsed
119 .get("decision")
120 .and_then(|v| v.as_str())
121 .map(|s| s.to_string());
122 outcome.blocked = true;
123 outcome.results.push(result);
124 return outcome;
125 }
126
127 if result.success() {
128 let parsed = result.parse_json_output();
129 if let Some(ctx) = parsed.get("additionalContext").and_then(|v| v.as_str()) {
130 outcome.additional_context = Some(ctx.to_string());
131 }
132 if let Some(input) = parsed.get("updatedInput") {
133 outcome.updated_input = Some(input.clone());
134 }
135 if let Some(perm) = parsed.get("permissionDecision").and_then(|v| v.as_str()) {
136 outcome.permission_decision = Some(perm.to_string());
137 }
138 if let Some(dec) = parsed.get("decision").and_then(|v| v.as_str()) {
139 outcome.decision = Some(dec.to_string());
140 }
141 } else if let Some(ref err) = result.error {
142 warn!(
143 event = %event,
144 error = %err,
145 "Hook command error"
146 );
147 }
148
149 outcome.results.push(result);
150 }
151 }
152
153 outcome
154 }
155
156 pub fn run_hooks_async(
161 &self,
162 event: HookEvent,
163 match_value: Option<String>,
164 event_data: Option<Value>,
165 ) where
166 Self: Send + Sync + 'static,
167 {
168 if !self.has_hooks_for(event) {
169 return;
170 }
171
172 let config = self.config.clone();
174 let session_id = self.session_id.clone();
175 let cwd = self.cwd.clone();
176
177 tokio::spawn(async move {
178 let manager = HookManager::new(config, session_id, cwd);
179 let _ = manager
180 .run_hooks(event, match_value.as_deref(), event_data.as_ref())
181 .await;
182 });
183 }
184
185 fn build_stdin(
197 &self,
198 event: HookEvent,
199 match_value: Option<&str>,
200 event_data: Option<&Value>,
201 ) -> Value {
202 let mut payload = Map::new();
203
204 payload.insert(
205 "session_id".to_string(),
206 Value::String(self.session_id.clone()),
207 );
208 payload.insert("cwd".to_string(), Value::String(self.cwd.clone()));
209 payload.insert(
210 "hook_event_name".to_string(),
211 Value::String(event.as_str().to_string()),
212 );
213
214 let mv = match_value.unwrap_or("");
215
216 if event.is_tool_event() {
218 payload.insert("tool_name".to_string(), Value::String(mv.to_string()));
219 }
220
221 if event.is_subagent_event() {
223 payload.insert("agent_type".to_string(), Value::String(mv.to_string()));
224 }
225
226 if event == HookEvent::SessionStart {
228 payload.insert(
229 "startup_type".to_string(),
230 Value::String(if mv.is_empty() { "startup" } else { mv }.to_string()),
231 );
232 }
233
234 if event == HookEvent::PreCompact {
236 payload.insert(
237 "trigger".to_string(),
238 Value::String(if mv.is_empty() { "auto" } else { mv }.to_string()),
239 );
240 }
241
242 if let Some(Value::Object(data)) = event_data {
244 for key in &[
246 "tool_input",
247 "tool_response",
248 "user_prompt",
249 "agent_task",
250 "agent_result",
251 ] {
252 if let Some(val) = data.get(*key) {
253 payload.insert((*key).to_string(), val.clone());
254 }
255 }
256 for (key, val) in data {
258 if !payload.contains_key(key) {
259 payload.insert(key.clone(), val.clone());
260 }
261 }
262 }
263
264 Value::Object(payload)
265 }
266}
267
268#[cfg(test)]
269#[path = "manager_tests.rs"]
270mod tests;