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