Skip to main content

simulator_api/
session_name.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use serde_with::{DeserializeFromStr, SerializeDisplay};
6use uuid::Uuid;
7
8const MAX_SESSION_NAME_LEN: usize = 100;
9
10/// A session's name. Letters are lowercased, so names that differ only by case are the same name.
11#[derive(
12    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr,
13)]
14pub struct SessionName(String);
15
16impl SessionName {
17    pub fn as_str(&self) -> &str {
18        &self.0
19    }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23#[error(
24    "invalid session name '{name}': use 1-{MAX_SESSION_NAME_LEN} letters, digits, '-', '_' or \
25     '.', starting and ending with a letter or digit"
26)]
27pub struct InvalidSessionName {
28    name: String,
29}
30
31impl TryFrom<String> for SessionName {
32    type Error = InvalidSessionName;
33
34    fn try_from(name: String) -> Result<Self, Self::Error> {
35        static WELL_FORMED: LazyLock<Regex> = LazyLock::new(|| {
36            Regex::new(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$").expect("valid regex")
37        });
38        let lowered = name.to_ascii_lowercase();
39        if lowered.len() <= MAX_SESSION_NAME_LEN && WELL_FORMED.is_match(&lowered) {
40            Ok(Self(lowered))
41        } else {
42            Err(InvalidSessionName { name })
43        }
44    }
45}
46
47impl std::str::FromStr for SessionName {
48    type Err = InvalidSessionName;
49
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        s.to_owned().try_into()
52    }
53}
54
55impl std::fmt::Display for SessionName {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.write_str(&self.0)
58    }
59}
60
61/// Identifies one create request, so re-sending the same create after a
62/// reconnect reclaims the name it asked for instead of finding it taken.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(transparent)]
65pub struct CreateToken(String);
66
67impl CreateToken {
68    pub fn random() -> Self {
69        Self(Uuid::new_v4().simple().to_string())
70    }
71
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct RequestedName {
79    pub name: SessionName,
80    pub create_token: Option<CreateToken>,
81}
82
83#[cfg(test)]
84mod tests {
85    use rstest::rstest;
86
87    use super::*;
88
89    #[rstest]
90    #[case::single_char("a", true)]
91    #[case::single_digit("7", true)]
92    #[case::words("swift-blue-otter", true)]
93    #[case::digits_and_dashes("run-2026-10-06", true)]
94    #[case::inner_double_dash("a--b", true)]
95    #[case::underscore("my_run", true)]
96    #[case::dot("sol-usdc.v2", true)]
97    #[case::max_len(&"a".repeat(100), true)]
98    #[case::empty("", false)]
99    #[case::too_long(&"a".repeat(101), false)]
100    #[case::leading_dash("-run", false)]
101    #[case::trailing_dash("run-", false)]
102    #[case::lone_dash("-", false)]
103    #[case::leading_dot(".run", false)]
104    #[case::trailing_underscore("run_", false)]
105    #[case::space("my run", false)]
106    #[case::slash("a/b", false)]
107    #[case::non_ascii("caf\u{e9}", false)]
108    fn validates_session_names(#[case] name: &str, #[case] valid: bool) {
109        assert_eq!(name.parse::<SessionName>().is_ok(), valid, "{name:?}");
110    }
111
112    #[test]
113    fn uppercase_input_is_lowercased() {
114        let name: SessionName = "SOL-USDC_Run.2".parse().expect("valid");
115        assert_eq!(name.as_str(), "sol-usdc_run.2");
116    }
117
118    #[test]
119    fn invalid_name_error_names_the_input_and_the_rule() {
120        let error = "My Run".parse::<SessionName>().expect_err("invalid");
121        assert_eq!(
122            error.to_string(),
123            "invalid session name 'My Run': use 1-100 letters, digits, '-', '_' or '.', \
124             starting and ending with a letter or digit"
125        );
126    }
127
128    #[test]
129    fn session_name_is_a_plain_string_on_the_wire() {
130        let name: SessionName = "my-run".parse().expect("valid");
131        let json = serde_json::to_string(&name).expect("encode");
132        assert_eq!(json, "\"my-run\"");
133        assert_eq!(
134            serde_json::from_str::<SessionName>(&json).expect("decode"),
135            name
136        );
137        assert!(serde_json::from_str::<SessionName>("\"my run\"").is_err());
138    }
139}