Skip to main content

agent_uri/
agent_prefix.rs

1//! Agent prefix type for semantic classification.
2
3use std::cmp::Ordering;
4use std::fmt;
5use std::ops::Deref;
6use std::str::FromStr;
7
8use crate::constants::MAX_AGENT_PREFIX_LENGTH;
9use crate::error::AgentPrefixError;
10use crate::type_class::TypeClass;
11
12/// A validated agent prefix (semantic type classification).
13///
14/// The prefix encodes WHAT THE AGENT IS (its implementation type).
15/// Structure: `type-class[_type-modifier[_type-modifier...]]`
16///
17/// # Constraints
18///
19/// - Maximum 63 characters
20/// - Only lowercase letters and underscores
21/// - Must start and end with a letter
22/// - No digits allowed
23///
24/// # Examples
25///
26/// ```
27/// use agent_uri::AgentPrefix;
28///
29/// let prefix = AgentPrefix::parse("llm").unwrap();
30/// assert_eq!(prefix.type_class().as_str(), "llm");
31/// assert!(prefix.modifiers().is_empty());
32///
33/// let prefix = AgentPrefix::parse("llm_chat_streaming").unwrap();
34/// assert_eq!(prefix.type_class().as_str(), "llm");
35/// assert_eq!(prefix.modifiers(), &["chat", "streaming"]);
36/// ```
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct AgentPrefix {
39    type_class: TypeClass,
40    modifiers: Vec<String>,
41    /// Original string (normalized)
42    normalized: String,
43}
44
45impl AgentPrefix {
46    /// Parses an agent prefix from a string.
47    ///
48    /// # Errors
49    ///
50    /// Returns `AgentPrefixError` if:
51    /// - The prefix is empty
52    /// - The prefix exceeds 63 characters
53    /// - The prefix contains invalid characters
54    /// - The prefix doesn't start with a letter
55    /// - The prefix doesn't end with a letter
56    /// - The prefix contains digits
57    pub fn parse(input: &str) -> Result<Self, AgentPrefixError> {
58        if input.is_empty() {
59            return Err(AgentPrefixError::Empty);
60        }
61
62        if input.len() > MAX_AGENT_PREFIX_LENGTH {
63            return Err(AgentPrefixError::TooLong {
64                max: MAX_AGENT_PREFIX_LENGTH,
65                actual: input.len(),
66            });
67        }
68
69        // Check first character - safe because we already verified input is not empty
70        let Some(first) = input.chars().next() else {
71            return Err(AgentPrefixError::Empty);
72        };
73        if !first.is_ascii_lowercase() {
74            return Err(AgentPrefixError::MustStartWithLetter { found: first });
75        }
76
77        // Check last character - safe because we already verified input is not empty
78        let Some(last) = input.chars().next_back() else {
79            return Err(AgentPrefixError::Empty);
80        };
81        if !last.is_ascii_lowercase() {
82            return Err(AgentPrefixError::MustEndWithLetter { found: last });
83        }
84
85        // Validate all characters and check for digits
86        for (i, c) in input.chars().enumerate() {
87            if c.is_ascii_digit() {
88                return Err(AgentPrefixError::ContainsDigit { position: i });
89            }
90            if !c.is_ascii_lowercase() && c != '_' {
91                return Err(AgentPrefixError::InvalidChar { char: c, position: i });
92            }
93        }
94
95        // Split into class and modifiers
96        let parts: Vec<&str> = input.split('_').collect();
97        let type_class = parts[0].parse::<TypeClass>().map_err(|_| {
98            AgentPrefixError::InvalidChar {
99                char: parts[0].chars().next().unwrap_or(' '),
100                position: 0,
101            }
102        })?;
103
104        let modifiers = parts[1..].iter().map(|s| (*s).to_string()).collect();
105
106        Ok(Self {
107            type_class,
108            modifiers,
109            normalized: input.to_string(),
110        })
111    }
112
113    /// Returns the type class.
114    #[must_use]
115    pub const fn type_class(&self) -> &TypeClass {
116        &self.type_class
117    }
118
119    /// Returns the modifiers (subclasses).
120    #[must_use]
121    pub fn modifiers(&self) -> &[String] {
122        &self.modifiers
123    }
124
125    /// Returns the normalized string representation.
126    #[must_use]
127    pub fn as_str(&self) -> &str {
128        &self.normalized
129    }
130}
131
132impl fmt::Display for AgentPrefix {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(f, "{}", self.normalized)
135    }
136}
137
138impl FromStr for AgentPrefix {
139    type Err = AgentPrefixError;
140
141    fn from_str(s: &str) -> Result<Self, Self::Err> {
142        Self::parse(s)
143    }
144}
145
146impl AsRef<str> for AgentPrefix {
147    fn as_ref(&self) -> &str {
148        &self.normalized
149    }
150}
151
152impl TryFrom<&str> for AgentPrefix {
153    type Error = AgentPrefixError;
154
155    fn try_from(s: &str) -> Result<Self, Self::Error> {
156        Self::parse(s)
157    }
158}
159
160impl Deref for AgentPrefix {
161    type Target = str;
162
163    fn deref(&self) -> &Self::Target {
164        &self.normalized
165    }
166}
167
168impl PartialOrd for AgentPrefix {
169    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
170        Some(self.cmp(other))
171    }
172}
173
174impl Ord for AgentPrefix {
175    fn cmp(&self, other: &Self) -> Ordering {
176        self.normalized.cmp(&other.normalized)
177    }
178}
179
180#[cfg(feature = "serde")]
181impl serde::Serialize for AgentPrefix {
182    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
183    where
184        S: serde::Serializer,
185    {
186        serializer.serialize_str(&self.normalized)
187    }
188}
189
190#[cfg(feature = "serde")]
191impl<'de> serde::Deserialize<'de> for AgentPrefix {
192    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
193    where
194        D: serde::Deserializer<'de>,
195    {
196        let s = String::deserialize(deserializer)?;
197        Self::parse(&s).map_err(serde::de::Error::custom)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn parse_simple_class() {
207        let prefix = AgentPrefix::parse("llm").unwrap();
208        assert_eq!(prefix.type_class().as_str(), "llm");
209        assert!(prefix.modifiers().is_empty());
210    }
211
212    #[test]
213    fn parse_with_modifiers() {
214        let prefix = AgentPrefix::parse("llm_chat_streaming").unwrap();
215        assert_eq!(prefix.type_class().as_str(), "llm");
216        assert_eq!(prefix.modifiers(), &["chat", "streaming"]);
217    }
218
219    #[test]
220    fn parse_empty_fails() {
221        let result = AgentPrefix::parse("");
222        assert!(matches!(result, Err(AgentPrefixError::Empty)));
223    }
224
225    #[test]
226    fn parse_too_long_fails() {
227        let long = "a".repeat(64);
228        let result = AgentPrefix::parse(&long);
229        assert!(matches!(result, Err(AgentPrefixError::TooLong { .. })));
230    }
231
232    #[test]
233    fn parse_with_digit_fails() {
234        // Digit in middle (not at end, which would trigger MustEndWithLetter first)
235        let result = AgentPrefix::parse("ll2m");
236        assert!(matches!(
237            result,
238            Err(AgentPrefixError::ContainsDigit { position: 2 })
239        ));
240    }
241
242    #[test]
243    fn parse_uppercase_fails() {
244        let result = AgentPrefix::parse("LLM");
245        assert!(matches!(result, Err(AgentPrefixError::MustStartWithLetter { found: 'L' })));
246    }
247
248    #[test]
249    fn parse_ends_with_underscore_fails() {
250        let result = AgentPrefix::parse("llm_");
251        assert!(matches!(result, Err(AgentPrefixError::MustEndWithLetter { found: '_' })));
252    }
253
254    #[test]
255    fn parse_starts_with_underscore_fails() {
256        let result = AgentPrefix::parse("_llm");
257        assert!(matches!(result, Err(AgentPrefixError::MustStartWithLetter { found: '_' })));
258    }
259}