use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize};
pub const MAX_ID_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IdError {
#[error("identifier is empty")]
Empty,
#[error("identifier exceeds {MAX_ID_LEN} bytes")]
TooLong,
#[error("identifier contains an invalid character (allowed: A-Z a-z 0-9 '.' '_' ':' '-')")]
InvalidChar,
}
fn validate(s: &str) -> Result<(), IdError> {
if s.is_empty() {
return Err(IdError::Empty);
}
if s.len() > MAX_ID_LEN {
return Err(IdError::TooLong);
}
if !s
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
{
return Err(IdError::InvalidChar);
}
Ok(())
}
macro_rules! id_newtype {
($name:ident, $schema_name:literal, $doc:literal) => {
#[doc = $doc]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn parse(s: impl Into<String>) -> Result<Self, IdError> {
let s = s.into();
validate(&s)?;
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for $name {
type Err = IdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(serde::de::Error::custom)
}
}
impl rmcp::schemars::JsonSchema for $name {
fn schema_name() -> std::borrow::Cow<'static, str> {
$schema_name.into()
}
fn json_schema(_: &mut rmcp::schemars::SchemaGenerator) -> rmcp::schemars::Schema {
rmcp::schemars::json_schema!({
"type": "string",
"pattern": r"^[A-Za-z0-9._:-]+$",
"minLength": 1,
"maxLength": 128,
})
}
}
};
}
id_newtype!(
AgentId,
"AgentId",
"Validated identity handle for an opaque agent (e.g. from `BASEMIND_AGENT_ID`)."
);
id_newtype!(RoomId, "RoomId", "Validated name of a comms room.");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_identifiers() {
for s in ["agent-1", "claude_code", "team:backend", "a.b.c", "A1", "x"] {
assert!(AgentId::parse(s).is_ok(), "{s} should be valid");
assert!(RoomId::parse(s).is_ok(), "{s} should be valid");
}
}
#[test]
fn rejects_empty() {
assert_eq!(AgentId::parse(""), Err(IdError::Empty));
}
#[test]
fn rejects_too_long() {
let long = "a".repeat(MAX_ID_LEN + 1);
assert_eq!(AgentId::parse(long), Err(IdError::TooLong));
assert!(AgentId::parse("a".repeat(MAX_ID_LEN)).is_ok());
}
#[test]
fn rejects_invalid_chars_including_nul_and_separators() {
for bad in [
"has space",
"slash/",
"nul\0byte",
"emoji😀",
"new\nline",
"a\tb",
] {
assert_eq!(
AgentId::parse(bad),
Err(IdError::InvalidChar),
"{bad:?} should be rejected"
);
}
}
#[test]
fn serializes_as_plain_string() {
let id = AgentId::parse("agent-7").unwrap();
assert_eq!(serde_json::to_string(&id).unwrap(), "\"agent-7\"");
}
#[test]
fn deserialize_validates() {
let ok: RoomId = serde_json::from_str("\"backend-team\"").unwrap();
assert_eq!(ok.as_str(), "backend-team");
assert!(serde_json::from_str::<RoomId>("\"bad id\"").is_err());
assert!(serde_json::from_str::<RoomId>("\"\"").is_err());
}
#[test]
fn from_str_roundtrips_display() {
let id: AgentId = "node.7".parse().unwrap();
assert_eq!(id.to_string(), "node.7");
}
}