Skip to main content

rmux_core/
options.rs

1use std::collections::HashMap;
2
3use rmux_proto::types::OptionScopeSelector;
4use rmux_proto::{
5    OptionName, PaneTarget, RmuxError, ScopeSelector, SessionName, SetOptionMode, WindowTarget,
6};
7
8#[path = "options/access.rs"]
9mod access;
10#[path = "options/mutation.rs"]
11mod mutation;
12#[path = "options/registry.rs"]
13mod registry;
14#[path = "options/render.rs"]
15mod render;
16#[path = "options/scope.rs"]
17mod scope;
18#[path = "options/show.rs"]
19mod show;
20#[path = "options/storage.rs"]
21mod storage;
22
23use mutation::{
24    apply_array_mutation, build_mutation_outcome, is_global_scope, legacy_scope_for_option,
25    normalize_scalar_value,
26};
27pub use mutation::{validate_option_mutation, validate_option_name_mutation};
28pub use registry::{
29    option_affects_alerts, option_affects_rendering, option_name_by_name, resolve_option_name,
30    OptionQuery,
31};
32use registry::{option_metadata, OptionChangeMask, OptionValueType};
33pub use scope::default_global_scope_for_option_name;
34use storage::{OptionEntry, OptionNode};
35
36/// Option rendering mode for `show-options`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ShowOptionsMode {
39    /// Render the fully resolved view for each known option.
40    Resolved,
41    /// Render the resolved view and mark inherited values with `*`, matching `show-options -A`.
42    ResolvedWithInheritanceMarkers,
43    /// Render only entries explicitly present in the selected tree.
44    Explicit,
45}
46
47/// A server-visible option mutation side effect.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct OptionNotification {
50    /// The canonical option name.
51    pub name: String,
52    /// The exact scope that was mutated.
53    pub scope: OptionScopeSelector,
54    /// The effect bitmask associated with the option.
55    pub effects: OptionChangeMask,
56}
57
58/// Outcome for a successful option mutation.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct OptionMutationOutcome {
61    /// The canonical option name.
62    pub name: String,
63    /// The known wire option, when the option is part of the closed V1 registry.
64    pub known_option: Option<OptionName>,
65    /// Side effects the server may react to.
66    pub notifications: Vec<OptionNotification>,
67}
68
69type SessionOptions = HashMap<SessionName, OptionNode>;
70type WindowOptions = HashMap<WindowTarget, OptionNode>;
71type PaneOptions = HashMap<PaneTarget, OptionNode>;
72
73/// In-memory storage for supported RMUX option values.
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct OptionStore {
76    server_global: OptionNode,
77    session_global: OptionNode,
78    window_global: OptionNode,
79    sessions: SessionOptions,
80    windows: WindowOptions,
81    panes: PaneOptions,
82}
83
84impl OptionStore {
85    /// Creates an empty option store with no explicit overrides.
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Returns whether no explicit option overrides are present.
92    #[must_use]
93    pub fn is_empty(&self) -> bool {
94        self.server_global.is_empty()
95            && self.session_global.is_empty()
96            && self.window_global.is_empty()
97            && self.sessions.values().all(OptionNode::is_empty)
98            && self.windows.values().all(OptionNode::is_empty)
99            && self.panes.values().all(OptionNode::is_empty)
100    }
101
102    /// Applies a mutation for a known legacy option.
103    pub fn set(
104        &mut self,
105        scope: ScopeSelector,
106        option: OptionName,
107        value: String,
108        mode: SetOptionMode,
109    ) -> Result<OptionMutationOutcome, RmuxError> {
110        let explicit_scope = legacy_scope_for_option(option, &scope);
111        self.set_by_name(
112            explicit_scope,
113            option_metadata(option).name(),
114            Some(value),
115            mode,
116            false,
117            false,
118            false,
119        )
120    }
121
122    /// Applies a mutation using a tmux-style string option name.
123    #[allow(clippy::too_many_arguments)]
124    pub fn set_by_name(
125        &mut self,
126        scope: OptionScopeSelector,
127        name: &str,
128        value: Option<String>,
129        mode: SetOptionMode,
130        only_if_unset: bool,
131        unset: bool,
132        unset_pane_overrides: bool,
133    ) -> Result<OptionMutationOutcome, RmuxError> {
134        if unset_pane_overrides && !matches!(scope, OptionScopeSelector::Window(_)) {
135            return Err(RmuxError::InvalidSetOption(
136                "unset pane overrides only supports window scope".to_owned(),
137            ));
138        }
139
140        let query = validate_option_name_mutation(name, &scope, mode, value.as_deref(), unset)?;
141
142        if unset_pane_overrides {
143            self.unset_window_pane_overrides(&scope, query.canonical_name());
144        }
145
146        if unset {
147            self.unset_query(scope, &query, only_if_unset)
148        } else {
149            self.set_query(scope, &query, value.as_deref(), mode, only_if_unset)
150        }
151    }
152
153    /// Removes all option overrides owned by the given session.
154    pub fn remove_session(
155        &mut self,
156        session_name: &SessionName,
157    ) -> Option<HashMap<OptionName, String>> {
158        self.windows
159            .retain(|target, _| target.session_name() != session_name);
160        self.panes
161            .retain(|target, _| target.session_name() != session_name);
162        self.sessions
163            .remove(session_name)
164            .map(|node| node.into_known_values())
165    }
166
167    /// Rekeys all option overrides owned by the given session.
168    pub fn rename_session(
169        &mut self,
170        session_name: &SessionName,
171        new_name: SessionName,
172    ) -> Result<(), RmuxError> {
173        let mut renamed_sessions = HashMap::with_capacity(self.sessions.len());
174        for (name, values) in &self.sessions {
175            let next_name = if name == session_name {
176                new_name.clone()
177            } else {
178                name.clone()
179            };
180            if renamed_sessions
181                .insert(next_name.clone(), values.clone())
182                .is_some()
183            {
184                return Err(RmuxError::Server(format!(
185                    "session options already exist for session {next_name}"
186                )));
187            }
188        }
189
190        let mut renamed_windows = HashMap::with_capacity(self.windows.len());
191        for (target, values) in &self.windows {
192            let next_target = if target.session_name() == session_name {
193                WindowTarget::with_window(new_name.clone(), target.window_index())
194            } else {
195                target.clone()
196            };
197            if renamed_windows
198                .insert(next_target.clone(), values.clone())
199                .is_some()
200            {
201                return Err(RmuxError::Server(format!(
202                    "window options already exist for {next_target}"
203                )));
204            }
205        }
206
207        let mut renamed_panes = HashMap::with_capacity(self.panes.len());
208        for (target, values) in &self.panes {
209            let next_target = if target.session_name() == session_name {
210                PaneTarget::with_window(
211                    new_name.clone(),
212                    target.window_index(),
213                    target.pane_index(),
214                )
215            } else {
216                target.clone()
217            };
218            if renamed_panes
219                .insert(next_target.clone(), values.clone())
220                .is_some()
221            {
222                return Err(RmuxError::Server(format!(
223                    "pane options already exist for {next_target}"
224                )));
225            }
226        }
227
228        self.sessions = renamed_sessions;
229        self.windows = renamed_windows;
230        self.panes = renamed_panes;
231        Ok(())
232    }
233
234    /// Removes all window and pane option overrides owned by the given window.
235    pub fn remove_window(&mut self, target: &WindowTarget) -> Option<HashMap<OptionName, String>> {
236        self.panes.retain(|pane_target, _| {
237            pane_target.session_name() != target.session_name()
238                || pane_target.window_index() != target.window_index()
239        });
240        self.windows
241            .remove(target)
242            .map(OptionNode::into_known_values)
243    }
244
245    /// Removes all pane option overrides owned by the given pane.
246    pub fn remove_pane(&mut self, target: &PaneTarget) -> Option<HashMap<OptionName, String>> {
247        self.panes.remove(target).map(OptionNode::into_known_values)
248    }
249
250    fn set_query(
251        &mut self,
252        scope: OptionScopeSelector,
253        query: &OptionQuery,
254        value: Option<&str>,
255        mode: SetOptionMode,
256        only_if_unset: bool,
257    ) -> Result<OptionMutationOutcome, RmuxError> {
258        let effective_before = self
259            .effective_value_for_scope(&scope, query)
260            .or_else(|| self.default_value_as_string(query));
261        let explicit_before = self.explicit_value_for_scope(&scope, query);
262        let default_entry = self.default_entry_for_scope(query, scope.clone());
263        let node = self.node_for_exact_scope_mut(&scope);
264        if only_if_unset && node.contains(query.canonical_name(), query.index()) {
265            return Err(RmuxError::InvalidSetOption(format!(
266                "{} is already set",
267                query.canonical_name()
268            )));
269        }
270
271        if query.is_array()
272            && mode == SetOptionMode::Append
273            && query.index().is_none()
274            && value.unwrap_or_default().is_empty()
275        {
276            return Ok(build_mutation_outcome(query, scope));
277        }
278
279        if query.is_array() {
280            let entry = node
281                .entries
282                .entry(query.canonical_name().to_owned())
283                .or_insert_with(|| {
284                    if is_global_scope(&scope) {
285                        default_entry.unwrap_or_else(|| {
286                            OptionEntry::new_empty_array(
287                                query.canonical_name(),
288                                query.known_option(),
289                                scope.clone(),
290                                query.value_type(),
291                            )
292                        })
293                    } else {
294                        OptionEntry::new_empty_array(
295                            query.canonical_name(),
296                            query.known_option(),
297                            scope.clone(),
298                            query.value_type(),
299                        )
300                    }
301                });
302            apply_array_mutation(
303                entry,
304                query,
305                value.unwrap_or_default(),
306                mode,
307                explicit_before.as_deref(),
308            )?;
309        } else {
310            let current = match (query.value_type(), mode) {
311                (OptionValueType::String, SetOptionMode::Append) => {
312                    if explicit_before.is_some() || is_global_scope(&scope) {
313                        explicit_before.clone().or_else(|| effective_before.clone())
314                    } else {
315                        None
316                    }
317                }
318                (OptionValueType::String, SetOptionMode::Replace) => None,
319                _ => effective_before.clone(),
320            };
321            let normalized = normalize_scalar_value(query, value, current.as_deref())?;
322            node.entries.insert(
323                query.canonical_name().to_owned(),
324                OptionEntry::new_scalar(query, scope.clone(), normalized),
325            );
326        }
327
328        Ok(build_mutation_outcome(query, scope))
329    }
330
331    fn unset_query(
332        &mut self,
333        scope: OptionScopeSelector,
334        query: &OptionQuery,
335        only_if_unset: bool,
336    ) -> Result<OptionMutationOutcome, RmuxError> {
337        let default_entry = self.default_entry_for_scope(query, scope.clone());
338        let node = self.node_for_exact_scope_mut(&scope);
339        if only_if_unset && node.contains(query.canonical_name(), query.index()) {
340            return Err(RmuxError::InvalidSetOption(format!(
341                "{} is already set",
342                query.canonical_name()
343            )));
344        }
345
346        if query.is_array() && query.index().is_some() {
347            let remove_node = if let Some(entry) = node.entries.get_mut(query.canonical_name()) {
348                entry.remove_array_index(query.index().unwrap(), query.separator());
349                entry.is_empty()
350            } else {
351                false
352            };
353            if remove_node {
354                node.entries.remove(query.canonical_name());
355            }
356        } else if is_global_scope(&scope) {
357            if let Some(default_entry) = default_entry {
358                node.entries
359                    .insert(query.canonical_name().to_owned(), default_entry);
360            } else {
361                node.entries.remove(query.canonical_name());
362            }
363        } else {
364            node.entries.remove(query.canonical_name());
365        }
366
367        Ok(build_mutation_outcome(query, scope))
368    }
369
370    fn unset_window_pane_overrides(&mut self, scope: &OptionScopeSelector, name: &str) {
371        let OptionScopeSelector::Window(target) = scope else {
372            return;
373        };
374        self.panes.retain(|pane_target, node| {
375            let matches_window = pane_target.session_name() == target.session_name()
376                && pane_target.window_index() == target.window_index();
377            if matches_window {
378                node.entries.remove(name);
379            }
380            !node.is_empty()
381        });
382    }
383}
384
385#[cfg(test)]
386#[path = "options/tests.rs"]
387mod tests;