1use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize};
14
15use crate::RmuxError;
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
25#[serde(transparent)]
26pub struct SessionName(String);
27
28impl SessionName {
29 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)))
38 }
39
40 #[must_use]
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45
46 #[must_use]
48 pub fn into_inner(self) -> String {
49 self.0
50 }
51}
52
53fn sanitize_session_name(input: &str) -> String {
54 sanitize_session_name_with_backslash_policy(input, true)
55}
56
57fn sanitize_deserialized_session_name(input: &str) -> String {
58 sanitize_session_name_with_backslash_policy(input, false)
59}
60
61fn sanitize_session_name_with_backslash_policy(input: &str, escape_backslash: bool) -> String {
62 let mut sanitized = String::with_capacity(input.len());
63 for character in input.chars() {
64 let rewritten = match character {
65 ':' | '.' => '_',
66 other => other,
67 };
68 push_session_name_character(rewritten, escape_backslash, &mut sanitized);
69 }
70 sanitized
71}
72
73fn push_session_name_character(character: char, escape_backslash: bool, output: &mut String) {
74 match character {
75 '\0' => output.push_str("\\000"),
76 '\x07' => output.push_str("\\a"),
77 '\x08' => output.push_str("\\b"),
78 '\t' => output.push_str("\\t"),
79 '\n' => output.push_str("\\n"),
80 '\x0b' => output.push_str("\\v"),
81 '\x0c' => output.push_str("\\f"),
82 '\r' => output.push_str("\\r"),
83 '\\' if escape_backslash => output.push_str("\\\\"),
84 control if control.is_control() => {
85 let value = control as u32;
86 output.push('\\');
87 output.push(char::from(b'0' + ((value >> 6) & 0x7) as u8));
88 output.push(char::from(b'0' + ((value >> 3) & 0x7) as u8));
89 output.push(char::from(b'0' + (value & 0x7) as u8));
90 }
91 format_char if is_display_unsafe_format_char(format_char) => {
97 let mut buffer = [0_u8; 4];
98 for byte in format_char.encode_utf8(&mut buffer).bytes() {
99 output.push('\\');
100 output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
101 output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
102 output.push(char::from(b'0' + (byte & 0x7)));
103 }
104 }
105 _ => {
106 output.push(character);
107 }
108 }
109}
110
111fn is_display_unsafe_format_char(character: char) -> bool {
115 matches!(
116 character as u32,
117 0x00AD | 0x061C | 0x200B..=0x200F | 0x2028 | 0x2029 | 0x202A..=0x202E | 0x2066..=0x2069 | 0xFEFF )
126}
127
128impl AsRef<str> for SessionName {
129 fn as_ref(&self) -> &str {
130 self.as_str()
131 }
132}
133
134impl fmt::Display for SessionName {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter.write_str(self.as_str())
137 }
138}
139
140impl FromStr for SessionName {
141 type Err = RmuxError;
142
143 fn from_str(value: &str) -> Result<Self, Self::Err> {
144 Self::new(value)
145 }
146}
147
148impl TryFrom<&str> for SessionName {
149 type Error = RmuxError;
150
151 fn try_from(value: &str) -> Result<Self, Self::Error> {
152 Self::new(value)
153 }
154}
155
156impl TryFrom<String> for SessionName {
157 type Error = RmuxError;
158
159 fn try_from(value: String) -> Result<Self, Self::Error> {
160 Self::new(value)
161 }
162}
163
164impl<'de> Deserialize<'de> for SessionName {
165 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166 where
167 D: Deserializer<'de>,
168 {
169 let value = String::deserialize(deserializer)?;
170 if value.is_empty() {
171 return Err(serde::de::Error::custom(RmuxError::EmptySessionName));
172 }
173 Ok(Self(sanitize_deserialized_session_name(&value)))
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
183#[serde(transparent)]
184pub struct SessionId(u32);
185
186impl SessionId {
187 #[must_use]
189 pub const fn new(value: u32) -> Self {
190 Self(value)
191 }
192
193 #[must_use]
195 pub const fn as_u32(self) -> u32 {
196 self.0
197 }
198}
199
200impl fmt::Display for SessionId {
201 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202 write!(formatter, "${}", self.0)
203 }
204}
205
206impl From<SessionId> for u32 {
207 fn from(value: SessionId) -> Self {
208 value.0
209 }
210}
211
212impl From<u32> for SessionId {
213 fn from(value: u32) -> Self {
214 Self(value)
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
224#[serde(transparent)]
225pub struct WindowId(u32);
226
227impl WindowId {
228 #[must_use]
230 pub const fn new(value: u32) -> Self {
231 Self(value)
232 }
233
234 #[must_use]
236 pub const fn as_u32(self) -> u32 {
237 self.0
238 }
239}
240
241impl fmt::Display for WindowId {
242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243 write!(formatter, "@{}", self.0)
244 }
245}
246
247impl From<WindowId> for u32 {
248 fn from(value: WindowId) -> Self {
249 value.0
250 }
251}
252
253impl From<u32> for WindowId {
254 fn from(value: u32) -> Self {
255 Self(value)
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
265#[serde(transparent)]
266pub struct PaneId(u32);
267
268impl PaneId {
269 #[must_use]
271 pub const fn new(value: u32) -> Self {
272 Self(value)
273 }
274
275 #[must_use]
277 pub const fn as_u32(self) -> u32 {
278 self.0
279 }
280}
281
282impl fmt::Display for PaneId {
283 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
284 write!(formatter, "%{}", self.0)
285 }
286}
287
288impl From<PaneId> for u32 {
289 fn from(value: PaneId) -> Self {
290 value.0
291 }
292}
293
294impl From<u32> for PaneId {
295 fn from(value: u32) -> Self {
296 Self(value)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::{is_display_unsafe_format_char, PaneId, SessionId, SessionName, WindowId};
303 use crate::RmuxError;
304
305 #[test]
306 fn session_name_rejects_empty_values() {
307 assert_eq!(SessionName::new(""), Err(RmuxError::EmptySessionName));
308 }
309
310 #[test]
311 fn session_name_rewrites_colon_and_dot() {
312 assert_eq!(
313 SessionName::new("alpha:beta.gamma")
314 .expect("rewritten")
315 .as_str(),
316 "alpha_beta_gamma"
317 );
318 }
319
320 #[test]
321 fn session_name_round_trips_through_serde() {
322 let payload = bincode::serialize("alpha.beta").expect("string encodes");
323 assert_eq!(
324 bincode::deserialize::<SessionName>(&payload).expect("rewritten on the wire"),
325 SessionName::new("alpha_beta").expect("valid")
326 );
327 }
328
329 #[test]
330 fn session_name_serde_rejects_empty_payloads_truthfully() {
331 let payload = bincode::serialize("").expect("empty string encodes");
332 assert!(
333 bincode::deserialize::<SessionName>(&payload).is_err(),
334 "empty session names must fail deserialization rather than silently \
335 producing an empty inner value"
336 );
337 }
338
339 #[test]
340 fn session_name_serialize_round_trips_after_rewriting() {
341 let original = SessionName::new("alpha.beta").expect("rewrites dots");
342 let bytes = bincode::serialize(&original).expect("session name encodes");
343 let restored: SessionName =
344 bincode::deserialize(&bytes).expect("session name decodes idempotently");
345 assert_eq!(restored, original);
346 assert_eq!(restored.as_str(), "alpha_beta");
347 }
348
349 #[test]
350 fn session_name_from_str_and_try_from_match_constructor() {
351 let from_str: SessionName = "alpha:beta".parse().expect("FromStr rewrites");
352 let try_from_ref: SessionName =
353 SessionName::try_from("alpha:beta").expect("TryFrom<&str> rewrites");
354 let try_from_owned: SessionName =
355 SessionName::try_from(String::from("alpha:beta")).expect("TryFrom<String> rewrites");
356 assert_eq!(from_str, try_from_ref);
357 assert_eq!(from_str, try_from_owned);
358 assert_eq!(from_str.as_str(), "alpha_beta");
359 }
360
361 #[test]
362 fn session_name_into_inner_returns_sanitized_string() {
363 let owned = SessionName::new("alpha:beta")
364 .expect("rewrites colons")
365 .into_inner();
366 assert_eq!(owned, "alpha_beta");
367 }
368
369 #[test]
370 fn session_name_escapes_line_and_paragraph_separators() {
371 assert_eq!(
373 SessionName::new("a\u{2028}b\u{2029}c")
374 .expect("escaped")
375 .as_str(),
376 "a\\342\\200\\250b\\342\\200\\251c"
377 );
378 }
379
380 #[test]
381 fn session_name_escapes_bidi_overrides_and_zero_width_marks() {
382 let rendered = SessionName::new("a\u{202e}b\u{200b}c").expect("escaped");
385 assert_eq!(rendered.as_str(), "a\\342\\200\\256b\\342\\200\\213c");
386 assert!(!rendered.as_str().chars().any(is_display_unsafe_format_char));
387 }
388
389 #[test]
390 fn session_name_sanitization_is_idempotent_through_serde() {
391 let original =
394 SessionName::new("tab\there\u{2028}line\u{202e}rtl\x01ctl").expect("escaped");
395 let bytes = bincode::serialize(&original).expect("encodes");
396 let restored: SessionName = bincode::deserialize(&bytes).expect("decodes");
397 assert_eq!(restored, original, "serde must preserve canonical escapes");
398 }
399
400 #[test]
401 fn session_name_escapes_backslashes_to_avoid_control_escape_collisions() {
402 let literal_escape = SessionName::new("test\\nsession").expect("valid");
403 let newline = SessionName::new("test\nsession").expect("valid");
404
405 assert_eq!(literal_escape.as_str(), "test\\\\nsession");
406 assert_eq!(newline.as_str(), "test\\nsession");
407 assert_ne!(literal_escape, newline);
408 }
409
410 #[test]
411 fn session_id_displays_with_dollar_prefix() {
412 assert_eq!(SessionId::new(7).to_string(), "$7");
413 assert_eq!(SessionId::new(7).as_u32(), 7);
414 }
415
416 #[test]
417 fn window_id_displays_with_at_prefix() {
418 assert_eq!(WindowId::new(9).to_string(), "@9");
419 assert_eq!(WindowId::new(9).as_u32(), 9);
420 }
421
422 #[test]
423 fn window_id_zero_and_max_render_as_at_prefixed_decimal() {
424 assert_eq!(WindowId::new(0).to_string(), "@0");
425 assert_eq!(
426 WindowId::new(u32::MAX).to_string(),
427 format!("@{}", u32::MAX)
428 );
429 }
430
431 #[test]
432 fn pane_id_displays_with_percent_prefix() {
433 assert_eq!(PaneId::new(3).to_string(), "%3");
434 assert_eq!(PaneId::new(3).as_u32(), 3);
435 }
436
437 #[test]
438 fn pane_id_zero_and_max_render_as_percent_prefixed_decimal() {
439 assert_eq!(PaneId::new(0).to_string(), "%0");
440 assert_eq!(PaneId::new(u32::MAX).to_string(), format!("%{}", u32::MAX));
441 }
442
443 #[test]
444 fn session_id_zero_and_max_render_as_dollar_prefixed_decimal() {
445 assert_eq!(SessionId::new(0).to_string(), "$0");
446 assert_eq!(
447 SessionId::new(u32::MAX).to_string(),
448 format!("${}", u32::MAX)
449 );
450 }
451
452 #[test]
453 fn identity_newtypes_round_trip_through_u32_conversions() {
454 for value in [0_u32, 1, 17, u32::MAX] {
455 assert_eq!(u32::from(SessionId::from(value)), value);
456 assert_eq!(u32::from(WindowId::from(value)), value);
457 assert_eq!(u32::from(PaneId::from(value)), value);
458 assert_eq!(SessionId::from(value).as_u32(), value);
459 assert_eq!(WindowId::from(value).as_u32(), value);
460 assert_eq!(PaneId::from(value).as_u32(), value);
461 }
462 }
463
464 #[test]
465 fn identity_newtypes_are_serde_transparent() {
466 assert_eq!(
467 bincode::serialize(&PaneId::new(11)).expect("encodes"),
468 bincode::serialize(&11_u32).expect("encodes")
469 );
470 assert_eq!(
471 bincode::serialize(&WindowId::new(11)).expect("encodes"),
472 bincode::serialize(&11_u32).expect("encodes")
473 );
474 assert_eq!(
475 bincode::serialize(&SessionId::new(11)).expect("encodes"),
476 bincode::serialize(&11_u32).expect("encodes")
477 );
478 }
479
480 #[test]
481 fn identity_id_newtypes_decode_back_through_serde() {
482 for value in [0_u32, 7, 257, u32::MAX] {
483 let session_bytes =
484 bincode::serialize(&SessionId::new(value)).expect("session id encodes");
485 let window_bytes =
486 bincode::serialize(&WindowId::new(value)).expect("window id encodes");
487 let pane_bytes = bincode::serialize(&PaneId::new(value)).expect("pane id encodes");
488
489 assert_eq!(
490 bincode::deserialize::<SessionId>(&session_bytes).expect("session id decodes"),
491 SessionId::new(value),
492 );
493 assert_eq!(
494 bincode::deserialize::<WindowId>(&window_bytes).expect("window id decodes"),
495 WindowId::new(value),
496 );
497 assert_eq!(
498 bincode::deserialize::<PaneId>(&pane_bytes).expect("pane id decodes"),
499 PaneId::new(value),
500 );
501 }
502 }
503
504 #[test]
505 fn identity_id_newtypes_total_order_matches_inner_u32() {
506 let mut ids = [PaneId::new(3), PaneId::new(0), PaneId::new(1)];
507 ids.sort();
508 assert_eq!(ids, [PaneId::new(0), PaneId::new(1), PaneId::new(3)]);
509 }
510
511 #[test]
512 fn session_name_already_sanitized_round_trips_through_serde() {
513 let original = SessionName::new("alpha-beta_gamma").expect("printable name");
514 let bytes = bincode::serialize(&original).expect("session name encodes");
515 let restored: SessionName =
516 bincode::deserialize(&bytes).expect("session name decodes idempotently");
517 assert_eq!(restored, original);
518 }
519}