simulator_api/
session_name.rs1use 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#[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#[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}