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