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::{hook_global_root, 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    /// Swaps window and pane hooks between two winlink slots.
307    pub fn swap_window_hooks(&mut self, source: &WindowTarget, target: &WindowTarget) {
308        if source == target {
309            return;
310        }
311
312        let source_window = self.windows.remove(source);
313        let target_window = self.windows.remove(target);
314        if let Some(bindings) = source_window {
315            self.windows.insert(target.clone(), bindings);
316        }
317        if let Some(bindings) = target_window {
318            self.windows.insert(source.clone(), bindings);
319        }
320
321        let source_panes = remove_window_pane_hooks(&mut self.panes, source);
322        let target_panes = remove_window_pane_hooks(&mut self.panes, target);
323        self.panes
324            .extend(rekey_pane_hooks(source_panes, source, target));
325        self.panes
326            .extend(rekey_pane_hooks(target_panes, target, source));
327    }
328
329    /// Moves window and pane hooks from one winlink slot to another.
330    pub fn move_window_hooks(&mut self, source: &WindowTarget, target: &WindowTarget) {
331        if source == target {
332            return;
333        }
334
335        let source_window = self.windows.remove(source);
336        let _ = self.windows.remove(target);
337        if let Some(bindings) = source_window {
338            self.windows.insert(target.clone(), bindings);
339        }
340
341        let source_panes = remove_window_pane_hooks(&mut self.panes, source);
342        let _ = remove_window_pane_hooks(&mut self.panes, target);
343        self.panes
344            .extend(rekey_pane_hooks(source_panes, source, target));
345    }
346
347    /// Removes all hooks owned by the given pane.
348    pub fn remove_pane(&mut self, target: &PaneTarget) -> bool {
349        self.panes.remove(target).is_some()
350    }
351
352    /// Rekeys window and pane hooks after a session window reindex.
353    pub fn remap_session_window_indices(
354        &mut self,
355        session_name: &SessionName,
356        index_map: &BTreeMap<u32, u32>,
357    ) -> Result<(), RmuxError> {
358        let mut remapped_windows = HashMap::with_capacity(self.windows.len());
359        for (target, bindings) in &self.windows {
360            let next_target = remapped_window_target(target, session_name, index_map);
361            if remapped_windows
362                .insert(next_target.clone(), bindings.clone())
363                .is_some()
364            {
365                return Err(RmuxError::Server(format!(
366                    "hooks already exist for {next_target}"
367                )));
368            }
369        }
370
371        let mut remapped_panes = HashMap::with_capacity(self.panes.len());
372        for (target, bindings) in &self.panes {
373            let next_target = remapped_pane_target(target, session_name, index_map);
374            if remapped_panes
375                .insert(next_target.clone(), bindings.clone())
376                .is_some()
377            {
378                return Err(RmuxError::Server(format!(
379                    "hooks already exist for {next_target}"
380                )));
381            }
382        }
383
384        self.windows = remapped_windows;
385        self.panes = remapped_panes;
386        Ok(())
387    }
388
389    /// Rekeys all hooks owned by the given session.
390    pub fn rename_session(
391        &mut self,
392        session_name: &SessionName,
393        new_name: SessionName,
394    ) -> Result<(), RmuxError> {
395        let mut renamed_sessions = HashMap::with_capacity(self.sessions.len());
396        for (name, bindings) in &self.sessions {
397            let next_name = if name == session_name {
398                new_name.clone()
399            } else {
400                name.clone()
401            };
402            if renamed_sessions
403                .insert(next_name.clone(), bindings.clone())
404                .is_some()
405            {
406                return Err(RmuxError::Server(format!(
407                    "hooks already exist for session {next_name}"
408                )));
409            }
410        }
411
412        let mut renamed_windows = HashMap::with_capacity(self.windows.len());
413        for (target, bindings) in &self.windows {
414            let next_target = if target.session_name() == session_name {
415                WindowTarget::with_window(new_name.clone(), target.window_index())
416            } else {
417                target.clone()
418            };
419            if renamed_windows
420                .insert(next_target.clone(), bindings.clone())
421                .is_some()
422            {
423                return Err(RmuxError::Server(format!(
424                    "hooks already exist for {next_target}"
425                )));
426            }
427        }
428
429        let mut renamed_panes = HashMap::with_capacity(self.panes.len());
430        for (target, bindings) in &self.panes {
431            let next_target = if target.session_name() == session_name {
432                PaneTarget::with_window(
433                    new_name.clone(),
434                    target.window_index(),
435                    target.pane_index(),
436                )
437            } else {
438                target.clone()
439            };
440            if renamed_panes
441                .insert(next_target.clone(), bindings.clone())
442                .is_some()
443            {
444                return Err(RmuxError::Server(format!(
445                    "hooks already exist for {next_target}"
446                )));
447            }
448        }
449
450        self.sessions = renamed_sessions;
451        self.windows = renamed_windows;
452        self.panes = renamed_panes;
453        Ok(())
454    }
455
456    fn bindings_for_scope_mut(
457        &mut self,
458        hook: HookName,
459        scope: &ScopeSelector,
460    ) -> &mut HookBindings {
461        match scope {
462            ScopeSelector::Global => self.global_bindings_mut(root_for_hook(hook)),
463            ScopeSelector::Session(session_name) => {
464                self.sessions.entry(session_name.clone()).or_default()
465            }
466            ScopeSelector::Window(target) => self.windows.entry(target.clone()).or_default(),
467            ScopeSelector::Pane(target) => self.panes.entry(target.clone()).or_default(),
468        }
469    }
470
471    fn global_bindings(&self, root: HookGlobalRoot) -> &HookBindings {
472        match root {
473            HookGlobalRoot::Session => &self.session_global,
474            HookGlobalRoot::Window => &self.window_global,
475        }
476    }
477
478    fn global_bindings_mut(&mut self, root: HookGlobalRoot) -> &mut HookBindings {
479        match root {
480            HookGlobalRoot::Session => &mut self.session_global,
481            HookGlobalRoot::Window => &mut self.window_global,
482        }
483    }
484
485    fn dispatch_session(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
486        let session_name = match scope {
487            ScopeSelector::Session(session_name) => Some(session_name.clone()),
488            ScopeSelector::Window(target) => Some(target.session_name().clone()),
489            ScopeSelector::Pane(target) => Some(target.session_name().clone()),
490            ScopeSelector::Global => None,
491        };
492
493        if let Some(session_name) = session_name {
494            let (dispatches, remove_scope) =
495                if let Some(bindings) = self.sessions.get_mut(&session_name) {
496                    let dispatches = bindings.dispatch(hook);
497                    let should_remove = bindings.is_empty();
498                    (dispatches, should_remove)
499                } else {
500                    (Vec::new(), false)
501                };
502            if remove_scope {
503                self.sessions.remove(&session_name);
504            }
505            if !dispatches.is_empty() {
506                return dispatches;
507            }
508        }
509
510        self.session_global.dispatch(hook)
511    }
512
513    fn dispatch_window(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
514        let target = match scope {
515            ScopeSelector::Window(target) => Some(target.clone()),
516            ScopeSelector::Pane(target) => Some(WindowTarget::with_window(
517                target.session_name().clone(),
518                target.window_index(),
519            )),
520            ScopeSelector::Global | ScopeSelector::Session(_) => None,
521        };
522
523        if let Some(target) = target {
524            let (dispatches, remove_scope) = if let Some(bindings) = self.windows.get_mut(&target) {
525                let dispatches = bindings.dispatch(hook);
526                let should_remove = bindings.is_empty();
527                (dispatches, should_remove)
528            } else {
529                (Vec::new(), false)
530            };
531            if remove_scope {
532                self.windows.remove(&target);
533            }
534            if !dispatches.is_empty() {
535                return dispatches;
536            }
537        }
538
539        self.window_global.dispatch(hook)
540    }
541
542    fn dispatch_pane(&mut self, scope: &ScopeSelector, hook: HookName) -> Vec<HookDispatch> {
543        if let ScopeSelector::Pane(target) = scope {
544            let target = target.clone();
545            let (dispatches, remove_scope) = if let Some(bindings) = self.panes.get_mut(&target) {
546                let dispatches = bindings.dispatch(hook);
547                let should_remove = bindings.is_empty();
548                (dispatches, should_remove)
549            } else {
550                (Vec::new(), false)
551            };
552            if remove_scope {
553                self.panes.remove(&target);
554            }
555            if !dispatches.is_empty() {
556                return dispatches;
557            }
558        }
559
560        self.dispatch_window(scope, hook)
561    }
562}
563
564fn remove_window_pane_hooks(
565    panes: &mut HashMap<PaneTarget, HookBindings>,
566    window: &WindowTarget,
567) -> Vec<(PaneTarget, HookBindings)> {
568    let pane_targets = panes
569        .keys()
570        .filter(|pane_target| {
571            pane_target.session_name() == window.session_name()
572                && pane_target.window_index() == window.window_index()
573        })
574        .cloned()
575        .collect::<Vec<_>>();
576    pane_targets
577        .into_iter()
578        .filter_map(|pane_target| {
579            panes
580                .remove(&pane_target)
581                .map(|bindings| (pane_target, bindings))
582        })
583        .collect()
584}
585
586fn rekey_pane_hooks(
587    panes: Vec<(PaneTarget, HookBindings)>,
588    source: &WindowTarget,
589    target: &WindowTarget,
590) -> Vec<(PaneTarget, HookBindings)> {
591    panes
592        .into_iter()
593        .map(move |(pane_target, bindings)| {
594            debug_assert_eq!(pane_target.session_name(), source.session_name());
595            debug_assert_eq!(pane_target.window_index(), source.window_index());
596            (
597                PaneTarget::with_window(
598                    target.session_name().clone(),
599                    target.window_index(),
600                    pane_target.pane_index(),
601                ),
602                bindings,
603            )
604        })
605        .collect()
606}
607
608fn remapped_window_target(
609    target: &WindowTarget,
610    session_name: &SessionName,
611    index_map: &BTreeMap<u32, u32>,
612) -> WindowTarget {
613    if target.session_name() != session_name {
614        return target.clone();
615    }
616    index_map.get(&target.window_index()).copied().map_or_else(
617        || target.clone(),
618        |window_index| WindowTarget::with_window(session_name.clone(), window_index),
619    )
620}
621
622fn remapped_pane_target(
623    target: &PaneTarget,
624    session_name: &SessionName,
625    index_map: &BTreeMap<u32, u32>,
626) -> PaneTarget {
627    if target.session_name() != session_name {
628        return target.clone();
629    }
630    index_map.get(&target.window_index()).copied().map_or_else(
631        || target.clone(),
632        |window_index| {
633            PaneTarget::with_window(session_name.clone(), window_index, target.pane_index())
634        },
635    )
636}
637
638#[cfg(test)]
639#[path = "hooks/tests.rs"]
640mod tests;