Skip to main content

rmux_client/
nested.rs

1//! Nested-session detection via inherited multiplexer environment.
2
3use std::error::Error as StdError;
4use std::fmt;
5
6/// The client context inferred from inherited multiplexer environment.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ClientContext {
9    /// No multiplexer variable is set - the client is outside any multiplexer.
10    Outside,
11    /// A multiplexer variable is set - the client is inside a session.
12    Nested,
13}
14
15/// The inherited multiplexer environment that made the client nested.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ClientContextParent {
18    /// No multiplexer environment was inherited.
19    None,
20    /// The client is inside an RMUX session.
21    Rmux,
22    /// The client is inside another tmux-compatible multiplexer session.
23    Tmux,
24}
25
26impl ClientContext {
27    /// Returns `true` when the client is inside a nested multiplexer session.
28    #[must_use]
29    pub const fn is_nested(self) -> bool {
30        matches!(self, Self::Nested)
31    }
32}
33
34/// Error returned when a command requires a nested client context.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct NestedContextError;
37
38impl fmt::Display for NestedContextError {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str("switch-client requires a nested client context")
41    }
42}
43
44impl StdError for NestedContextError {}
45
46/// Detects the client context by inspecting inherited multiplexer environment.
47///
48/// A non-empty value indicates a nested context. Absent or empty values
49/// indicate the client is outside any multiplexer session.
50#[must_use]
51pub fn detect_context() -> ClientContext {
52    detect_context_from_env(
53        std::env::var_os("RMUX").as_deref(),
54        std::env::var_os("TMUX").as_deref(),
55    )
56}
57
58/// Detects which inherited multiplexer environment, if any, is present.
59#[must_use]
60pub fn detect_parent() -> ClientContextParent {
61    detect_parent_from_env(
62        std::env::var_os("RMUX").as_deref(),
63        std::env::var_os("TMUX").as_deref(),
64    )
65}
66
67/// Returns an error when the supplied context is not nested.
68pub fn ensure_nested_context(context: ClientContext) -> Result<(), NestedContextError> {
69    if context.is_nested() {
70        Ok(())
71    } else {
72        Err(NestedContextError)
73    }
74}
75
76/// Detects the current client context and validates that it is nested.
77pub fn require_nested_context() -> Result<(), NestedContextError> {
78    ensure_nested_context(detect_context())
79}
80
81/// Pure detection logic that does not access the environment directly.
82///
83/// Exposed for deterministic unit testing.
84fn detect_context_from_env(
85    rmux_value: Option<&std::ffi::OsStr>,
86    tmux_value: Option<&std::ffi::OsStr>,
87) -> ClientContext {
88    detect_parent_from_env(rmux_value, tmux_value).context()
89}
90
91fn detect_parent_from_env(
92    rmux_value: Option<&std::ffi::OsStr>,
93    tmux_value: Option<&std::ffi::OsStr>,
94) -> ClientContextParent {
95    if rmux_value.is_some_and(|value| !value.is_empty()) {
96        return ClientContextParent::Rmux;
97    }
98    if tmux_value.is_some_and(|value| !value.is_empty()) {
99        return ClientContextParent::Tmux;
100    }
101    ClientContextParent::None
102}
103
104impl ClientContextParent {
105    fn context(self) -> ClientContext {
106        match self {
107            Self::None => ClientContext::Outside,
108            Self::Rmux | Self::Tmux => ClientContext::Nested,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::{
116        detect_context_from_env, detect_parent_from_env, ensure_nested_context,
117        require_nested_context, ClientContext, ClientContextParent, NestedContextError,
118    };
119    use std::ffi::OsStr;
120    use std::sync::Mutex;
121
122    static RMUX_ENV_LOCK: Mutex<()> = Mutex::new(());
123
124    #[test]
125    fn absent_rmux_is_outside() {
126        assert_eq!(detect_context_from_env(None, None), ClientContext::Outside);
127        assert_eq!(
128            detect_parent_from_env(None, None),
129            ClientContextParent::None
130        );
131    }
132
133    #[test]
134    fn empty_rmux_is_outside() {
135        assert_eq!(
136            detect_context_from_env(Some(OsStr::new("")), Some(OsStr::new(""))),
137            ClientContext::Outside
138        );
139        assert_eq!(
140            detect_parent_from_env(Some(OsStr::new("")), Some(OsStr::new(""))),
141            ClientContextParent::None
142        );
143    }
144
145    #[test]
146    fn nonempty_rmux_is_nested() {
147        assert_eq!(
148            detect_context_from_env(Some(OsStr::new("/tmp/rmux-1000/default,12345,0")), None),
149            ClientContext::Nested
150        );
151        assert_eq!(
152            detect_parent_from_env(Some(OsStr::new("/tmp/rmux-1000/default,12345,0")), None),
153            ClientContextParent::Rmux
154        );
155    }
156
157    #[test]
158    fn nonempty_tmux_is_nested() {
159        assert_eq!(
160            detect_context_from_env(None, Some(OsStr::new("/tmp/rmux-1000/default,12345,0"))),
161            ClientContext::Nested
162        );
163        assert_eq!(
164            detect_parent_from_env(None, Some(OsStr::new("/tmp/rmux-1000/default,12345,0"))),
165            ClientContextParent::Tmux
166        );
167    }
168
169    #[test]
170    fn any_nonempty_value_is_nested() {
171        assert_eq!(
172            detect_context_from_env(Some(OsStr::new("x")), None),
173            ClientContext::Nested
174        );
175    }
176
177    #[test]
178    fn rmux_parent_takes_precedence_over_tmux_parent() {
179        assert_eq!(
180            detect_parent_from_env(Some(OsStr::new("rmux")), Some(OsStr::new("tmux"))),
181            ClientContextParent::Rmux
182        );
183    }
184
185    #[test]
186    fn is_nested_accessor() {
187        assert!(ClientContext::Nested.is_nested());
188        assert!(!ClientContext::Outside.is_nested());
189    }
190
191    #[test]
192    fn ensure_nested_context_rejects_outside_contexts() {
193        assert_eq!(
194            ensure_nested_context(ClientContext::Outside),
195            Err(NestedContextError)
196        );
197        assert_eq!(ensure_nested_context(ClientContext::Nested), Ok(()));
198    }
199
200    #[test]
201    fn require_nested_context_reads_env() {
202        let _guard = RMUX_ENV_LOCK.lock().expect("rmux env lock");
203        let original = std::env::var_os("RMUX");
204        let original_tmux = std::env::var_os("TMUX");
205
206        std::env::remove_var("RMUX");
207        std::env::remove_var("TMUX");
208        assert_eq!(super::detect_context(), ClientContext::Outside);
209        assert_eq!(require_nested_context(), Err(NestedContextError));
210
211        std::env::set_var("RMUX", "/tmp/rmux-1000/default,1,0");
212        assert_eq!(super::detect_context(), ClientContext::Nested);
213        assert_eq!(require_nested_context(), Ok(()));
214
215        std::env::remove_var("RMUX");
216        std::env::set_var("TMUX", "/tmp/rmux-1000/default,1,0");
217        assert_eq!(super::detect_context(), ClientContext::Nested);
218        assert_eq!(require_nested_context(), Ok(()));
219
220        match original {
221            Some(value) => std::env::set_var("RMUX", value),
222            None => std::env::remove_var("RMUX"),
223        }
224        match original_tmux {
225            Some(value) => std::env::set_var("TMUX", value),
226            None => std::env::remove_var("TMUX"),
227        }
228    }
229}