use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use uuid::Uuid;
const MAX_SESSION_NAME_LEN: usize = 100;
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr,
)]
pub struct SessionName(String);
impl SessionName {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"invalid session name '{name}': use 1-{MAX_SESSION_NAME_LEN} letters, digits, '-', '_' or \
'.', starting and ending with a letter or digit"
)]
pub struct InvalidSessionName {
name: String,
}
impl TryFrom<String> for SessionName {
type Error = InvalidSessionName;
fn try_from(name: String) -> Result<Self, Self::Error> {
static WELL_FORMED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$").expect("valid regex")
});
let lowered = name.to_ascii_lowercase();
if lowered.len() <= MAX_SESSION_NAME_LEN && WELL_FORMED.is_match(&lowered) {
Ok(Self(lowered))
} else {
Err(InvalidSessionName { name })
}
}
}
impl std::str::FromStr for SessionName {
type Err = InvalidSessionName;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.to_owned().try_into()
}
}
impl std::fmt::Display for SessionName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CreateToken(String);
impl CreateToken {
pub fn random() -> Self {
Self(Uuid::new_v4().simple().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestedName {
pub name: SessionName,
pub create_token: Option<CreateToken>,
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::single_char("a", true)]
#[case::single_digit("7", true)]
#[case::words("swift-blue-otter", true)]
#[case::digits_and_dashes("run-2026-10-06", true)]
#[case::inner_double_dash("a--b", true)]
#[case::underscore("my_run", true)]
#[case::dot("sol-usdc.v2", true)]
#[case::max_len(&"a".repeat(100), true)]
#[case::empty("", false)]
#[case::too_long(&"a".repeat(101), false)]
#[case::leading_dash("-run", false)]
#[case::trailing_dash("run-", false)]
#[case::lone_dash("-", false)]
#[case::leading_dot(".run", false)]
#[case::trailing_underscore("run_", false)]
#[case::space("my run", false)]
#[case::slash("a/b", false)]
#[case::non_ascii("caf\u{e9}", false)]
fn validates_session_names(#[case] name: &str, #[case] valid: bool) {
assert_eq!(name.parse::<SessionName>().is_ok(), valid, "{name:?}");
}
#[test]
fn uppercase_input_is_lowercased() {
let name: SessionName = "SOL-USDC_Run.2".parse().expect("valid");
assert_eq!(name.as_str(), "sol-usdc_run.2");
}
#[test]
fn invalid_name_error_names_the_input_and_the_rule() {
let error = "My Run".parse::<SessionName>().expect_err("invalid");
assert_eq!(
error.to_string(),
"invalid session name 'My Run': use 1-100 letters, digits, '-', '_' or '.', \
starting and ending with a letter or digit"
);
}
#[test]
fn session_name_is_a_plain_string_on_the_wire() {
let name: SessionName = "my-run".parse().expect("valid");
let json = serde_json::to_string(&name).expect("encode");
assert_eq!(json, "\"my-run\"");
assert_eq!(
serde_json::from_str::<SessionName>(&json).expect("decode"),
name
);
assert!(serde_json::from_str::<SessionName>("\"my run\"").is_err());
}
}