simulator-api 0.28.0

Wire-protocol types for the Solana simulator backtest WebSocket API
Documentation
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;

/// A session's name. Letters are lowercased, so names that differ only by case are the same name.
#[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)
    }
}

/// Identifies one create request, so re-sending the same create after a
/// reconnect reclaims the name it asked for instead of finding it taken.
#[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());
    }
}