1#![allow(dead_code)]
3
4use std::collections::{HashMap, HashSet};
5use std::fmt;
6use std::path::Path;
7
8use serde::de;
9
10use crate::utils::hooks::session_hooks::get_session_hooks;
11use crate::utils::settings::{get_settings_file_path_for_source, read_settings_file};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub enum HookEvent {
16 PreToolUse,
17 PostToolUse,
18 PostToolUseFailure,
19 PermissionDenied,
20 Notification,
21 UserPromptSubmit,
22 SessionStart,
23 SessionEnd,
24 Stop,
25 StopFailure,
26 SubagentStart,
27 SubagentStop,
28 PreCompact,
29 PostCompact,
30 PermissionRequest,
31 Setup,
32 TeammateIdle,
33 TaskCreated,
34 TaskCompleted,
35 Elicitation,
36 ElicitationResult,
37 ConfigChange,
38 WorktreeCreate,
39 WorktreeRemove,
40 InstructionsLoaded,
41 CwdChanged,
42 FileChanged,
43}
44
45impl HookEvent {
46 pub fn as_str(&self) -> &'static str {
47 match self {
48 HookEvent::PreToolUse => "PreToolUse",
49 HookEvent::PostToolUse => "PostToolUse",
50 HookEvent::PostToolUseFailure => "PostToolUseFailure",
51 HookEvent::PermissionDenied => "PermissionDenied",
52 HookEvent::Notification => "Notification",
53 HookEvent::UserPromptSubmit => "UserPromptSubmit",
54 HookEvent::SessionStart => "SessionStart",
55 HookEvent::SessionEnd => "SessionEnd",
56 HookEvent::Stop => "Stop",
57 HookEvent::StopFailure => "StopFailure",
58 HookEvent::SubagentStart => "SubagentStart",
59 HookEvent::SubagentStop => "SubagentStop",
60 HookEvent::PreCompact => "PreCompact",
61 HookEvent::PostCompact => "PostCompact",
62 HookEvent::PermissionRequest => "PermissionRequest",
63 HookEvent::Setup => "Setup",
64 HookEvent::TeammateIdle => "TeammateIdle",
65 HookEvent::TaskCreated => "TaskCreated",
66 HookEvent::TaskCompleted => "TaskCompleted",
67 HookEvent::Elicitation => "Elicitation",
68 HookEvent::ElicitationResult => "ElicitationResult",
69 HookEvent::ConfigChange => "ConfigChange",
70 HookEvent::WorktreeCreate => "WorktreeCreate",
71 HookEvent::WorktreeRemove => "WorktreeRemove",
72 HookEvent::InstructionsLoaded => "InstructionsLoaded",
73 HookEvent::CwdChanged => "CwdChanged",
74 HookEvent::FileChanged => "FileChanged",
75 }
76 }
77}
78
79pub const HOOK_EVENTS: &[HookEvent] = &[
81 HookEvent::PreToolUse,
82 HookEvent::PostToolUse,
83 HookEvent::PostToolUseFailure,
84 HookEvent::PermissionDenied,
85 HookEvent::Notification,
86 HookEvent::UserPromptSubmit,
87 HookEvent::SessionStart,
88 HookEvent::SessionEnd,
89 HookEvent::Stop,
90 HookEvent::StopFailure,
91 HookEvent::SubagentStart,
92 HookEvent::SubagentStop,
93 HookEvent::PreCompact,
94 HookEvent::PostCompact,
95 HookEvent::PermissionRequest,
96 HookEvent::Setup,
97 HookEvent::TeammateIdle,
98 HookEvent::TaskCreated,
99 HookEvent::TaskCompleted,
100 HookEvent::Elicitation,
101 HookEvent::ElicitationResult,
102 HookEvent::ConfigChange,
103 HookEvent::WorktreeCreate,
104 HookEvent::WorktreeRemove,
105 HookEvent::InstructionsLoaded,
106 HookEvent::CwdChanged,
107 HookEvent::FileChanged,
108];
109
110pub use crate::utils::settings::EditableSettingSource;
112
113pub const SOURCES: &[EditableSettingSource] = &[
115 EditableSettingSource::UserSettings,
116 EditableSettingSource::ProjectSettings,
117 EditableSettingSource::LocalSettings,
118];
119
120#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122pub enum HookSource {
123 Editable(EditableSettingSource),
124 PolicySettings,
125 PluginHook,
126 SessionHook,
127 BuiltinHook,
128}
129
130impl std::fmt::Display for HookSource {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match self {
133 HookSource::Editable(s) => match s {
134 EditableSettingSource::UserSettings => write!(f, "User"),
135 EditableSettingSource::ProjectSettings => write!(f, "Project"),
136 EditableSettingSource::LocalSettings => write!(f, "Local"),
137 },
138 HookSource::PolicySettings => write!(f, "Policy"),
139 HookSource::PluginHook => write!(f, "Plugin"),
140 HookSource::SessionHook => write!(f, "Session"),
141 HookSource::BuiltinHook => write!(f, "Built-in"),
142 }
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct IndividualHookConfig {
149 pub event: HookEvent,
150 pub config: HookCommand,
151 pub matcher: Option<String>,
152 pub source: HookSource,
153 pub plugin_name: Option<String>,
154}
155
156#[derive(Debug, Clone, serde::Serialize)]
161#[serde(tag = "type")]
162pub enum HookCommand {
163 #[serde(rename = "command")]
164 Command {
165 command: String,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 shell: Option<String>,
168 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
169 if_condition: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 timeout: Option<u64>,
172 #[serde(rename = "statusMessage", skip_serializing_if = "Option::is_none")]
173 status_message: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 once: Option<bool>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 r#async: Option<bool>,
178 #[serde(rename = "asyncRewake", skip_serializing_if = "Option::is_none")]
179 async_rewake: Option<bool>,
180 },
181 #[serde(rename = "prompt")]
182 Prompt {
183 prompt: String,
184 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
185 if_condition: Option<String>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 timeout: Option<u64>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 model: Option<String>,
190 #[serde(rename = "statusMessage", skip_serializing_if = "Option::is_none")]
191 status_message: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 once: Option<bool>,
194 },
195 #[serde(rename = "agent")]
196 Agent {
197 prompt: String,
198 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
199 if_condition: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 timeout: Option<u64>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 model: Option<String>,
204 #[serde(rename = "statusMessage", skip_serializing_if = "Option::is_none")]
205 status_message: Option<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 once: Option<bool>,
208 },
209 #[serde(rename = "http")]
210 Http {
211 url: String,
212 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
213 if_condition: Option<String>,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 timeout: Option<u64>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 headers: Option<HashMap<String, String>>,
218 #[serde(rename = "allowedEnvVars", skip_serializing_if = "Option::is_none")]
219 allowed_env_vars: Option<Vec<String>>,
220 #[serde(rename = "statusMessage", skip_serializing_if = "Option::is_none")]
221 status_message: Option<String>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 once: Option<bool>,
224 },
225}
226
227impl<'de> de::Deserialize<'de> for HookCommand {
228 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
229 where
230 D: de::Deserializer<'de>,
231 {
232 let map: serde_json::Map<String, serde_json::Value> =
233 serde_json::Map::deserialize(deserializer)?;
234
235 let type_str = map
236 .get("type")
237 .and_then(|v| v.as_str())
238 .ok_or_else(|| de::Error::missing_field("type"))?;
239
240 let opt_str = |m: &serde_json::Map<String, serde_json::Value>, key: &str| -> Option<String> {
242 m.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
243 };
244 let opt_u64 =
245 |m: &serde_json::Map<String, serde_json::Value>, key: &str| -> Option<u64> {
246 m.get(key).and_then(|v| v.as_u64())
247 };
248 let opt_bool =
249 |m: &serde_json::Map<String, serde_json::Value>, key: &str| -> Option<bool> {
250 m.get(key).and_then(|v| v.as_bool())
251 };
252
253 match type_str {
254 "command" => {
255 let command = opt_str(&map, "command")
256 .ok_or_else(|| de::Error::missing_field("command"))?;
257 Ok(HookCommand::Command {
258 command,
259 shell: opt_str(&map, "shell"),
260 if_condition: opt_str(&map, "if"),
261 timeout: opt_u64(&map, "timeout"),
262 status_message: opt_str(&map, "statusMessage"),
263 once: opt_bool(&map, "once"),
264 r#async: opt_bool(&map, "async"),
265 async_rewake: opt_bool(&map, "asyncRewake"),
266 })
267 }
268 "prompt" => {
269 let prompt = opt_str(&map, "prompt")
270 .ok_or_else(|| de::Error::missing_field("prompt"))?;
271 Ok(HookCommand::Prompt {
272 prompt,
273 if_condition: opt_str(&map, "if"),
274 timeout: opt_u64(&map, "timeout"),
275 model: opt_str(&map, "model"),
276 status_message: opt_str(&map, "statusMessage"),
277 once: opt_bool(&map, "once"),
278 })
279 }
280 "agent" => {
281 let prompt = opt_str(&map, "prompt")
282 .ok_or_else(|| de::Error::missing_field("prompt"))?;
283 Ok(HookCommand::Agent {
284 prompt,
285 if_condition: opt_str(&map, "if"),
286 timeout: opt_u64(&map, "timeout"),
287 model: opt_str(&map, "model"),
288 status_message: opt_str(&map, "statusMessage"),
289 once: opt_bool(&map, "once"),
290 })
291 }
292 "http" => {
293 let url = opt_str(&map, "url")
294 .ok_or_else(|| de::Error::missing_field("url"))?;
295 let headers = map
296 .get("headers")
297 .and_then(|v| v.as_object())
298 .map(|m| {
299 m.iter()
300 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
301 .collect()
302 });
303 let allowed_env_vars = map
304 .get("allowedEnvVars")
305 .and_then(|v| v.as_array())
306 .map(|arr| {
307 arr.iter()
308 .filter_map(|v| v.as_str().map(|s| s.to_string()))
309 .collect()
310 });
311 Ok(HookCommand::Http {
312 url,
313 if_condition: opt_str(&map, "if"),
314 timeout: opt_u64(&map, "timeout"),
315 headers,
316 allowed_env_vars,
317 status_message: opt_str(&map, "statusMessage"),
318 once: opt_bool(&map, "once"),
319 })
320 }
321 other => Err(de::Error::unknown_variant(
322 other,
323 &["command", "prompt", "agent", "http"],
324 )),
325 }
326 }
327}
328
329pub const DEFAULT_HOOK_SHELL: &str = "bash";
331
332pub fn is_hook_equal(a: &HookCommand, b: &HookCommand) -> bool {
334 match (a, b) {
335 (
336 HookCommand::Command {
337 command: cmd_a,
338 shell: shell_a,
339 if_condition: if_a,
340 ..
341 },
342 HookCommand::Command {
343 command: cmd_b,
344 shell: shell_b,
345 if_condition: if_b,
346 ..
347 },
348 ) => {
349 cmd_a == cmd_b
350 && (shell_a
351 .clone()
352 .unwrap_or_else(|| DEFAULT_HOOK_SHELL.to_string())
353 == shell_b
354 .clone()
355 .unwrap_or_else(|| DEFAULT_HOOK_SHELL.to_string()))
356 && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default())
357 }
358 (
359 HookCommand::Prompt {
360 prompt: p_a,
361 if_condition: if_a,
362 ..
363 },
364 HookCommand::Prompt {
365 prompt: p_b,
366 if_condition: if_b,
367 ..
368 },
369 ) => p_a == p_b && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default()),
370 (
371 HookCommand::Agent {
372 prompt: p_a,
373 if_condition: if_a,
374 ..
375 },
376 HookCommand::Agent {
377 prompt: p_b,
378 if_condition: if_b,
379 ..
380 },
381 ) => p_a == p_b && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default()),
382 (
383 HookCommand::Http {
384 url: u_a,
385 if_condition: if_a,
386 ..
387 },
388 HookCommand::Http {
389 url: u_b,
390 if_condition: if_b,
391 ..
392 },
393 ) => u_a == u_b && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default()),
394 _ => false,
395 }
396}
397
398pub fn get_hook_display_text(hook: &HookCommand) -> String {
400 match hook {
401 HookCommand::Command { command, .. } => command.clone(),
402 HookCommand::Prompt { prompt, .. } => prompt.clone(),
403 HookCommand::Agent { prompt, .. } => prompt.clone(),
404 HookCommand::Http { url, .. } => url.clone(),
405 }
406}
407
408#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
413pub struct HookMatcher {
414 #[serde(skip_serializing_if = "Option::is_none")]
415 pub matcher: Option<String>,
416 pub hooks: Vec<HookCommand>,
417}
418
419pub type ParsedHooksSettings = HashMap<String, Vec<HookMatcher>>;
423
424fn parse_hooks_from_settings(settings: &serde_json::Value) -> Option<ParsedHooksSettings> {
427 let hooks_obj = settings.get("hooks")?;
428 let hooks_map = hooks_obj.as_object()?;
429 let mut parsed = HashMap::new();
430 for (event_name, matchers_value) in hooks_map {
431 if let Ok(matchers) =
432 serde_json::from_value::<Vec<HookMatcher>>(matchers_value.clone())
433 {
434 if !matchers.is_empty() {
435 parsed.insert(event_name.clone(), matchers);
436 }
437 }
438 }
439 if parsed.is_empty() {
440 None
441 } else {
442 Some(parsed)
443 }
444}
445
446pub fn parse_hook_event(s: &str) -> Result<HookEvent, String> {
448 match s {
449 "PreToolUse" => Ok(HookEvent::PreToolUse),
450 "PostToolUse" => Ok(HookEvent::PostToolUse),
451 "PostToolUseFailure" => Ok(HookEvent::PostToolUseFailure),
452 "PermissionDenied" => Ok(HookEvent::PermissionDenied),
453 "Notification" => Ok(HookEvent::Notification),
454 "UserPromptSubmit" => Ok(HookEvent::UserPromptSubmit),
455 "SessionStart" => Ok(HookEvent::SessionStart),
456 "SessionEnd" => Ok(HookEvent::SessionEnd),
457 "Stop" => Ok(HookEvent::Stop),
458 "StopFailure" => Ok(HookEvent::StopFailure),
459 "SubagentStart" => Ok(HookEvent::SubagentStart),
460 "SubagentStop" => Ok(HookEvent::SubagentStop),
461 "PreCompact" => Ok(HookEvent::PreCompact),
462 "PostCompact" => Ok(HookEvent::PostCompact),
463 "PermissionRequest" => Ok(HookEvent::PermissionRequest),
464 "Setup" => Ok(HookEvent::Setup),
465 "TeammateIdle" => Ok(HookEvent::TeammateIdle),
466 "TaskCreated" => Ok(HookEvent::TaskCreated),
467 "TaskCompleted" => Ok(HookEvent::TaskCompleted),
468 "Elicitation" => Ok(HookEvent::Elicitation),
469 "ElicitationResult" => Ok(HookEvent::ElicitationResult),
470 "ConfigChange" => Ok(HookEvent::ConfigChange),
471 "WorktreeCreate" => Ok(HookEvent::WorktreeCreate),
472 "WorktreeRemove" => Ok(HookEvent::WorktreeRemove),
473 "InstructionsLoaded" => Ok(HookEvent::InstructionsLoaded),
474 "CwdChanged" => Ok(HookEvent::CwdChanged),
475 "FileChanged" => Ok(HookEvent::FileChanged),
476 _ => Err(format!("Unknown hook event: {}", s)),
477 }
478}
479
480fn is_restricted_to_managed_only() -> bool {
483 if std::env::var("AI_CODE_ALLOW_MANAGED_HOOKS_ONLY")
485 .ok()
486 .map(|v| v == "true" || v == "1")
487 .unwrap_or(false)
488 {
489 return true;
490 }
491
492 false
495}
496
497pub fn get_all_hooks(session_id: &str) -> Vec<IndividualHookConfig> {
507 let mut hooks: Vec<IndividualHookConfig> = Vec::new();
508
509 let restricted_to_managed_only = is_restricted_to_managed_only();
512
513 if !restricted_to_managed_only {
514 let sources = [
516 EditableSettingSource::UserSettings,
517 EditableSettingSource::ProjectSettings,
518 EditableSettingSource::LocalSettings,
519 ];
520
521 let mut seen_files: HashSet<String> = HashSet::new();
525
526 for source in &sources {
527 let file_path = get_settings_file_path_for_source(source);
528
529 if let Some(ref path) = file_path {
530 let resolved_path = path
531 .canonicalize()
532 .map(|p| p.to_string_lossy().to_string())
533 .unwrap_or_else(|_| path.to_string_lossy().to_string());
534
535 if seen_files.contains(&resolved_path) {
536 continue;
537 }
538 seen_files.insert(resolved_path);
539 }
540
541 if let Some(source_hooks) = get_hooks_for_source(source) {
543 for (event_str, matchers) in source_hooks {
544 if let Ok(event) = parse_hook_event(&event_str) {
545 for matcher in &matchers {
546 for hook_command in &matcher.hooks {
547 hooks.push(IndividualHookConfig {
548 event: event.clone(),
549 config: hook_command.clone(),
550 matcher: matcher.matcher.clone(),
551 source: HookSource::Editable(source.clone()),
552 plugin_name: None,
553 });
554 }
555 }
556 }
557 }
558 }
559 }
560 }
561
562 let session_hooks_map = get_session_hooks(session_id, None);
564 for (event, matchers) in session_hooks_map {
565 for matcher in matchers {
566 for hook_command in &matcher.hooks {
567 hooks.push(IndividualHookConfig {
568 event: event.clone(),
569 config: hook_command.clone(),
570 matcher: Some(matcher.matcher.clone()),
571 source: HookSource::SessionHook,
572 plugin_name: None,
573 });
574 }
575 }
576 }
577
578 hooks
579}
580
581pub fn get_hooks_for_event(session_id: &str, event: &HookEvent) -> Vec<IndividualHookConfig> {
583 get_all_hooks(session_id)
584 .into_iter()
585 .filter(|hook| &hook.event == event)
586 .collect()
587}
588
589pub fn get_hooks_for_source(source: &EditableSettingSource) -> Option<ParsedHooksSettings> {
594 let path = get_settings_file_path_for_source(source)?;
595 let settings = read_settings_file(&path)?;
596 parse_hooks_from_settings(&settings)
597}
598
599pub fn hook_source_description_display_string(source: &HookSource) -> String {
601 match source {
602 HookSource::Editable(s) => match s {
603 EditableSettingSource::UserSettings => {
604 "User settings (~/.ai/settings.json)".to_string()
605 }
606 EditableSettingSource::ProjectSettings => {
607 "Project settings (.ai/settings.json)".to_string()
608 }
609 EditableSettingSource::LocalSettings => {
610 "Local settings (.ai/settings.local.json)".to_string()
611 }
612 },
613 HookSource::PolicySettings => "Policy settings".to_string(),
614 HookSource::PluginHook => "Plugin hooks (~/.ai/plugins/*/hooks/hooks.json)".to_string(),
615 HookSource::SessionHook => "Session hooks (in-memory, temporary)".to_string(),
616 HookSource::BuiltinHook => {
617 "Built-in hook (registered internally by AI Code)".to_string()
618 }
619 }
620}
621
622pub fn hook_source_header_display_string(source: &HookSource) -> String {
624 match source {
625 HookSource::Editable(s) => match s {
626 EditableSettingSource::UserSettings => "User Settings".to_string(),
627 EditableSettingSource::ProjectSettings => "Project Settings".to_string(),
628 EditableSettingSource::LocalSettings => "Local Settings".to_string(),
629 },
630 HookSource::PolicySettings => "Policy Settings".to_string(),
631 HookSource::PluginHook => "Plugin Hooks".to_string(),
632 HookSource::SessionHook => "Session Hooks".to_string(),
633 HookSource::BuiltinHook => "Built-in Hooks".to_string(),
634 }
635}
636
637pub fn hook_source_inline_display_string(source: &HookSource) -> String {
639 match source {
640 HookSource::Editable(s) => match s {
641 EditableSettingSource::UserSettings => "User".to_string(),
642 EditableSettingSource::ProjectSettings => "Project".to_string(),
643 EditableSettingSource::LocalSettings => "Local".to_string(),
644 },
645 HookSource::PolicySettings => "Policy".to_string(),
646 HookSource::PluginHook => "Plugin".to_string(),
647 HookSource::SessionHook => "Session".to_string(),
648 HookSource::BuiltinHook => "Built-in".to_string(),
649 }
650}
651
652pub fn sort_matchers_by_priority(
656 matchers: &[String],
657 hooks_by_event_and_matcher: &HashMap<HookEvent, HashMap<String, Vec<IndividualHookConfig>>>,
658 selected_event: &HookEvent,
659) -> Vec<String> {
660 let source_priority: HashMap<EditableSettingSource, usize> = SOURCES
662 .iter()
663 .enumerate()
664 .map(|(i, s)| (s.clone(), i))
665 .collect();
666
667 let mut sorted = matchers.to_vec();
668 sorted.sort_by(|a, b| {
669 let a_hooks = hooks_by_event_and_matcher
670 .get(selected_event)
671 .and_then(|m| m.get(a))
672 .cloned()
673 .unwrap_or_default();
674 let b_hooks = hooks_by_event_and_matcher
675 .get(selected_event)
676 .and_then(|m| m.get(b))
677 .cloned()
678 .unwrap_or_default();
679
680 let a_sources: HashSet<&HookSource> = a_hooks.iter().map(|h| &h.source).collect();
681 let b_sources: HashSet<&HookSource> = b_hooks.iter().map(|h| &h.source).collect();
682
683 let get_source_priority = |source: &&HookSource| -> usize {
686 match *source {
687 HookSource::PluginHook | HookSource::BuiltinHook => 999,
688 HookSource::Editable(s) => *source_priority.get(s).unwrap_or(&999),
689 HookSource::PolicySettings => 0, HookSource::SessionHook => 100,
691 }
692 };
693
694 let a_highest_priority = a_sources
695 .iter()
696 .map(get_source_priority)
697 .min()
698 .unwrap_or(999);
699 let b_highest_priority = b_sources
700 .iter()
701 .map(get_source_priority)
702 .min()
703 .unwrap_or(999);
704
705 if a_highest_priority != b_highest_priority {
706 return a_highest_priority.cmp(&b_highest_priority);
707 }
708
709 a.cmp(b)
711 });
712
713 sorted
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 #[test]
721 fn test_is_hook_equal_same_command() {
722 let a = HookCommand::Command {
723 command: "echo hello".to_string(),
724 shell: None,
725 if_condition: None,
726 timeout: None,
727 status_message: None,
728 once: None,
729 r#async: None,
730 async_rewake: None,
731 };
732 let b = HookCommand::Command {
733 command: "echo hello".to_string(),
734 shell: None,
735 if_condition: None,
736 timeout: Some(30), status_message: None,
738 once: None,
739 r#async: None,
740 async_rewake: None,
741 };
742 assert!(is_hook_equal(&a, &b));
743 }
744
745 #[test]
746 fn test_is_hook_equal_different_command() {
747 let a = HookCommand::Command {
748 command: "echo hello".to_string(),
749 shell: None,
750 if_condition: None,
751 timeout: None,
752 status_message: None,
753 once: None,
754 r#async: None,
755 async_rewake: None,
756 };
757 let b = HookCommand::Command {
758 command: "echo world".to_string(),
759 shell: None,
760 if_condition: None,
761 timeout: None,
762 status_message: None,
763 once: None,
764 r#async: None,
765 async_rewake: None,
766 };
767 assert!(!is_hook_equal(&a, &b));
768 }
769
770 #[test]
771 fn test_is_hook_equal_different_types() {
772 let a = HookCommand::Command {
773 command: "echo hello".to_string(),
774 shell: None,
775 if_condition: None,
776 timeout: None,
777 status_message: None,
778 once: None,
779 r#async: None,
780 async_rewake: None,
781 };
782 let b = HookCommand::Prompt {
783 prompt: "echo hello".to_string(),
784 if_condition: None,
785 timeout: None,
786 model: None,
787 status_message: None,
788 once: None,
789 };
790 assert!(!is_hook_equal(&a, &b));
791 }
792
793 #[test]
794 fn test_get_hook_display_text() {
795 let hook = HookCommand::Command {
796 command: "echo hello".to_string(),
797 shell: None,
798 if_condition: None,
799 timeout: None,
800 status_message: None,
801 once: None,
802 r#async: None,
803 async_rewake: None,
804 };
805 assert_eq!(get_hook_display_text(&hook), "echo hello");
806 }
807
808 #[test]
809 fn test_parse_hook_event() {
810 assert_eq!(parse_hook_event("Stop").unwrap(), HookEvent::Stop);
811 assert_eq!(
812 parse_hook_event("PreToolUse").unwrap(),
813 HookEvent::PreToolUse
814 );
815 assert!(parse_hook_event("Unknown").is_err());
816 }
817
818 #[test]
819 fn test_hook_source_display_strings() {
820 let source = HookSource::Editable(EditableSettingSource::UserSettings);
821 assert_eq!(
822 hook_source_description_display_string(&source),
823 "User settings (~/.ai/settings.json)"
824 );
825 assert_eq!(hook_source_header_display_string(&source), "User Settings");
826 assert_eq!(hook_source_inline_display_string(&source), "User");
827 }
828
829 #[test]
830 fn test_deserialize_hook_command_command() {
831 let json = serde_json::json!({
832 "type": "command",
833 "command": "echo hello",
834 "shell": "bash",
835 "if": "Bash(git *)"
836 });
837 let hook: HookCommand = serde_json::from_value(json).unwrap();
838 match hook {
839 HookCommand::Command {
840 command, shell, if_condition, ..
841 } => {
842 assert_eq!(command, "echo hello");
843 assert_eq!(shell, Some("bash".to_string()));
844 assert_eq!(if_condition, Some("Bash(git *)".to_string()));
845 }
846 _ => panic!("Expected Command variant"),
847 }
848 }
849
850 #[test]
851 fn test_deserialize_hook_command_prompt() {
852 let json = serde_json::json!({
853 "type": "prompt",
854 "prompt": "Review the code",
855 "model": "claude-sonnet-4-6"
856 });
857 let hook: HookCommand = serde_json::from_value(json).unwrap();
858 match hook {
859 HookCommand::Prompt { prompt, model, .. } => {
860 assert_eq!(prompt, "Review the code");
861 assert_eq!(model, Some("claude-sonnet-4-6".to_string()));
862 }
863 _ => panic!("Expected Prompt variant"),
864 }
865 }
866
867 #[test]
868 fn test_deserialize_hook_command_agent() {
869 let json = serde_json::json!({
870 "type": "agent",
871 "prompt": "Verify tests pass"
872 });
873 let hook: HookCommand = serde_json::from_value(json).unwrap();
874 match hook {
875 HookCommand::Agent { prompt, .. } => {
876 assert_eq!(prompt, "Verify tests pass");
877 }
878 _ => panic!("Expected Agent variant"),
879 }
880 }
881
882 #[test]
883 fn test_deserialize_hook_command_http() {
884 let json = serde_json::json!({
885 "type": "http",
886 "url": "https://example.com/hook",
887 "if": "Bash(npm *)"
888 });
889 let hook: HookCommand = serde_json::from_value(json).unwrap();
890 match hook {
891 HookCommand::Http { url, if_condition, .. } => {
892 assert_eq!(url, "https://example.com/hook");
893 assert_eq!(if_condition, Some("Bash(npm *)".to_string()));
894 }
895 _ => panic!("Expected Http variant"),
896 }
897 }
898
899 #[test]
900 fn test_deserialize_hook_matcher() {
901 let json = serde_json::json!({
902 "matcher": "Bash(git *)",
903 "hooks": [
904 {"type": "command", "command": "git status"},
905 {"type": "prompt", "prompt": "Check git state"}
906 ]
907 });
908 let matcher: HookMatcher = serde_json::from_value(json).unwrap();
909 assert_eq!(matcher.matcher, Some("Bash(git *)".to_string()));
910 assert_eq!(matcher.hooks.len(), 2);
911 }
912
913 #[test]
914 fn test_deserialize_hook_matcher_no_matcher() {
915 let json = serde_json::json!({
916 "hooks": [
917 {"type": "command", "command": "echo hi"}
918 ]
919 });
920 let matcher: HookMatcher = serde_json::from_value(json).unwrap();
921 assert_eq!(matcher.matcher, None);
922 assert_eq!(matcher.hooks.len(), 1);
923 }
924
925 #[test]
926 fn test_parse_hooks_from_settings() {
927 let settings = serde_json::json!({
928 "hooks": {
929 "Stop": [
930 {
931 "hooks": [
932 {"type": "command", "command": "echo stopped"}
933 ]
934 }
935 ],
936 "PreToolUse": [
937 {
938 "matcher": "Bash(git *)",
939 "hooks": [
940 {"type": "command", "command": "git status"}
941 ]
942 }
943 ]
944 },
945 "model": "claude-sonnet-4-6"
946 });
947 let parsed = parse_hooks_from_settings(&settings).unwrap();
948 assert_eq!(parsed.len(), 2);
949 assert!(parsed.contains_key("Stop"));
950 assert!(parsed.contains_key("PreToolUse"));
951 assert_eq!(parsed["Stop"].len(), 1);
952 assert_eq!(parsed["PreToolUse"][0].matcher, Some("Bash(git *)".to_string()));
953 }
954
955 #[test]
956 fn test_parse_hooks_from_settings_no_hooks() {
957 let settings = serde_json::json!({
958 "model": "claude-sonnet-4-6"
959 });
960 let parsed = parse_hooks_from_settings(&settings);
961 assert!(parsed.is_none());
962 }
963
964 #[test]
965 fn test_hook_command_serialization() {
966 let hook = HookCommand::Command {
967 command: "echo hello".to_string(),
968 shell: Some("bash".to_string()),
969 if_condition: Some("Bash(git *)".to_string()),
970 timeout: Some(30),
971 status_message: Some("Running git check".to_string()),
972 once: Some(false),
973 r#async: Some(true),
974 async_rewake: Some(false),
975 };
976 let json = serde_json::to_value(&hook).unwrap();
977 assert_eq!(json["type"], "command");
978 assert_eq!(json["command"], "echo hello");
979 assert_eq!(json["shell"], "bash");
980 assert_eq!(json["if"], "Bash(git *)");
981 assert_eq!(json["timeout"], 30);
982 assert_eq!(json["statusMessage"], "Running git check");
983 assert_eq!(json["async"], true);
984 assert_eq!(json["asyncRewake"], false);
985 }
986}