Skip to main content

rmux_core/
hooks.rs

1use std::collections::HashMap;
2
3use rmux_proto::{
4    HookLifecycle, HookName, PaneTarget, RmuxError, ScopeSelector, SessionName, WindowTarget,
5};
6
7#[path = "hooks/bindings.rs"]
8mod bindings;
9#[path = "hooks/rules.rs"]
10mod rules;
11#[path = "hooks/types.rs"]
12mod types;
13
14use bindings::HookBindings;
15use rules::{hook_class, hook_inventory, hook_is_visible_in_show_hooks, root_for_hook};
16pub use rules::{validate_hook_registration, validate_hook_scope};
17use types::HookClass;
18pub use types::{HookBindingView, HookDispatch, HookGlobalRoot, HookSetOptions};
19
20/// In-memory storage for tmux-style hook arrays.
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct HookStore {
23    session_global: HookBindings,
24    window_global: HookBindings,
25    sessions: HashMap<SessionName, HookBindings>,
26    windows: HashMap<WindowTarget, HookBindings>,
27    panes: HashMap<PaneTarget, HookBindings>,
28}
29
30impl HookStore {
31    /// Creates an empty hook store with no registered hooks.
32    #[must_use]
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Returns whether no explicit hooks are present at any scope.
38    #[must_use]
39    pub fn is_empty(&self) -> bool {
40        self.session_global.is_empty()
41            && self.window_global.is_empty()
42            && self.sessions.values().all(HookBindings::is_empty)
43            && self.windows.values().all(HookBindings::is_empty)
44            && self.panes.values().all(HookBindings::is_empty)
45    }
46
47    /// Registers or replaces a hook using tmux's default index-zero semantics.
48    pub fn set(
49        &mut self,
50        scope: ScopeSelector,
51        hook: HookName,
52        command: String,
53        lifecycle: HookLifecycle,
54    ) -> Result<u32, RmuxError> {
55        self.set_with_options(scope, hook, command, lifecycle, HookSetOptions::default())
56    }
57
58    /// Registers or mutates a hook using indexed tmux array semantics.
59    pub fn set_with_options(
60        &mut self,
61        scope: ScopeSelector,
62        hook: HookName,
63        command: String,
64        lifecycle: HookLifecycle,
65        options: HookSetOptions,
66    ) -> Result<u32, RmuxError> {
67        validate_hook_scope(hook, &scope)?;
68        let bindings = self.bindings_for_scope_mut(hook, &scope);
69        Ok(bindings.set(hook, command, lifecycle, options))
70    }
71
72    /// Removes a hook or a single indexed hook element.
73    pub fn unset(
74        &mut self,
75        scope: ScopeSelector,
76        hook: HookName,
77        index: Option<u32>,
78    ) -> Result<(), RmuxError> {
79        validate_hook_scope(hook, &scope)?;
80        match scope {
81            ScopeSelector::Global => {
82                self.global_bindings_mut(root_for_hook(hook))
83                    .unset(hook, index);
84            }
85            ScopeSelector::Session(session_name) => {
86                let remove_scope = if let Some(bindings) = self.sessions.get_mut(&session_name) {
87                    bindings.unset(hook, index);
88                    bindings.is_empty()
89                } else {
90                    false
91                };
92                if remove_scope {
93                    self.sessions.remove(&session_name);
94                }
95            }
96            ScopeSelector::Window(target) => {
97                let remove_scope = if let Some(bindings) = self.windows.get_mut(&target) {
98                    bindings.unset(hook, index);
99                    bindings.is_empty()
100                } else {
101                    false
102                };
103                if remove_scope {
104                    self.windows.remove(&target);
105                }
106            }
107            ScopeSelector::Pane(target) => {
108                let remove_scope = if let Some(bindings) = self.panes.get_mut(&target) {
109                    bindings.unset(hook, index);
110                    bindings.is_empty()
111                } else {
112                    false
113                };
114                if remove_scope {
115                    self.panes.remove(&target);
116                }
117            }
118        }
119        Ok(())
120    }
121
122    /// Returns the first explicit global command for the given hook, when present.
123    #[must_use]
124    pub fn global_command(&self, hook: HookName) -> Option<&str> {
125        self.global_bindings(root_for_hook(hook)).command(hook)
126    }
127
128    /// Returns the exact global command at the given array index, when present.
129    #[must_use]
130    pub fn global_command_at(&self, hook: HookName, index: u32) -> Option<&str> {
131        self.global_bindings(root_for_hook(hook))
132            .command_at(hook, index)
133    }
134
135    /// Returns the first explicit global lifecycle for the given hook, when present.
136    #[must_use]
137    pub fn global_lifecycle(&self, hook: HookName) -> Option<HookLifecycle> {
138        self.global_bindings(root_for_hook(hook)).lifecycle(hook)
139    }
140
141    /// Returns the exact global lifecycle at the given array index, when present.
142    #[must_use]
143    pub fn global_lifecycle_at(&self, hook: HookName, index: u32) -> Option<HookLifecycle> {
144        self.global_bindings(root_for_hook(hook))
145            .lifecycle_at(hook, index)
146    }
147
148    /// Returns the first exact session-local command for the given hook, when present.
149    #[must_use]
150    pub fn session_command(&self, session_name: &SessionName, hook: HookName) -> Option<&str> {
151        self.sessions
152            .get(session_name)
153            .and_then(|bindings| bindings.command(hook))
154    }
155
156    /// Returns the exact session-local command at the given array index, when present.
157    #[must_use]
158    pub fn session_command_at(
159        &self,
160        session_name: &SessionName,
161        hook: HookName,
162        index: u32,
163    ) -> Option<&str> {
164        self.sessions
165            .get(session_name)
166            .and_then(|bindings| bindings.command_at(hook, index))
167    }
168
169    /// Returns the first exact session-local lifecycle for the given hook, when present.
170    #[must_use]
171    pub fn session_lifecycle(
172        &self,
173        session_name: &SessionName,
174        hook: HookName,
175    ) -> Option<HookLifecycle> {
176        self.sessions
177            .get(session_name)
178            .and_then(|bindings| bindings.lifecycle(hook))
179    }
180
181    /// Returns the exact session-local lifecycle at the given array index, when present.
182    #[must_use]
183    pub fn session_lifecycle_at(
184        &self,
185        session_name: &SessionName,
186        hook: HookName,
187        index: u32,
188    ) -> Option<HookLifecycle> {
189        self.sessions
190            .get(session_name)
191            .and_then(|bindings| bindings.lifecycle_at(hook, index))
192    }
193
194    /// Returns the first exact window-local command for the given hook, when present.
195    #[must_use]
196    pub fn window_command(&self, target: &WindowTarget, hook: HookName) -> Option<&str> {
197        self.windows
198            .get(target)
199            .and_then(|bindings| bindings.command(hook))
200    }
201
202    /// Returns the first exact pane-local command for the given hook, when present.
203    #[must_use]
204    pub fn pane_command(&self, target: &PaneTarget, hook: HookName) -> Option<&str> {
205        self.panes
206            .get(target)
207            .and_then(|bindings| bindings.command(hook))
208    }
209
210    /// Returns the explicit hook bindings for the requested global root.
211    #[must_use]
212    pub fn global_bindings_view(
213        &self,
214        root: HookGlobalRoot,
215        hook: Option<HookName>,
216    ) -> Vec<HookBindingView> {
217        self.global_bindings(root).views(hook)
218    }
219
220    /// Returns the explicit session-local hook bindings.
221    #[must_use]
222    pub fn session_bindings_view(
223        &self,
224        session_name: &SessionName,
225        hook: Option<HookName>,
226    ) -> Vec<HookBindingView> {
227        self.sessions
228            .get(session_name)
229            .map_or_else(Vec::new, |bindings| bindings.views(hook))
230    }
231
232    /// Returns the explicit window-local hook bindings.
233    #[must_use]
234    pub fn window_bindings_view(
235        &self,
236        target: &WindowTarget,
237        hook: Option<HookName>,
238    ) -> Vec<HookBindingView> {
239        self.windows
240            .get(target)
241            .map_or_else(Vec::new, |bindings| bindings.views(hook))
242    }
243
244    /// Returns the explicit pane-local hook bindings.
245    #[must_use]
246    pub fn pane_bindings_view(
247        &self,
248        target: &PaneTarget,
249        hook: Option<HookName>,
250    ) -> Vec<HookBindingView> {
251        self.panes
252            .get(target)
253            .map_or_else(Vec::new, |bindings| bindings.views(hook))
254    }
255
256    /// Returns the tmux-compatible hook inventory visible at the requested global root.
257    #[must_use]
258    pub fn shipped_global_hooks(root: HookGlobalRoot, hook: Option<HookName>) -> Vec<HookName> {
259        hook_inventory()
260            .into_iter()
261            .filter(|candidate| hook.map(|expected| *candidate == expected).unwrap_or(true))
262            .filter(|candidate| {
263                hook_is_visible_in_show_hooks(*candidate) && root_for_hook(*candidate) == root
264            })
265            .collect()
266    }
267
268    /// Resolves a hook for the provided event scope and returns the matching command batch.
269    #[must_use]
270    pub fn dispatch(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
271        match hook_class(hook) {
272            HookClass::Session => self.dispatch_session(scope, hook),
273            HookClass::Window => self.dispatch_window(scope, hook),
274            HookClass::Pane => self.dispatch_pane(scope, hook),
275        }
276    }
277
278    /// Removes all hooks owned by the given session.
279    pub fn remove_session(&mut self, session_name: &SessionName) -> bool {
280        let mut removed = self.sessions.remove(session_name).is_some();
281        self.windows.retain(|target, _| {
282            let keep = target.session_name() != session_name;
283            removed |= !keep;
284            keep
285        });
286        self.panes.retain(|target, _| {
287            let keep = target.session_name() != session_name;
288            removed |= !keep;
289            keep
290        });
291        removed
292    }
293
294    /// Removes all hooks owned by the given window and its panes.
295    pub fn remove_window(&mut self, target: &WindowTarget) -> bool {
296        let mut removed = self.windows.remove(target).is_some();
297        self.panes.retain(|pane_target, _| {
298            let keep = pane_target.session_name() != target.session_name()
299                || pane_target.window_index() != target.window_index();
300            removed |= !keep;
301            keep
302        });
303        removed
304    }
305
306    /// Removes all hooks owned by the given pane.
307    pub fn remove_pane(&mut self, target: &PaneTarget) -> bool {
308        self.panes.remove(target).is_some()
309    }
310
311    /// Rekeys all hooks owned by the given session.
312    pub fn rename_session(
313        &mut self,
314        session_name: &SessionName,
315        new_name: SessionName,
316    ) -> Result<(), RmuxError> {
317        let mut renamed_sessions = HashMap::with_capacity(self.sessions.len());
318        for (name, bindings) in &self.sessions {
319            let next_name = if name == session_name {
320                new_name.clone()
321            } else {
322                name.clone()
323            };
324            if renamed_sessions
325                .insert(next_name.clone(), bindings.clone())
326                .is_some()
327            {
328                return Err(RmuxError::Server(format!(
329                    "hooks already exist for session {next_name}"
330                )));
331            }
332        }
333
334        let mut renamed_windows = HashMap::with_capacity(self.windows.len());
335        for (target, bindings) in &self.windows {
336            let next_target = if target.session_name() == session_name {
337                WindowTarget::with_window(new_name.clone(), target.window_index())
338            } else {
339                target.clone()
340            };
341            if renamed_windows
342                .insert(next_target.clone(), bindings.clone())
343                .is_some()
344            {
345                return Err(RmuxError::Server(format!(
346                    "hooks already exist for {next_target}"
347                )));
348            }
349        }
350
351        let mut renamed_panes = HashMap::with_capacity(self.panes.len());
352        for (target, bindings) in &self.panes {
353            let next_target = if target.session_name() == session_name {
354                PaneTarget::with_window(
355                    new_name.clone(),
356                    target.window_index(),
357                    target.pane_index(),
358                )
359            } else {
360                target.clone()
361            };
362            if renamed_panes
363                .insert(next_target.clone(), bindings.clone())
364                .is_some()
365            {
366                return Err(RmuxError::Server(format!(
367                    "hooks already exist for {next_target}"
368                )));
369            }
370        }
371
372        self.sessions = renamed_sessions;
373        self.windows = renamed_windows;
374        self.panes = renamed_panes;
375        Ok(())
376    }
377
378    fn bindings_for_scope_mut(
379        &mut self,
380        hook: HookName,
381        scope: &ScopeSelector,
382    ) -> &mut HookBindings {
383        match scope {
384            ScopeSelector::Global => self.global_bindings_mut(root_for_hook(hook)),
385            ScopeSelector::Session(session_name) => {
386                self.sessions.entry(session_name.clone()).or_default()
387            }
388            ScopeSelector::Window(target) => self.windows.entry(target.clone()).or_default(),
389            ScopeSelector::Pane(target) => self.panes.entry(target.clone()).or_default(),
390        }
391    }
392
393    fn global_bindings(&self, root: HookGlobalRoot) -> &HookBindings {
394        match root {
395            HookGlobalRoot::Session => &self.session_global,
396            HookGlobalRoot::Window => &self.window_global,
397        }
398    }
399
400    fn global_bindings_mut(&mut self, root: HookGlobalRoot) -> &mut HookBindings {
401        match root {
402            HookGlobalRoot::Session => &mut self.session_global,
403            HookGlobalRoot::Window => &mut self.window_global,
404        }
405    }
406
407    fn dispatch_session(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
408        let session_name = match scope {
409            ScopeSelector::Session(session_name) => Some(session_name.clone()),
410            ScopeSelector::Window(target) => Some(target.session_name().clone()),
411            ScopeSelector::Pane(target) => Some(target.session_name().clone()),
412            ScopeSelector::Global => None,
413        };
414
415        if let Some(session_name) = session_name {
416            let (dispatches, remove_scope) =
417                if let Some(bindings) = self.sessions.get_mut(&session_name) {
418                    let dispatches = bindings.dispatch(hook);
419                    let should_remove = bindings.is_empty();
420                    (dispatches, should_remove)
421                } else {
422                    (Vec::new(), false)
423                };
424            if remove_scope {
425                self.sessions.remove(&session_name);
426            }
427            if !dispatches.is_empty() {
428                return dispatches;
429            }
430        }
431
432        self.session_global.dispatch(hook)
433    }
434
435    fn dispatch_window(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
436        let target = match scope {
437            ScopeSelector::Window(target) => Some(target.clone()),
438            ScopeSelector::Pane(target) => Some(WindowTarget::with_window(
439                target.session_name().clone(),
440                target.window_index(),
441            )),
442            ScopeSelector::Global | ScopeSelector::Session(_) => None,
443        };
444
445        if let Some(target) = target {
446            let (dispatches, remove_scope) = if let Some(bindings) = self.windows.get_mut(&target) {
447                let dispatches = bindings.dispatch(hook);
448                let should_remove = bindings.is_empty();
449                (dispatches, should_remove)
450            } else {
451                (Vec::new(), false)
452            };
453            if remove_scope {
454                self.windows.remove(&target);
455            }
456            if !dispatches.is_empty() {
457                return dispatches;
458            }
459        }
460
461        self.window_global.dispatch(hook)
462    }
463
464    fn dispatch_pane(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
465        if let ScopeSelector::Pane(target) = scope {
466            let target = target.clone();
467            let (dispatches, remove_scope) = if let Some(bindings) = self.panes.get_mut(&target) {
468                let dispatches = bindings.dispatch(hook);
469                let should_remove = bindings.is_empty();
470                (dispatches, should_remove)
471            } else {
472                (Vec::new(), false)
473            };
474            if remove_scope {
475                self.panes.remove(&target);
476            }
477            if !dispatches.is_empty() {
478                return dispatches;
479            }
480        }
481
482        self.dispatch_window(scope, hook)
483    }
484}
485
486#[cfg(test)]
487#[path = "hooks/tests.rs"]
488mod tests;