Skip to main content

rmux_core/
environment.rs

1use std::collections::HashMap;
2
3use rmux_proto::{RmuxError, ScopeSelector, SessionName};
4
5/// tmux-compatible hidden environment entry flag.
6pub const ENVIRON_HIDDEN: u8 = 0x1;
7
8/// Renderable `show-environment` entry with tmux-compatible flags and tombstones.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ShowEnvironmentEntry {
11    /// Environment variable name.
12    pub name: String,
13    /// Stored value, or `None` for a cleared tombstone.
14    pub value: Option<String>,
15    /// tmux-compatible entry flags.
16    pub flags: u8,
17}
18
19impl ShowEnvironmentEntry {
20    /// Returns whether the entry is hidden from normal `show-environment` output.
21    #[must_use]
22    pub const fn is_hidden(&self) -> bool {
23        self.flags & ENVIRON_HIDDEN != 0
24    }
25
26    /// Returns whether the entry is a cleared tombstone.
27    #[must_use]
28    pub const fn is_cleared(&self) -> bool {
29        self.value.is_none()
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34struct EnvironmentEntry {
35    value: Option<String>,
36    flags: u8,
37}
38
39impl EnvironmentEntry {
40    fn new(value: String, flags: u8) -> Self {
41        Self {
42            value: Some(value),
43            flags,
44        }
45    }
46
47    fn clear(&mut self) {
48        self.value = None;
49    }
50
51    const fn flags(&self) -> u8 {
52        self.flags
53    }
54
55    fn value(&self) -> Option<&str> {
56        self.value.as_deref()
57    }
58
59    const fn is_hidden(&self) -> bool {
60        self.flags & ENVIRON_HIDDEN != 0
61    }
62
63    const fn is_cleared(&self) -> bool {
64        self.value.is_none()
65    }
66}
67
68/// In-memory storage for global and session-local environment values.
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct EnvironmentStore {
71    global: HashMap<String, EnvironmentEntry>,
72    sessions: HashMap<SessionName, HashMap<String, EnvironmentEntry>>,
73}
74
75impl EnvironmentStore {
76    /// Creates an empty environment store with no implicit defaults.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Returns whether neither global nor session-local values are present.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.global.is_empty() && self.sessions.is_empty()
86    }
87
88    /// Stores the given visible value in the selected scope.
89    pub fn set(&mut self, scope: ScopeSelector, name: String, value: String) {
90        self.set_with_flags(scope, name, value, 0);
91    }
92
93    /// Stores the given value and flag word in the selected scope.
94    pub fn set_with_flags(&mut self, scope: ScopeSelector, name: String, value: String, flags: u8) {
95        self.scope_entries_mut(scope)
96            .insert(name, EnvironmentEntry::new(value, flags));
97    }
98
99    /// Clears the selected variable, leaving a tombstone entry behind.
100    pub fn clear(&mut self, scope: ScopeSelector, name: String) {
101        let entries = self.scope_entries_mut(scope);
102        if let Some(entry) = entries.get_mut(&name) {
103            entry.clear();
104        } else {
105            entries.insert(name, EnvironmentEntry::default());
106        }
107    }
108
109    /// Removes the selected variable entirely.
110    pub fn unset(&mut self, scope: ScopeSelector, name: &str) -> bool {
111        self.scope_entries_mut(scope).remove(name).is_some()
112    }
113
114    /// Returns whether the exact entry exists in the selected scope.
115    #[must_use]
116    pub fn contains_entry(&self, scope: &ScopeSelector, name: &str) -> bool {
117        self.scope_entries(scope)
118            .is_some_and(|entries| entries.contains_key(name))
119    }
120
121    /// Returns the exact global value for the given variable, when present and not cleared.
122    #[must_use]
123    pub fn global_value(&self, name: &str) -> Option<&str> {
124        self.global.get(name).and_then(EnvironmentEntry::value)
125    }
126
127    /// Returns all exact global environment entries in unspecified order.
128    pub fn global_entries(&self) -> impl Iterator<Item = (&str, &str)> {
129        self.global
130            .iter()
131            .filter_map(|(name, entry)| entry.value().map(|value| (name.as_str(), value)))
132    }
133
134    /// Returns the exact session-local value for the given variable, when present and not cleared.
135    #[must_use]
136    pub fn session_value(&self, session_name: &SessionName, name: &str) -> Option<&str> {
137        self.sessions
138            .get(session_name)
139            .and_then(|values| values.get(name))
140            .and_then(EnvironmentEntry::value)
141    }
142
143    /// Resolves a single variable using session-local then global lookup.
144    #[must_use]
145    pub fn resolve(&self, session_name: Option<&SessionName>, name: &str) -> Option<&str> {
146        if let Some(session_name) = session_name {
147            if let Some(entry) = self
148                .sessions
149                .get(session_name)
150                .and_then(|values| values.get(name))
151            {
152                return entry.value();
153            }
154        }
155
156        self.global.get(name).and_then(EnvironmentEntry::value)
157    }
158
159    /// Returns the visible explicit environment snapshot that future panes should inherit.
160    #[must_use]
161    pub fn resolved(&self, session_name: &SessionName) -> HashMap<String, String> {
162        let mut values = HashMap::new();
163        for (name, entry) in &self.global {
164            apply_entry_to_child_environment(&mut values, name, entry);
165        }
166        if let Some(session_values) = self.sessions.get(session_name) {
167            for (name, entry) in session_values {
168                apply_entry_to_child_environment(&mut values, name, entry);
169            }
170        }
171        values
172    }
173
174    /// Applies the selected scope chain to a process environment map.
175    pub fn apply_to_process_environment(
176        &self,
177        session_name: Option<&SessionName>,
178        values: &mut HashMap<String, String>,
179    ) {
180        for (name, entry) in &self.global {
181            apply_entry_to_child_environment(values, name, entry);
182        }
183
184        if let Some(session_name) = session_name {
185            if let Some(session_values) = self.sessions.get(session_name) {
186                for (name, entry) in session_values {
187                    apply_entry_to_child_environment(values, name, entry);
188                }
189            }
190        }
191    }
192
193    /// Merges client variables into a session environment using tmux `update-environment`.
194    pub fn update(
195        &mut self,
196        session_name: &SessionName,
197        patterns: &[String],
198        source: &HashMap<String, String>,
199    ) {
200        for pattern in patterns {
201            let mut found = false;
202            for (name, value) in source {
203                if crate::fnmatch(pattern, name) {
204                    self.set(
205                        ScopeSelector::Session(session_name.clone()),
206                        name.clone(),
207                        value.clone(),
208                    );
209                    found = true;
210                }
211            }
212            if !found {
213                self.clear(
214                    ScopeSelector::Session(session_name.clone()),
215                    pattern.clone(),
216                );
217            }
218        }
219    }
220
221    /// Returns sorted `show-environment` entries for the selected global or session scope.
222    pub fn show_environment_entries(
223        &self,
224        scope: &ScopeSelector,
225        hidden_only: bool,
226        name: Option<&str>,
227    ) -> Result<Vec<ShowEnvironmentEntry>, RmuxError> {
228        let exact_entries = match scope {
229            ScopeSelector::Global => &self.global,
230            ScopeSelector::Session(session_name) => {
231                if let Some(entries) = self.sessions.get(session_name) {
232                    entries
233                } else {
234                    empty_environment_entries()
235                }
236            }
237            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {
238                return Err(RmuxError::Server(
239                    "show-environment only supports global or session scope".to_owned(),
240                ));
241            }
242        };
243
244        if let Some(name) = name {
245            let Some(entry) = exact_entries.get(name) else {
246                return Err(RmuxError::Server(format!("unknown variable: {name}")));
247            };
248            if hidden_only && !entry.is_hidden() {
249                return Ok(Vec::new());
250            }
251            if !hidden_only && entry.is_hidden() {
252                return Ok(Vec::new());
253            }
254            return Ok(vec![ShowEnvironmentEntry {
255                name: name.to_owned(),
256                value: entry.value.clone(),
257                flags: entry.flags(),
258            }]);
259        }
260
261        let mut values = exact_entries
262            .iter()
263            .filter(|(_, entry)| hidden_only == entry.is_hidden())
264            .map(|(name, entry)| ShowEnvironmentEntry {
265                name: name.clone(),
266                value: entry.value.clone(),
267                flags: entry.flags(),
268            })
269            .collect::<Vec<_>>();
270        values.sort_by(|left, right| left.name.cmp(&right.name));
271        Ok(values)
272    }
273
274    /// Removes all session-local values for the given session.
275    pub fn remove_session(
276        &mut self,
277        session_name: &SessionName,
278    ) -> Option<HashMap<String, String>> {
279        self.sessions.remove(session_name).map(|entries| {
280            entries
281                .into_iter()
282                .filter_map(|(name, entry)| entry.value.map(|value| (name, value)))
283                .collect()
284        })
285    }
286
287    /// Rekeys all session-local values from one validated session name to another.
288    pub fn rename_session(
289        &mut self,
290        session_name: &SessionName,
291        new_name: SessionName,
292    ) -> Result<(), RmuxError> {
293        if self.sessions.contains_key(&new_name) {
294            return Err(RmuxError::Server(format!(
295                "environment already exists for session {new_name}"
296            )));
297        }
298
299        let mut sessions = std::mem::take(&mut self.sessions);
300        if let Some(values) = sessions.remove(session_name) {
301            let replaced = sessions.insert(new_name, values);
302            debug_assert!(replaced.is_none());
303        }
304        self.sessions = sessions;
305        Ok(())
306    }
307
308    fn scope_entries_mut(
309        &mut self,
310        scope: ScopeSelector,
311    ) -> &mut HashMap<String, EnvironmentEntry> {
312        match scope {
313            ScopeSelector::Global => &mut self.global,
314            ScopeSelector::Session(session_name) => self.sessions.entry(session_name).or_default(),
315            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {
316                unreachable!("environment mutations are validated before storage")
317            }
318        }
319    }
320
321    fn scope_entries(&self, scope: &ScopeSelector) -> Option<&HashMap<String, EnvironmentEntry>> {
322        match scope {
323            ScopeSelector::Global => Some(&self.global),
324            ScopeSelector::Session(session_name) => self.sessions.get(session_name),
325            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => None,
326        }
327    }
328}
329
330fn apply_entry_to_child_environment(
331    values: &mut HashMap<String, String>,
332    name: &str,
333    entry: &EnvironmentEntry,
334) {
335    if entry.is_hidden() || entry.is_cleared() {
336        values.remove(name);
337    } else if let Some(value) = entry.value() {
338        values.insert(name.to_owned(), value.to_owned());
339    }
340}
341
342fn empty_environment_entries() -> &'static HashMap<String, EnvironmentEntry> {
343    static EMPTY: std::sync::OnceLock<HashMap<String, EnvironmentEntry>> =
344        std::sync::OnceLock::new();
345    EMPTY.get_or_init(HashMap::new)
346}
347
348#[cfg(test)]
349#[path = "environment/tests.rs"]
350mod tests;