Skip to main content

rmux_core/
hooks.rs

1use std::collections::{BTreeMap, 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 window and pane hooks after a session window reindex.
312    pub fn remap_session_window_indices(
313        &mut self,
314        session_name: &SessionName,
315        index_map: &BTreeMap<u32, u32>,
316    ) -> Result<(), RmuxError> {
317        let mut remapped_windows = HashMap::with_capacity(self.windows.len());
318        for (target, bindings) in &self.windows {
319            let next_target = remapped_window_target(target, session_name, index_map);
320            if remapped_windows
321                .insert(next_target.clone(), bindings.clone())
322                .is_some()
323            {
324                return Err(RmuxError::Server(format!(
325                    "hooks already exist for {next_target}"
326                )));
327            }
328        }
329
330        let mut remapped_panes = HashMap::with_capacity(self.panes.len());
331        for (target, bindings) in &self.panes {
332            let next_target = remapped_pane_target(target, session_name, index_map);
333            if remapped_panes
334                .insert(next_target.clone(), bindings.clone())
335                .is_some()
336            {
337                return Err(RmuxError::Server(format!(
338                    "hooks already exist for {next_target}"
339                )));
340            }
341        }
342
343        self.windows = remapped_windows;
344        self.panes = remapped_panes;
345        Ok(())
346    }
347
348    /// Rekeys all hooks owned by the given session.
349    pub fn rename_session(
350        &mut self,
351        session_name: &SessionName,
352        new_name: SessionName,
353    ) -> Result<(), RmuxError> {
354        let mut renamed_sessions = HashMap::with_capacity(self.sessions.len());
355        for (name, bindings) in &self.sessions {
356            let next_name = if name == session_name {
357                new_name.clone()
358            } else {
359                name.clone()
360            };
361            if renamed_sessions
362                .insert(next_name.clone(), bindings.clone())
363                .is_some()
364            {
365                return Err(RmuxError::Server(format!(
366                    "hooks already exist for session {next_name}"
367                )));
368            }
369        }
370
371        let mut renamed_windows = HashMap::with_capacity(self.windows.len());
372        for (target, bindings) in &self.windows {
373            let next_target = if target.session_name() == session_name {
374                WindowTarget::with_window(new_name.clone(), target.window_index())
375            } else {
376                target.clone()
377            };
378            if renamed_windows
379                .insert(next_target.clone(), bindings.clone())
380                .is_some()
381            {
382                return Err(RmuxError::Server(format!(
383                    "hooks already exist for {next_target}"
384                )));
385            }
386        }
387
388        let mut renamed_panes = HashMap::with_capacity(self.panes.len());
389        for (target, bindings) in &self.panes {
390            let next_target = if target.session_name() == session_name {
391                PaneTarget::with_window(
392                    new_name.clone(),
393                    target.window_index(),
394                    target.pane_index(),
395                )
396            } else {
397                target.clone()
398            };
399            if renamed_panes
400                .insert(next_target.clone(), bindings.clone())
401                .is_some()
402            {
403                return Err(RmuxError::Server(format!(
404                    "hooks already exist for {next_target}"
405                )));
406            }
407        }
408
409        self.sessions = renamed_sessions;
410        self.windows = renamed_windows;
411        self.panes = renamed_panes;
412        Ok(())
413    }
414
415    fn bindings_for_scope_mut(
416        &mut self,
417        hook: HookName,
418        scope: &ScopeSelector,
419    ) -> &mut HookBindings {
420        match scope {
421            ScopeSelector::Global => self.global_bindings_mut(root_for_hook(hook)),
422            ScopeSelector::Session(session_name) => {
423                self.sessions.entry(session_name.clone()).or_default()
424            }
425            ScopeSelector::Window(target) => self.windows.entry(target.clone()).or_default(),
426            ScopeSelector::Pane(target) => self.panes.entry(target.clone()).or_default(),
427        }
428    }
429
430    fn global_bindings(&self, root: HookGlobalRoot) -> &HookBindings {
431        match root {
432            HookGlobalRoot::Session => &self.session_global,
433            HookGlobalRoot::Window => &self.window_global,
434        }
435    }
436
437    fn global_bindings_mut(&mut self, root: HookGlobalRoot) -> &mut HookBindings {
438        match root {
439            HookGlobalRoot::Session => &mut self.session_global,
440            HookGlobalRoot::Window => &mut self.window_global,
441        }
442    }
443
444    fn dispatch_session(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
445        let session_name = match scope {
446            ScopeSelector::Session(session_name) => Some(session_name.clone()),
447            ScopeSelector::Window(target) => Some(target.session_name().clone()),
448            ScopeSelector::Pane(target) => Some(target.session_name().clone()),
449            ScopeSelector::Global => None,
450        };
451
452        if let Some(session_name) = session_name {
453            let (dispatches, remove_scope) =
454                if let Some(bindings) = self.sessions.get_mut(&session_name) {
455                    let dispatches = bindings.dispatch(hook);
456                    let should_remove = bindings.is_empty();
457                    (dispatches, should_remove)
458                } else {
459                    (Vec::new(), false)
460                };
461            if remove_scope {
462                self.sessions.remove(&session_name);
463            }
464            if !dispatches.is_empty() {
465                return dispatches;
466            }
467        }
468
469        self.session_global.dispatch(hook)
470    }
471
472    fn dispatch_window(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
473        let target = match scope {
474            ScopeSelector::Window(target) => Some(target.clone()),
475            ScopeSelector::Pane(target) => Some(WindowTarget::with_window(
476                target.session_name().clone(),
477                target.window_index(),
478            )),
479            ScopeSelector::Global | ScopeSelector::Session(_) => None,
480        };
481
482        if let Some(target) = target {
483            let (dispatches, remove_scope) = if let Some(bindings) = self.windows.get_mut(&target) {
484                let dispatches = bindings.dispatch(hook);
485                let should_remove = bindings.is_empty();
486                (dispatches, should_remove)
487            } else {
488                (Vec::new(), false)
489            };
490            if remove_scope {
491                self.windows.remove(&target);
492            }
493            if !dispatches.is_empty() {
494                return dispatches;
495            }
496        }
497
498        self.window_global.dispatch(hook)
499    }
500
501    fn dispatch_pane(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
502        if let ScopeSelector::Pane(target) = scope {
503            let target = target.clone();
504            let (dispatches, remove_scope) = if let Some(bindings) = self.panes.get_mut(&target) {
505                let dispatches = bindings.dispatch(hook);
506                let should_remove = bindings.is_empty();
507                (dispatches, should_remove)
508            } else {
509                (Vec::new(), false)
510            };
511            if remove_scope {
512                self.panes.remove(&target);
513            }
514            if !dispatches.is_empty() {
515                return dispatches;
516            }
517        }
518
519        self.dispatch_window(scope, hook)
520    }
521}
522
523fn remapped_window_target(
524    target: &WindowTarget,
525    session_name: &SessionName,
526    index_map: &BTreeMap<u32, u32>,
527) -> WindowTarget {
528    if target.session_name() != session_name {
529        return target.clone();
530    }
531    index_map.get(&target.window_index()).copied().map_or_else(
532        || target.clone(),
533        |window_index| WindowTarget::with_window(session_name.clone(), window_index),
534    )
535}
536
537fn remapped_pane_target(
538    target: &PaneTarget,
539    session_name: &SessionName,
540    index_map: &BTreeMap<u32, u32>,
541) -> PaneTarget {
542    if target.session_name() != session_name {
543        return target.clone();
544    }
545    index_map.get(&target.window_index()).copied().map_or_else(
546        || target.clone(),
547        |window_index| {
548            PaneTarget::with_window(session_name.clone(), window_index, target.pane_index())
549        },
550    )
551}
552
553#[cfg(test)]
554#[path = "hooks/tests.rs"]
555mod tests;