Skip to main content

ai_agent/utils/hooks/
hooks_settings.rs

1// Source: ~/claudecode/openclaudecode/src/utils/hooks/hooksSettings.ts
2#![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/// Hook event type
14#[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
79/// All hook events
80pub 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
110/// Editable setting sources - re-export from settings module.
111pub use crate::utils::settings::EditableSettingSource;
112
113/// Setting source priority order (lower index = higher priority)
114pub const SOURCES: &[EditableSettingSource] = &[
115    EditableSettingSource::UserSettings,
116    EditableSettingSource::ProjectSettings,
117    EditableSettingSource::LocalSettings,
118];
119
120/// Hook source
121#[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/// Individual hook configuration
147#[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/// Hook command types
157///
158/// Serialized/deserialized with `#[serde(tag = "type")]` to match the
159/// TypeScript discriminated union: command | prompt | agent | http.
160#[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        // Helper to extract optional string from map
241        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
329/// Default hook shell
330pub const DEFAULT_HOOK_SHELL: &str = "bash";
331
332/// Check if two hooks are equal (comparing only command/prompt content, not timeout)
333pub 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
398/// Get the display text for a hook
399pub 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/// A hook matcher as it appears in settings JSON.
409///
410/// Matches TypeScript `HookMatcher` from `schemas/hooks.ts`:
411/// `{ matcher?: string, hooks: HookCommand[] }`
412#[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
419/// Hooks settings extracted from a settings.json `hooks` key.
420///
421/// Matches TypeScript `HooksSettings = Partial<Record<HookEvent, HookMatcher[]>>`.
422pub type ParsedHooksSettings = HashMap<String, Vec<HookMatcher>>;
423
424/// Parse hooks from a settings JSON value.
425/// Returns the `hooks` section as a map from event name to matchers.
426fn 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
446/// Parse a hook event from a string (matches TypeScript enum names).
447pub 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
480/// Check if only managed hooks should run.
481/// Returns true when policy settings or env vars set allowManagedHooksOnly.
482fn is_restricted_to_managed_only() -> bool {
483    // Check environment variable (localized from CLAUDE_CODE_ to AI_)
484    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    // Check policy settings for allowManagedHooksOnly
493    // (would need policy settings integration)
494    false
495}
496
497/// Get all hooks from all sources in priority order.
498///
499/// Priority (highest first):
500/// 1. User settings (~/.ai/settings.json)
501/// 2. Project settings (.ai/settings.json in CWD)
502/// 3. Local settings (.ai/settings.local.json)
503/// 4. Session hooks (transient, in-memory)
504///
505/// Matches TypeScript `getAllHooks(appState)` from hooksSettings.ts lines 92-161.
506pub fn get_all_hooks(session_id: &str) -> Vec<IndividualHookConfig> {
507    let mut hooks: Vec<IndividualHookConfig> = Vec::new();
508
509    // Check if restricted to managed hooks only
510    // (would check policy settings and env vars)
511    let restricted_to_managed_only = is_restricted_to_managed_only();
512
513    if !restricted_to_managed_only {
514        // Get hooks from all editable sources in priority order
515        let sources = [
516            EditableSettingSource::UserSettings,
517            EditableSettingSource::ProjectSettings,
518            EditableSettingSource::LocalSettings,
519        ];
520
521        // Track which setting files we've already processed to avoid duplicates
522        // (e.g., when running from home directory, userSettings and projectSettings
523        // both resolve to ~/.ai/settings.json)
524        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            // Get hooks from this source's settings file
542            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    // Get session hooks and add them to the list
563    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
581/// Get hooks for a specific event
582pub 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
589/// Read hooks from a specific editable settings source.
590///
591/// Reads the settings file for the source, extracts the `hooks` section,
592/// and parses it into a map of event name -> matchers.
593pub 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
599/// Hook source description display string
600pub 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
622/// Hook source header display string
623pub 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
637/// Hook source inline display string
638pub 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
652/// Sort matchers by priority for a specific event.
653/// Priority is based on source order: userSettings > projectSettings > localSettings.
654/// Plugin hooks get lowest priority.
655pub 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    // Create a priority map based on SOURCES order (lower index = higher priority)
661    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        // Sort by highest priority source first (lowest priority number)
684        // Plugin hooks get lowest priority (highest number)
685        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, // Highest priority
690                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        // If same priority, sort by matcher name
710        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), // Different timeout doesn't matter
737            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}