Skip to main content

rmux_proto/
identity.rs

1//! Canonical identity newtypes shared across the RMUX workspace.
2//!
3//! `rmux-proto` is the single public home for the identity vocabulary
4//! (`SessionName`, `SessionId`, `WindowId`, `PaneId`). Other crates,
5//! including `rmux-core`, `rmux-server`, and `rmux-sdk`, must re-export
6//! these types rather than declaring their own. Allocation, lookup, and
7//! resolution remain in `rmux-core::session`; the types defined here
8//! describe identity values, not the policy that issues them.
9
10use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize};
14
15use crate::RmuxError;
16
17/// A validated RMUX session name.
18///
19/// Empty strings are rejected. `:` and `.` characters are rewritten to `_`
20/// to keep names safe for use inside exact target syntax (`session`,
21/// `session:window`, `session:window.pane`). Non-printable bytes are
22/// rendered using tmux's `vis`-style escape sequences so display output is
23/// always single-line and non-controlling.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
25#[serde(transparent)]
26pub struct SessionName(String);
27
28impl SessionName {
29    /// Validates and stores a session name using tmux-compatible rewriting.
30    pub fn new(value: impl Into<String>) -> Result<Self, RmuxError> {
31        let value = value.into();
32
33        if value.is_empty() {
34            return Err(RmuxError::EmptySessionName);
35        }
36
37        Ok(Self(sanitize_session_name(value.as_bytes())))
38    }
39
40    /// Returns the sanitized validated session name.
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45
46    /// Consumes the wrapper and returns the sanitized string.
47    #[must_use]
48    pub fn into_inner(self) -> String {
49        self.0
50    }
51}
52
53fn sanitize_session_name(input: &[u8]) -> String {
54    let mut sanitized = String::with_capacity(input.len());
55    for &byte in input {
56        let rewritten = match byte {
57            b':' | b'.' => b'_',
58            other => other,
59        };
60        push_session_name_byte(rewritten, &mut sanitized);
61    }
62    sanitized
63}
64
65fn push_session_name_byte(byte: u8, output: &mut String) {
66    if (0x20..=0x7e).contains(&byte) && byte != b'\\' {
67        output.push(char::from(byte));
68        return;
69    }
70
71    match byte {
72        b'\0' => output.push_str("\\000"),
73        b'\x07' => output.push_str("\\a"),
74        b'\x08' => output.push_str("\\b"),
75        b'\t' => output.push_str("\\t"),
76        b'\n' => output.push_str("\\n"),
77        b'\x0b' => output.push_str("\\v"),
78        b'\x0c' => output.push_str("\\f"),
79        b'\r' => output.push_str("\\r"),
80        b'\\' => output.push_str("\\\\"),
81        _ => {
82            output.push('\\');
83            output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
84            output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
85            output.push(char::from(b'0' + (byte & 0x7)));
86        }
87    }
88}
89
90impl AsRef<str> for SessionName {
91    fn as_ref(&self) -> &str {
92        self.as_str()
93    }
94}
95
96impl fmt::Display for SessionName {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter.write_str(self.as_str())
99    }
100}
101
102impl FromStr for SessionName {
103    type Err = RmuxError;
104
105    fn from_str(value: &str) -> Result<Self, Self::Err> {
106        Self::new(value)
107    }
108}
109
110impl TryFrom<&str> for SessionName {
111    type Error = RmuxError;
112
113    fn try_from(value: &str) -> Result<Self, Self::Error> {
114        Self::new(value)
115    }
116}
117
118impl TryFrom<String> for SessionName {
119    type Error = RmuxError;
120
121    fn try_from(value: String) -> Result<Self, Self::Error> {
122        Self::new(value)
123    }
124}
125
126impl<'de> Deserialize<'de> for SessionName {
127    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128    where
129        D: Deserializer<'de>,
130    {
131        let value = String::deserialize(deserializer)?;
132        Self::new(value).map_err(serde::de::Error::custom)
133    }
134}
135
136/// Stable per-server session identity (`$N`).
137///
138/// `SessionId` is the numeric identity rendered as `$N` by tmux-compatible
139/// formats. Allocation lives in `rmux-core::session::SessionStore`; the
140/// type defined here is the storable, transferable identity value.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
142#[serde(transparent)]
143pub struct SessionId(u32);
144
145impl SessionId {
146    /// Wraps a raw stable session identity.
147    #[must_use]
148    pub const fn new(value: u32) -> Self {
149        Self(value)
150    }
151
152    /// Returns the raw stable session identity.
153    #[must_use]
154    pub const fn as_u32(self) -> u32 {
155        self.0
156    }
157}
158
159impl fmt::Display for SessionId {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(formatter, "${}", self.0)
162    }
163}
164
165impl From<SessionId> for u32 {
166    fn from(value: SessionId) -> Self {
167        value.0
168    }
169}
170
171impl From<u32> for SessionId {
172    fn from(value: u32) -> Self {
173        Self(value)
174    }
175}
176
177/// Stable per-server window identity (`@N`).
178///
179/// `WindowId` is the numeric identity rendered as `@N` by tmux-compatible
180/// formats. Allocation lives in `rmux-core::session`; the type defined
181/// here is the storable, transferable identity value.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
183#[serde(transparent)]
184pub struct WindowId(u32);
185
186impl WindowId {
187    /// Wraps a raw stable window identity.
188    #[must_use]
189    pub const fn new(value: u32) -> Self {
190        Self(value)
191    }
192
193    /// Returns the raw stable window identity.
194    #[must_use]
195    pub const fn as_u32(self) -> u32 {
196        self.0
197    }
198}
199
200impl fmt::Display for WindowId {
201    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(formatter, "@{}", self.0)
203    }
204}
205
206impl From<WindowId> for u32 {
207    fn from(value: WindowId) -> Self {
208        value.0
209    }
210}
211
212impl From<u32> for WindowId {
213    fn from(value: u32) -> Self {
214        Self(value)
215    }
216}
217
218/// Stable per-server pane identity (`%N`).
219///
220/// `PaneId` is the numeric identity rendered as `%N` by tmux-compatible
221/// formats. Allocation lives in `rmux-core::session::SessionStore`; the
222/// type defined here is the storable, transferable identity value.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
224#[serde(transparent)]
225pub struct PaneId(u32);
226
227impl PaneId {
228    /// Wraps a raw stable pane identity.
229    #[must_use]
230    pub const fn new(value: u32) -> Self {
231        Self(value)
232    }
233
234    /// Returns the raw stable pane identity.
235    #[must_use]
236    pub const fn as_u32(self) -> u32 {
237        self.0
238    }
239}
240
241impl fmt::Display for PaneId {
242    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243        write!(formatter, "%{}", self.0)
244    }
245}
246
247impl From<PaneId> for u32 {
248    fn from(value: PaneId) -> Self {
249        value.0
250    }
251}
252
253impl From<u32> for PaneId {
254    fn from(value: u32) -> Self {
255        Self(value)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::{PaneId, SessionId, SessionName, WindowId};
262    use crate::RmuxError;
263
264    #[test]
265    fn session_name_rejects_empty_values() {
266        assert_eq!(SessionName::new(""), Err(RmuxError::EmptySessionName));
267    }
268
269    #[test]
270    fn session_name_rewrites_colon_and_dot() {
271        assert_eq!(
272            SessionName::new("alpha:beta.gamma")
273                .expect("rewritten")
274                .as_str(),
275            "alpha_beta_gamma"
276        );
277    }
278
279    #[test]
280    fn session_name_round_trips_through_serde() {
281        let payload = bincode::serialize("alpha.beta").expect("string encodes");
282        assert_eq!(
283            bincode::deserialize::<SessionName>(&payload).expect("rewritten on the wire"),
284            SessionName::new("alpha_beta").expect("valid")
285        );
286    }
287
288    #[test]
289    fn session_name_serde_rejects_empty_payloads_truthfully() {
290        let payload = bincode::serialize("").expect("empty string encodes");
291        assert!(
292            bincode::deserialize::<SessionName>(&payload).is_err(),
293            "empty session names must fail deserialization rather than silently \
294             producing an empty inner value"
295        );
296    }
297
298    #[test]
299    fn session_name_serialize_round_trips_after_rewriting() {
300        let original = SessionName::new("alpha.beta").expect("rewrites dots");
301        let bytes = bincode::serialize(&original).expect("session name encodes");
302        let restored: SessionName =
303            bincode::deserialize(&bytes).expect("session name decodes idempotently");
304        assert_eq!(restored, original);
305        assert_eq!(restored.as_str(), "alpha_beta");
306    }
307
308    #[test]
309    fn session_name_from_str_and_try_from_match_constructor() {
310        let from_str: SessionName = "alpha:beta".parse().expect("FromStr rewrites");
311        let try_from_ref: SessionName =
312            SessionName::try_from("alpha:beta").expect("TryFrom<&str> rewrites");
313        let try_from_owned: SessionName =
314            SessionName::try_from(String::from("alpha:beta")).expect("TryFrom<String> rewrites");
315        assert_eq!(from_str, try_from_ref);
316        assert_eq!(from_str, try_from_owned);
317        assert_eq!(from_str.as_str(), "alpha_beta");
318    }
319
320    #[test]
321    fn session_name_into_inner_returns_sanitized_string() {
322        let owned = SessionName::new("alpha:beta")
323            .expect("rewrites colons")
324            .into_inner();
325        assert_eq!(owned, "alpha_beta");
326    }
327
328    #[test]
329    fn session_id_displays_with_dollar_prefix() {
330        assert_eq!(SessionId::new(7).to_string(), "$7");
331        assert_eq!(SessionId::new(7).as_u32(), 7);
332    }
333
334    #[test]
335    fn window_id_displays_with_at_prefix() {
336        assert_eq!(WindowId::new(9).to_string(), "@9");
337        assert_eq!(WindowId::new(9).as_u32(), 9);
338    }
339
340    #[test]
341    fn window_id_zero_and_max_render_as_at_prefixed_decimal() {
342        assert_eq!(WindowId::new(0).to_string(), "@0");
343        assert_eq!(
344            WindowId::new(u32::MAX).to_string(),
345            format!("@{}", u32::MAX)
346        );
347    }
348
349    #[test]
350    fn pane_id_displays_with_percent_prefix() {
351        assert_eq!(PaneId::new(3).to_string(), "%3");
352        assert_eq!(PaneId::new(3).as_u32(), 3);
353    }
354
355    #[test]
356    fn pane_id_zero_and_max_render_as_percent_prefixed_decimal() {
357        assert_eq!(PaneId::new(0).to_string(), "%0");
358        assert_eq!(PaneId::new(u32::MAX).to_string(), format!("%{}", u32::MAX));
359    }
360
361    #[test]
362    fn session_id_zero_and_max_render_as_dollar_prefixed_decimal() {
363        assert_eq!(SessionId::new(0).to_string(), "$0");
364        assert_eq!(
365            SessionId::new(u32::MAX).to_string(),
366            format!("${}", u32::MAX)
367        );
368    }
369
370    #[test]
371    fn identity_newtypes_round_trip_through_u32_conversions() {
372        for value in [0_u32, 1, 17, u32::MAX] {
373            assert_eq!(u32::from(SessionId::from(value)), value);
374            assert_eq!(u32::from(WindowId::from(value)), value);
375            assert_eq!(u32::from(PaneId::from(value)), value);
376            assert_eq!(SessionId::from(value).as_u32(), value);
377            assert_eq!(WindowId::from(value).as_u32(), value);
378            assert_eq!(PaneId::from(value).as_u32(), value);
379        }
380    }
381
382    #[test]
383    fn identity_newtypes_are_serde_transparent() {
384        assert_eq!(
385            bincode::serialize(&PaneId::new(11)).expect("encodes"),
386            bincode::serialize(&11_u32).expect("encodes")
387        );
388        assert_eq!(
389            bincode::serialize(&WindowId::new(11)).expect("encodes"),
390            bincode::serialize(&11_u32).expect("encodes")
391        );
392        assert_eq!(
393            bincode::serialize(&SessionId::new(11)).expect("encodes"),
394            bincode::serialize(&11_u32).expect("encodes")
395        );
396    }
397
398    #[test]
399    fn identity_id_newtypes_decode_back_through_serde() {
400        for value in [0_u32, 7, 257, u32::MAX] {
401            let session_bytes =
402                bincode::serialize(&SessionId::new(value)).expect("session id encodes");
403            let window_bytes =
404                bincode::serialize(&WindowId::new(value)).expect("window id encodes");
405            let pane_bytes = bincode::serialize(&PaneId::new(value)).expect("pane id encodes");
406
407            assert_eq!(
408                bincode::deserialize::<SessionId>(&session_bytes).expect("session id decodes"),
409                SessionId::new(value),
410            );
411            assert_eq!(
412                bincode::deserialize::<WindowId>(&window_bytes).expect("window id decodes"),
413                WindowId::new(value),
414            );
415            assert_eq!(
416                bincode::deserialize::<PaneId>(&pane_bytes).expect("pane id decodes"),
417                PaneId::new(value),
418            );
419        }
420    }
421
422    #[test]
423    fn identity_id_newtypes_total_order_matches_inner_u32() {
424        let mut ids = [PaneId::new(3), PaneId::new(0), PaneId::new(1)];
425        ids.sort();
426        assert_eq!(ids, [PaneId::new(0), PaneId::new(1), PaneId::new(3)]);
427    }
428
429    #[test]
430    fn session_name_already_sanitized_round_trips_through_serde() {
431        let original = SessionName::new("alpha-beta_gamma").expect("printable name");
432        let bytes = bincode::serialize(&original).expect("session name encodes");
433        let restored: SessionName =
434            bincode::deserialize(&bytes).expect("session name decodes idempotently");
435        assert_eq!(restored, original);
436    }
437}