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