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