1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::borrow::Borrow;
3use std::fmt;
4use std::str::FromStr;
5use thiserror::Error;
6
7const MAX_AGENT_ID_LEN: usize = 64;
8
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct AgentId(String);
16
17impl AgentId {
18 pub fn new(value: impl Into<String>) -> Result<Self, AgentIdError> {
19 let value = value.into();
20 validate(&value)?;
21 Ok(Self(value))
22 }
23
24 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27}
28
29fn validate(value: &str) -> Result<(), AgentIdError> {
30 if value.is_empty() {
31 return Err(AgentIdError::Empty);
32 }
33 if value.len() > MAX_AGENT_ID_LEN {
34 return Err(AgentIdError::TooLong {
35 len: value.len(),
36 max: MAX_AGENT_ID_LEN,
37 });
38 }
39 if !value.bytes().all(|byte| {
40 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
41 }) {
42 return Err(AgentIdError::InvalidCharacters(value.to_owned()));
43 }
44 if matches!(value.as_bytes().first(), Some(b'-' | b'_'))
45 || matches!(value.as_bytes().last(), Some(b'-' | b'_'))
46 {
47 return Err(AgentIdError::InvalidBoundary(value.to_owned()));
48 }
49 Ok(())
50}
51
52impl AsRef<str> for AgentId {
53 fn as_ref(&self) -> &str {
54 self.as_str()
55 }
56}
57
58impl Borrow<str> for AgentId {
59 fn borrow(&self) -> &str {
60 self.as_str()
61 }
62}
63
64impl fmt::Display for AgentId {
65 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66 formatter.write_str(self.as_str())
67 }
68}
69
70impl FromStr for AgentId {
71 type Err = AgentIdError;
72
73 fn from_str(value: &str) -> Result<Self, Self::Err> {
74 Self::new(value)
75 }
76}
77
78impl Serialize for AgentId {
79 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80 where
81 S: Serializer,
82 {
83 serializer.serialize_str(self.as_str())
84 }
85}
86
87impl<'de> Deserialize<'de> for AgentId {
88 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89 where
90 D: Deserializer<'de>,
91 {
92 let value = String::deserialize(deserializer)?;
93 Self::new(value).map_err(serde::de::Error::custom)
94 }
95}
96
97#[derive(Debug, Error, Clone, Eq, PartialEq)]
98pub enum AgentIdError {
99 #[error("agent ID cannot be empty")]
100 Empty,
101 #[error("agent ID length {len} exceeds the {max}-byte limit")]
102 TooLong { len: usize, max: usize },
103 #[error("agent ID must contain only lowercase ASCII letters, digits, '-' or '_': {0}")]
104 InvalidCharacters(String),
105 #[error("agent ID cannot start or end with '-' or '_': {0}")]
106 InvalidBoundary(String),
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn accepts_stable_slugs() {
115 assert_eq!(AgentId::new("third-party-agent").unwrap().as_str(), "third-party-agent");
116 assert_eq!(AgentId::new("agent_2").unwrap().as_str(), "agent_2");
117 }
118
119 #[test]
120 fn rejects_ambiguous_ids() {
121 assert!(matches!(AgentId::new(""), Err(AgentIdError::Empty)));
122 assert!(matches!(
123 AgentId::new("Claude"),
124 Err(AgentIdError::InvalidCharacters(_))
125 ));
126 assert!(matches!(
127 AgentId::new("-claude"),
128 Err(AgentIdError::InvalidBoundary(_))
129 ));
130 }
131}