Skip to main content

rmux_core/
environment.rs

1use std::collections::{HashMap, HashSet};
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    /// Whether `value` already contains tmux-style display escapes for raw bytes.
18    pub value_is_display_escape: bool,
19}
20
21impl ShowEnvironmentEntry {
22    /// Returns whether the entry is hidden from normal `show-environment` output.
23    #[must_use]
24    pub const fn is_hidden(&self) -> bool {
25        self.flags & ENVIRON_HIDDEN != 0
26    }
27
28    /// Returns whether the entry is a cleared tombstone.
29    #[must_use]
30    pub const fn is_cleared(&self) -> bool {
31        self.value.is_none()
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
36struct EnvironmentEntry {
37    value: Option<String>,
38    display_value: Option<String>,
39    flags: u8,
40    implicit: bool,
41}
42
43impl EnvironmentEntry {
44    fn new(value: String, flags: u8) -> Self {
45        Self {
46            value: Some(value),
47            display_value: None,
48            flags,
49            implicit: false,
50        }
51    }
52
53    fn implicit(value: String) -> Self {
54        Self {
55            value: Some(value),
56            display_value: None,
57            flags: 0,
58            implicit: true,
59        }
60    }
61
62    fn implicit_display(value: String) -> Self {
63        Self {
64            value: None,
65            display_value: Some(value),
66            flags: 0,
67            implicit: true,
68        }
69    }
70
71    fn clear(&mut self) {
72        self.value = None;
73        self.display_value = None;
74        self.implicit = false;
75    }
76
77    const fn flags(&self) -> u8 {
78        self.flags
79    }
80
81    fn value(&self) -> Option<&str> {
82        self.value.as_deref()
83    }
84
85    fn show_value(&self) -> Option<&str> {
86        self.value.as_deref().or(self.display_value.as_deref())
87    }
88
89    const fn is_hidden(&self) -> bool {
90        self.flags & ENVIRON_HIDDEN != 0
91    }
92
93    const fn is_cleared(&self) -> bool {
94        self.value.is_none() && self.display_value.is_none()
95    }
96
97    const fn is_implicit(&self) -> bool {
98        self.implicit
99    }
100}
101
102/// In-memory storage for global and session-local environment values.
103#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub struct EnvironmentStore {
105    global: HashMap<String, EnvironmentEntry>,
106    sessions: HashMap<SessionName, HashMap<String, EnvironmentEntry>>,
107    global_unsets: HashSet<String>,
108    session_unsets: HashMap<SessionName, HashSet<String>>,
109}
110
111impl EnvironmentStore {
112    /// Creates an empty environment store with no implicit defaults.
113    #[must_use]
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Returns whether neither global nor session-local values are present.
119    #[must_use]
120    pub fn is_empty(&self) -> bool {
121        self.global.is_empty() && self.sessions.is_empty()
122    }
123
124    /// Stores the given visible value in the selected scope.
125    pub fn set(&mut self, scope: ScopeSelector, name: String, value: String) {
126        self.set_with_flags(scope, name, value, 0);
127    }
128
129    /// Stores the given value and flag word in the selected scope.
130    pub fn set_with_flags(&mut self, scope: ScopeSelector, name: String, value: String, flags: u8) {
131        self.forget_unset(&scope, &name);
132        insert_environment_entry(
133            self.scope_entries_mut(scope),
134            name,
135            EnvironmentEntry::new(value, flags),
136        );
137    }
138
139    /// Stores an implicit global value captured from the server environment.
140    pub fn set_implicit_global(&mut self, name: String, value: String) {
141        remove_name_from_set(&mut self.global_unsets, &name);
142        insert_environment_entry(&mut self.global, name, EnvironmentEntry::implicit(value));
143    }
144
145    /// Stores an implicit global value that is only renderable by `show-environment`.
146    pub fn set_implicit_global_display(&mut self, name: String, value: String) {
147        remove_name_from_set(&mut self.global_unsets, &name);
148        insert_environment_entry(
149            &mut self.global,
150            name,
151            EnvironmentEntry::implicit_display(value),
152        );
153    }
154
155    /// Clears the selected variable, leaving a tombstone entry behind.
156    pub fn clear(&mut self, scope: ScopeSelector, name: String) {
157        self.record_unset(&scope, name.clone());
158        let entries = self.scope_entries_mut(scope);
159        if let Some(entry) = environment_entry_mut(entries, &name) {
160            entry.clear();
161        } else {
162            insert_environment_entry(entries, name, EnvironmentEntry::default());
163        }
164    }
165
166    /// Removes the selected variable entirely.
167    pub fn unset(&mut self, scope: ScopeSelector, name: &str) -> bool {
168        match &scope {
169            ScopeSelector::Global => self.record_unset(&scope, name.to_owned()),
170            ScopeSelector::Session(_) => self.forget_unset(&scope, name),
171            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {}
172        }
173        remove_environment_entry(self.scope_entries_mut(scope), name).is_some()
174    }
175
176    /// Returns whether the exact entry exists in the selected scope.
177    #[must_use]
178    pub fn contains_entry(&self, scope: &ScopeSelector, name: &str) -> bool {
179        self.scope_entries(scope)
180            .is_some_and(|entries| environment_entry(entries, name).is_some())
181    }
182
183    /// Returns the exact global value for the given variable, when present and not cleared.
184    #[must_use]
185    pub fn global_value(&self, name: &str) -> Option<&str> {
186        environment_entry(&self.global, name).and_then(EnvironmentEntry::value)
187    }
188
189    /// Returns all exact global environment entries in unspecified order.
190    pub fn global_entries(&self) -> impl Iterator<Item = (&str, &str)> {
191        self.global
192            .iter()
193            .filter_map(|(name, entry)| entry.value().map(|value| (name.as_str(), value)))
194    }
195
196    /// Returns the exact session-local value for the given variable, when present and not cleared.
197    #[must_use]
198    pub fn session_value(&self, session_name: &SessionName, name: &str) -> Option<&str> {
199        self.sessions
200            .get(session_name)
201            .and_then(|values| environment_entry(values, name))
202            .and_then(EnvironmentEntry::value)
203    }
204
205    /// Resolves a single variable using session-local then global lookup.
206    #[must_use]
207    pub fn resolve(&self, session_name: Option<&SessionName>, name: &str) -> Option<&str> {
208        if let Some(session_name) = session_name {
209            if let Some(entry) = self
210                .sessions
211                .get(session_name)
212                .and_then(|values| environment_entry(values, name))
213            {
214                return entry.value();
215            }
216        }
217
218        environment_entry(&self.global, name).and_then(EnvironmentEntry::value)
219    }
220
221    /// Returns the visible explicit environment snapshot that future panes should inherit.
222    #[must_use]
223    pub fn resolved(&self, session_name: &SessionName) -> HashMap<String, String> {
224        let mut values = HashMap::new();
225        for (name, entry) in &self.global {
226            apply_entry_to_child_environment(&mut values, name, entry);
227        }
228        if let Some(session_values) = self.sessions.get(session_name) {
229            for (name, entry) in session_values {
230                apply_entry_to_child_environment(&mut values, name, entry);
231            }
232        }
233        values
234    }
235
236    /// Applies the selected scope chain to a process environment map.
237    pub fn apply_to_process_environment(
238        &self,
239        session_name: Option<&SessionName>,
240        values: &mut HashMap<String, String>,
241    ) {
242        self.apply_to_process_environment_inner(session_name, values, true);
243    }
244
245    /// Applies explicit values only, skipping implicit globals captured from the server process.
246    pub fn apply_to_process_environment_without_implicit_globals(
247        &self,
248        session_name: Option<&SessionName>,
249        values: &mut HashMap<String, String>,
250    ) {
251        self.apply_to_process_environment_inner(session_name, values, false);
252    }
253
254    /// Returns names that must be removed from raw process environments.
255    #[must_use]
256    pub fn suppressed_process_environment_names(
257        &self,
258        session_name: Option<&SessionName>,
259        include_implicit_globals: bool,
260    ) -> HashSet<String> {
261        let mut names = self.global_unsets.clone();
262        collect_suppressed_entry_names(&self.global, include_implicit_globals, &mut names);
263
264        if let Some(session_name) = session_name {
265            if let Some(session_unsets) = self.session_unsets.get(session_name) {
266                names.extend(session_unsets.iter().cloned());
267            }
268            if let Some(entries) = self.sessions.get(session_name) {
269                collect_suppressed_entry_names(entries, true, &mut names);
270            }
271        }
272
273        names
274    }
275
276    fn apply_to_process_environment_inner(
277        &self,
278        session_name: Option<&SessionName>,
279        values: &mut HashMap<String, String>,
280        include_implicit_globals: bool,
281    ) {
282        for (name, entry) in &self.global {
283            if !include_implicit_globals && entry.is_implicit() {
284                continue;
285            }
286            apply_entry_to_child_environment(values, name, entry);
287        }
288        remove_unset_names(values, &self.global_unsets);
289
290        if let Some(session_name) = session_name {
291            if let Some(session_values) = self.sessions.get(session_name) {
292                for (name, entry) in session_values {
293                    apply_entry_to_child_environment(values, name, entry);
294                }
295            }
296            if let Some(session_unsets) = self.session_unsets.get(session_name) {
297                remove_unset_names(values, session_unsets);
298            }
299        }
300    }
301
302    /// Merges client variables into a session environment using tmux `update-environment`.
303    pub fn update(
304        &mut self,
305        session_name: &SessionName,
306        patterns: &[String],
307        source: &HashMap<String, String>,
308    ) {
309        for pattern in patterns {
310            let mut found = false;
311            for (name, value) in source {
312                if crate::fnmatch(pattern, name) {
313                    self.set(
314                        ScopeSelector::Session(session_name.clone()),
315                        name.clone(),
316                        value.clone(),
317                    );
318                    found = true;
319                }
320            }
321            if !found {
322                self.clear(
323                    ScopeSelector::Session(session_name.clone()),
324                    pattern.clone(),
325                );
326            }
327        }
328    }
329
330    /// Returns sorted `show-environment` entries for the selected global or session scope.
331    pub fn show_environment_entries(
332        &self,
333        scope: &ScopeSelector,
334        hidden_only: bool,
335        name: Option<&str>,
336    ) -> Result<Vec<ShowEnvironmentEntry>, RmuxError> {
337        let exact_entries = match scope {
338            ScopeSelector::Global => &self.global,
339            ScopeSelector::Session(session_name) => {
340                if let Some(entries) = self.sessions.get(session_name) {
341                    entries
342                } else {
343                    empty_environment_entries()
344                }
345            }
346            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {
347                return Err(RmuxError::Server(
348                    "show-environment only supports global or session scope".to_owned(),
349                ));
350            }
351        };
352
353        if let Some(name) = name {
354            let Some((stored_name, entry)) = environment_entry_with_name(exact_entries, name)
355            else {
356                return Err(RmuxError::Server(format!("unknown variable: {name}")));
357            };
358            if hidden_only && !entry.is_hidden() {
359                return Ok(Vec::new());
360            }
361            if !hidden_only && entry.is_hidden() {
362                return Ok(Vec::new());
363            }
364            return Ok(vec![ShowEnvironmentEntry {
365                name: stored_name.to_owned(),
366                value: entry.show_value().map(str::to_owned),
367                flags: entry.flags(),
368                value_is_display_escape: entry.value.is_none() && entry.display_value.is_some(),
369            }]);
370        }
371
372        let mut values = exact_entries
373            .iter()
374            .filter(|(_, entry)| hidden_only == entry.is_hidden())
375            .map(|(name, entry)| ShowEnvironmentEntry {
376                name: name.clone(),
377                value: entry.show_value().map(str::to_owned),
378                flags: entry.flags(),
379                value_is_display_escape: entry.value.is_none() && entry.display_value.is_some(),
380            })
381            .collect::<Vec<_>>();
382        values.sort_by(|left, right| left.name.cmp(&right.name));
383        Ok(values)
384    }
385
386    /// Removes all session-local values for the given session.
387    pub fn remove_session(
388        &mut self,
389        session_name: &SessionName,
390    ) -> Option<HashMap<String, String>> {
391        self.session_unsets.remove(session_name);
392        self.sessions.remove(session_name).map(|entries| {
393            entries
394                .into_iter()
395                .filter_map(|(name, entry)| entry.value.map(|value| (name, value)))
396                .collect()
397        })
398    }
399
400    /// Rekeys all session-local values from one validated session name to another.
401    pub fn rename_session(
402        &mut self,
403        session_name: &SessionName,
404        new_name: SessionName,
405    ) -> Result<(), RmuxError> {
406        if self.sessions.contains_key(&new_name) {
407            return Err(RmuxError::Server(format!(
408                "environment already exists for session {new_name}"
409            )));
410        }
411
412        let mut sessions = std::mem::take(&mut self.sessions);
413        if let Some(values) = sessions.remove(session_name) {
414            let replaced = sessions.insert(new_name.clone(), values);
415            debug_assert!(replaced.is_none());
416        }
417        self.sessions = sessions;
418        let unsets = self.session_unsets.remove(session_name);
419        if let Some(unsets) = unsets {
420            let replaced = self.session_unsets.insert(new_name, unsets);
421            debug_assert!(replaced.is_none());
422        }
423        Ok(())
424    }
425
426    fn scope_entries_mut(
427        &mut self,
428        scope: ScopeSelector,
429    ) -> &mut HashMap<String, EnvironmentEntry> {
430        match scope {
431            ScopeSelector::Global => &mut self.global,
432            ScopeSelector::Session(session_name) => self.sessions.entry(session_name).or_default(),
433            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {
434                unreachable!("environment mutations are validated before storage")
435            }
436        }
437    }
438
439    fn scope_entries(&self, scope: &ScopeSelector) -> Option<&HashMap<String, EnvironmentEntry>> {
440        match scope {
441            ScopeSelector::Global => Some(&self.global),
442            ScopeSelector::Session(session_name) => self.sessions.get(session_name),
443            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => None,
444        }
445    }
446
447    fn record_unset(&mut self, scope: &ScopeSelector, name: String) {
448        match scope {
449            ScopeSelector::Global => {
450                insert_name_into_set(&mut self.global_unsets, name);
451            }
452            ScopeSelector::Session(session_name) => {
453                let unsets = self.session_unsets.entry(session_name.clone()).or_default();
454                insert_name_into_set(unsets, name);
455            }
456            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {}
457        }
458    }
459
460    fn forget_unset(&mut self, scope: &ScopeSelector, name: &str) {
461        match scope {
462            ScopeSelector::Global => {
463                remove_name_from_set(&mut self.global_unsets, name);
464            }
465            ScopeSelector::Session(session_name) => {
466                let remove_bucket = if let Some(unsets) = self.session_unsets.get_mut(session_name)
467                {
468                    remove_name_from_set(unsets, name);
469                    unsets.is_empty()
470                } else {
471                    false
472                };
473                if remove_bucket {
474                    self.session_unsets.remove(session_name);
475                }
476            }
477            ScopeSelector::Window(_) | ScopeSelector::Pane(_) => {}
478        }
479    }
480}
481
482fn apply_entry_to_child_environment(
483    values: &mut HashMap<String, String>,
484    name: &str,
485    entry: &EnvironmentEntry,
486) {
487    if entry.is_hidden() || entry.is_cleared() {
488        remove_environment_name(values, name);
489    } else if let Some(value) = entry.value() {
490        remove_environment_name(values, name);
491        values.insert(name.to_owned(), value.to_owned());
492    }
493}
494
495fn collect_suppressed_entry_names(
496    entries: &HashMap<String, EnvironmentEntry>,
497    include_implicit: bool,
498    names: &mut HashSet<String>,
499) {
500    for (name, entry) in entries {
501        if !include_implicit && entry.is_implicit() {
502            continue;
503        }
504        if entry.is_hidden() || entry.is_cleared() {
505            names.insert(name.clone());
506        }
507    }
508}
509
510fn remove_unset_names(values: &mut HashMap<String, String>, names: &HashSet<String>) {
511    for name in names {
512        remove_environment_name(values, name);
513    }
514}
515
516fn remove_environment_name(values: &mut HashMap<String, String>, name: &str) {
517    #[cfg(windows)]
518    if let Some(existing) = values
519        .keys()
520        .find(|key| key.eq_ignore_ascii_case(name))
521        .cloned()
522    {
523        values.remove(&existing);
524        return;
525    }
526
527    values.remove(name);
528}
529
530fn insert_environment_entry(
531    entries: &mut HashMap<String, EnvironmentEntry>,
532    name: String,
533    entry: EnvironmentEntry,
534) {
535    let _ = remove_environment_entry(entries, &name);
536    entries.insert(name, entry);
537}
538
539fn remove_environment_entry(
540    entries: &mut HashMap<String, EnvironmentEntry>,
541    name: &str,
542) -> Option<EnvironmentEntry> {
543    #[cfg(windows)]
544    if let Some(existing) = entries
545        .keys()
546        .find(|key| key.eq_ignore_ascii_case(name))
547        .cloned()
548    {
549        return entries.remove(&existing);
550    }
551
552    entries.remove(name)
553}
554
555fn environment_entry<'a>(
556    entries: &'a HashMap<String, EnvironmentEntry>,
557    name: &str,
558) -> Option<&'a EnvironmentEntry> {
559    environment_entry_with_name(entries, name).map(|(_, entry)| entry)
560}
561
562fn environment_entry_with_name<'a>(
563    entries: &'a HashMap<String, EnvironmentEntry>,
564    name: &str,
565) -> Option<(&'a str, &'a EnvironmentEntry)> {
566    #[cfg(windows)]
567    if let Some((existing, entry)) = entries
568        .iter()
569        .find(|(key, _)| key.eq_ignore_ascii_case(name))
570    {
571        return Some((existing.as_str(), entry));
572    }
573
574    entries
575        .get_key_value(name)
576        .map(|(key, entry)| (key.as_str(), entry))
577}
578
579fn environment_entry_mut<'a>(
580    entries: &'a mut HashMap<String, EnvironmentEntry>,
581    name: &str,
582) -> Option<&'a mut EnvironmentEntry> {
583    #[cfg(windows)]
584    if let Some(existing) = entries
585        .keys()
586        .find(|key| key.eq_ignore_ascii_case(name))
587        .cloned()
588    {
589        return entries.get_mut(&existing);
590    }
591
592    entries.get_mut(name)
593}
594
595fn insert_name_into_set(names: &mut HashSet<String>, name: String) {
596    remove_name_from_set(names, &name);
597    names.insert(name);
598}
599
600fn remove_name_from_set(names: &mut HashSet<String>, name: &str) -> bool {
601    #[cfg(windows)]
602    if let Some(existing) = names
603        .iter()
604        .find(|candidate| candidate.eq_ignore_ascii_case(name))
605        .cloned()
606    {
607        return names.remove(&existing);
608    }
609
610    names.remove(name)
611}
612
613fn empty_environment_entries() -> &'static HashMap<String, EnvironmentEntry> {
614    static EMPTY: std::sync::OnceLock<HashMap<String, EnvironmentEntry>> =
615        std::sync::OnceLock::new();
616    EMPTY.get_or_init(HashMap::new)
617}
618
619#[cfg(test)]
620#[path = "environment/tests.rs"]
621mod tests;