Skip to main content

rmux_core/
options.rs

1use std::collections::{BTreeMap, 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    /// Copies exact window and pane overrides from one winlink slot to another.
246    pub fn copy_window_overrides(&mut self, source: &WindowTarget, target: &WindowTarget) {
247        if let Some(source_window) = self.windows.get(source).cloned() {
248            self.windows.insert(
249                target.clone(),
250                source_window.with_scope(OptionScopeSelector::Window(target.clone())),
251            );
252        } else {
253            self.windows.remove(target);
254        }
255
256        self.panes.retain(|pane_target, _| {
257            pane_target.session_name() != target.session_name()
258                || pane_target.window_index() != target.window_index()
259        });
260
261        let source_panes = self
262            .panes
263            .iter()
264            .filter(|(pane_target, _)| {
265                pane_target.session_name() == source.session_name()
266                    && pane_target.window_index() == source.window_index()
267            })
268            .map(|(pane_target, node)| {
269                let target_pane = PaneTarget::with_window(
270                    target.session_name().clone(),
271                    target.window_index(),
272                    pane_target.pane_index(),
273                );
274                (
275                    target_pane.clone(),
276                    node.clone()
277                        .with_scope(OptionScopeSelector::Pane(target_pane)),
278                )
279            })
280            .collect::<Vec<_>>();
281
282        self.panes.extend(source_panes);
283    }
284
285    /// Removes all pane option overrides owned by the given pane.
286    pub fn remove_pane(&mut self, target: &PaneTarget) -> Option<HashMap<OptionName, String>> {
287        self.panes.remove(target).map(OptionNode::into_known_values)
288    }
289
290    /// Rekeys window and pane option overrides after a session window reindex.
291    pub fn remap_session_window_indices(
292        &mut self,
293        session_name: &SessionName,
294        index_map: &BTreeMap<u32, u32>,
295    ) -> Result<(), RmuxError> {
296        let mut remapped_windows = HashMap::with_capacity(self.windows.len());
297        for (target, values) in &self.windows {
298            let next_target = remapped_window_target(target, session_name, index_map);
299            if remapped_windows
300                .insert(next_target.clone(), values.clone())
301                .is_some()
302            {
303                return Err(RmuxError::Server(format!(
304                    "window options already exist for {next_target}"
305                )));
306            }
307        }
308
309        let mut remapped_panes = HashMap::with_capacity(self.panes.len());
310        for (target, values) in &self.panes {
311            let next_target = remapped_pane_target(target, session_name, index_map);
312            if remapped_panes
313                .insert(next_target.clone(), values.clone())
314                .is_some()
315            {
316                return Err(RmuxError::Server(format!(
317                    "pane options already exist for {next_target}"
318                )));
319            }
320        }
321
322        self.windows = remapped_windows;
323        self.panes = remapped_panes;
324        Ok(())
325    }
326
327    fn set_query(
328        &mut self,
329        scope: OptionScopeSelector,
330        query: &OptionQuery,
331        value: Option<&str>,
332        mode: SetOptionMode,
333        only_if_unset: bool,
334    ) -> Result<OptionMutationOutcome, RmuxError> {
335        let effective_before = self
336            .effective_value_for_scope(&scope, query)
337            .or_else(|| self.default_value_as_string(query));
338        let explicit_before = self.explicit_value_for_scope(&scope, query);
339        let default_entry = self.default_entry_for_scope(query, scope.clone());
340        let node = self.node_for_exact_scope_mut(&scope);
341        if only_if_unset && node.contains(query.canonical_name(), query.index()) {
342            return Err(RmuxError::InvalidSetOption(format!(
343                "{} is already set",
344                query.canonical_name()
345            )));
346        }
347
348        if query.is_array()
349            && mode == SetOptionMode::Append
350            && query.index().is_none()
351            && value.unwrap_or_default().is_empty()
352        {
353            return Ok(build_mutation_outcome(query, scope));
354        }
355
356        if query.is_array() {
357            let entry = node
358                .entries
359                .entry(query.canonical_name().to_owned())
360                .or_insert_with(|| {
361                    if is_global_scope(&scope) {
362                        default_entry.unwrap_or_else(|| {
363                            OptionEntry::new_empty_array(
364                                query.canonical_name(),
365                                query.known_option(),
366                                scope.clone(),
367                                query.value_type(),
368                            )
369                        })
370                    } else {
371                        OptionEntry::new_empty_array(
372                            query.canonical_name(),
373                            query.known_option(),
374                            scope.clone(),
375                            query.value_type(),
376                        )
377                    }
378                });
379            apply_array_mutation(
380                entry,
381                query,
382                value.unwrap_or_default(),
383                mode,
384                explicit_before.as_deref(),
385            )?;
386        } else {
387            let current = match (query.value_type(), mode) {
388                (OptionValueType::String, SetOptionMode::Append) => {
389                    if explicit_before.is_some() || is_global_scope(&scope) {
390                        explicit_before.clone().or_else(|| effective_before.clone())
391                    } else {
392                        None
393                    }
394                }
395                (OptionValueType::String, SetOptionMode::Replace) => None,
396                _ => effective_before.clone(),
397            };
398            let normalized = normalize_scalar_value(query, value, current.as_deref())?;
399            node.entries.insert(
400                query.canonical_name().to_owned(),
401                OptionEntry::new_scalar(query, scope.clone(), normalized),
402            );
403        }
404
405        Ok(build_mutation_outcome(query, scope))
406    }
407
408    fn unset_query(
409        &mut self,
410        scope: OptionScopeSelector,
411        query: &OptionQuery,
412        only_if_unset: bool,
413    ) -> Result<OptionMutationOutcome, RmuxError> {
414        let default_entry = self.default_entry_for_scope(query, scope.clone());
415        let node = self.node_for_exact_scope_mut(&scope);
416        if only_if_unset && node.contains(query.canonical_name(), query.index()) {
417            return Err(RmuxError::InvalidSetOption(format!(
418                "{} is already set",
419                query.canonical_name()
420            )));
421        }
422
423        if query.is_array() && query.index().is_some() {
424            let remove_node = if let Some(entry) = node.entries.get_mut(query.canonical_name()) {
425                entry.remove_array_index(query.index().unwrap(), query.separator());
426                entry.is_empty()
427            } else {
428                false
429            };
430            if remove_node {
431                node.entries.remove(query.canonical_name());
432            }
433        } else if is_global_scope(&scope) {
434            if let Some(default_entry) = default_entry {
435                node.entries
436                    .insert(query.canonical_name().to_owned(), default_entry);
437            } else {
438                node.entries.remove(query.canonical_name());
439            }
440        } else {
441            node.entries.remove(query.canonical_name());
442        }
443
444        Ok(build_mutation_outcome(query, scope))
445    }
446
447    fn unset_window_pane_overrides(&mut self, scope: &OptionScopeSelector, name: &str) {
448        let OptionScopeSelector::Window(target) = scope else {
449            return;
450        };
451        self.panes.retain(|pane_target, node| {
452            let matches_window = pane_target.session_name() == target.session_name()
453                && pane_target.window_index() == target.window_index();
454            if matches_window {
455                node.entries.remove(name);
456            }
457            !node.is_empty()
458        });
459    }
460}
461
462fn remapped_window_target(
463    target: &WindowTarget,
464    session_name: &SessionName,
465    index_map: &BTreeMap<u32, u32>,
466) -> WindowTarget {
467    if target.session_name() != session_name {
468        return target.clone();
469    }
470    index_map.get(&target.window_index()).copied().map_or_else(
471        || target.clone(),
472        |window_index| WindowTarget::with_window(session_name.clone(), window_index),
473    )
474}
475
476fn remapped_pane_target(
477    target: &PaneTarget,
478    session_name: &SessionName,
479    index_map: &BTreeMap<u32, u32>,
480) -> PaneTarget {
481    if target.session_name() != session_name {
482        return target.clone();
483    }
484    index_map.get(&target.window_index()).copied().map_or_else(
485        || target.clone(),
486        |window_index| {
487            PaneTarget::with_window(session_name.clone(), window_index, target.pane_index())
488        },
489    )
490}
491
492#[cfg(test)]
493#[path = "options/tests.rs"]
494mod tests;