Skip to main content

gate4agent_types/
id.rs

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/// Stable, extensible identifier for an agent CLI.
10///
11/// Unlike a consumer's legacy closed tool enum, this type preserves IDs added
12/// after a consumer was compiled. IDs use lowercase ASCII slugs so they remain
13/// safe as registry keys and serialized protocol values.
14#[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}