agent_uri/
agent_prefix.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct AgentPrefix {
43 type_class: TypeClass,
44 modifiers: Vec<String>,
45 normalized: String,
47}
48
49impl AgentPrefix {
50 pub fn parse(input: &str) -> Result<Self, AgentPrefixError> {
63 if input.is_empty() {
64 return Err(AgentPrefixError::Empty);
65 }
66
67 if input.len() > MAX_AGENT_PREFIX_LENGTH {
68 return Err(AgentPrefixError::TooLong {
69 max: MAX_AGENT_PREFIX_LENGTH,
70 actual: input.len(),
71 });
72 }
73
74 let Some(first) = input.chars().next() else {
76 return Err(AgentPrefixError::Empty);
77 };
78 if !first.is_ascii_lowercase() {
79 return Err(AgentPrefixError::MustStartWithLetter { found: first });
80 }
81
82 let Some(last) = input.chars().next_back() else {
84 return Err(AgentPrefixError::Empty);
85 };
86 if !last.is_ascii_lowercase() {
87 return Err(AgentPrefixError::MustEndWithLetter { found: last });
88 }
89
90 for (i, c) in input.chars().enumerate() {
92 if c.is_ascii_digit() {
93 return Err(AgentPrefixError::ContainsDigit { position: i });
94 }
95 if !c.is_ascii_lowercase() && c != '_' {
96 return Err(AgentPrefixError::InvalidChar {
97 char: c,
98 position: i,
99 });
100 }
101 }
102
103 let parts: Vec<&str> = input.split('_').collect();
106 let type_class =
107 parts[0]
108 .parse::<TypeClass>()
109 .map_err(|reason| AgentPrefixError::InvalidTypeClass {
110 class: parts[0].to_string(),
111 reason,
112 })?;
113
114 let modifiers = parts[1..]
115 .iter()
116 .filter(|s| !s.is_empty())
117 .map(|s| (*s).to_string())
118 .collect();
119
120 Ok(Self {
121 type_class,
122 modifiers,
123 normalized: input.to_string(),
124 })
125 }
126
127 #[must_use]
129 pub const fn type_class(&self) -> &TypeClass {
130 &self.type_class
131 }
132
133 #[must_use]
155 pub fn modifiers(&self) -> &[String] {
156 &self.modifiers
157 }
158
159 #[must_use]
164 pub fn as_str(&self) -> &str {
165 &self.normalized
166 }
167}
168
169impl fmt::Display for AgentPrefix {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 write!(f, "{}", self.normalized)
172 }
173}
174
175impl FromStr for AgentPrefix {
176 type Err = AgentPrefixError;
177
178 fn from_str(s: &str) -> Result<Self, Self::Err> {
179 Self::parse(s)
180 }
181}
182
183impl AsRef<str> for AgentPrefix {
184 fn as_ref(&self) -> &str {
185 &self.normalized
186 }
187}
188
189impl TryFrom<&str> for AgentPrefix {
190 type Error = AgentPrefixError;
191
192 fn try_from(s: &str) -> Result<Self, Self::Error> {
193 Self::parse(s)
194 }
195}
196
197impl Deref for AgentPrefix {
198 type Target = str;
199
200 fn deref(&self) -> &Self::Target {
201 &self.normalized
202 }
203}
204
205impl PartialOrd for AgentPrefix {
206 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
207 Some(self.cmp(other))
208 }
209}
210
211impl Ord for AgentPrefix {
212 fn cmp(&self, other: &Self) -> Ordering {
213 self.normalized.cmp(&other.normalized)
214 }
215}
216
217#[cfg(feature = "serde")]
218impl serde::Serialize for AgentPrefix {
219 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
220 where
221 S: serde::Serializer,
222 {
223 serializer.serialize_str(&self.normalized)
224 }
225}
226
227#[cfg(feature = "serde")]
228impl<'de> serde::Deserialize<'de> for AgentPrefix {
229 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230 where
231 D: serde::Deserializer<'de>,
232 {
233 let s = String::deserialize(deserializer)?;
234 Self::parse(&s).map_err(serde::de::Error::custom)
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn parse_simple_class() {
244 let prefix = AgentPrefix::parse("llm").unwrap();
245 assert_eq!(prefix.type_class().as_str(), "llm");
246 assert!(prefix.modifiers().is_empty());
247 }
248
249 #[test]
250 fn parse_with_modifiers() {
251 let prefix = AgentPrefix::parse("llm_chat_streaming").unwrap();
252 assert_eq!(prefix.type_class().as_str(), "llm");
253 assert_eq!(prefix.modifiers(), &["chat", "streaming"]);
254 }
255
256 #[test]
257 fn parse_one_letter_type_class_with_a_modifier() {
258 let prefix = AgentPrefix::parse("a_b").unwrap();
260
261 assert_eq!(prefix.type_class().as_str(), "a");
262 assert_eq!(prefix.modifiers(), &["b"]);
263 }
264
265 #[test]
266 fn parse_single_letter_prefix() {
267 let prefix = AgentPrefix::parse("a").unwrap();
268
269 assert_eq!(prefix.type_class().as_str(), "a");
270 assert!(prefix.modifiers().is_empty());
271 }
272
273 #[test]
274 fn parse_consecutive_underscores_is_accepted() {
275 let prefix = AgentPrefix::parse("llm__chat").unwrap();
280
281 assert_eq!(prefix.type_class().as_str(), "llm");
282 assert_eq!(prefix.modifiers(), &["chat"]);
283 assert_eq!(prefix.as_str(), "llm__chat");
284 }
285
286 #[test]
287 fn parse_long_underscore_run_yields_no_empty_modifiers() {
288 let prefix = AgentPrefix::parse("a___b").unwrap();
289
290 assert_eq!(prefix.modifiers(), &["b"]);
291 assert!(
292 prefix.modifiers().iter().all(|m| !m.is_empty()),
293 "an empty string is not a modifier by any reading"
294 );
295 }
296
297 #[test]
298 fn a_prefix_with_only_underscores_between_two_letters_has_one_modifier() {
299 let prefix = AgentPrefix::parse("llm____streaming").unwrap();
300
301 assert_eq!(prefix.modifiers(), &["streaming"]);
302 }
303
304 #[test]
305 fn underscore_runs_are_distinct_values_despite_a_shared_reading() {
306 let one = AgentPrefix::parse("llm_chat").unwrap();
310 let two = AgentPrefix::parse("llm__chat").unwrap();
311
312 assert_eq!(one.type_class(), two.type_class());
313 assert_eq!(one.modifiers(), two.modifiers());
314 assert_ne!(one, two);
315 assert_ne!(one.as_str(), two.as_str());
316 }
317
318 #[test]
319 fn parse_invalid_character_still_reports_invalid_char() {
320 let result = AgentPrefix::parse("llm-chat");
321
322 assert!(matches!(
323 result,
324 Err(AgentPrefixError::InvalidChar {
325 char: '-',
326 position: 3,
327 })
328 ));
329 }
330
331 #[test]
332 fn parse_empty_fails() {
333 let result = AgentPrefix::parse("");
334 assert!(matches!(result, Err(AgentPrefixError::Empty)));
335 }
336
337 #[test]
338 fn parse_too_long_fails() {
339 let long = "a".repeat(64);
340 let result = AgentPrefix::parse(&long);
341 assert!(matches!(result, Err(AgentPrefixError::TooLong { .. })));
342 }
343
344 #[test]
345 fn parse_with_digit_fails() {
346 let result = AgentPrefix::parse("ll2m");
348 assert!(matches!(
349 result,
350 Err(AgentPrefixError::ContainsDigit { position: 2 })
351 ));
352 }
353
354 #[test]
355 fn parse_uppercase_fails() {
356 let result = AgentPrefix::parse("LLM");
357 assert!(matches!(
358 result,
359 Err(AgentPrefixError::MustStartWithLetter { found: 'L' })
360 ));
361 }
362
363 #[test]
364 fn parse_ends_with_underscore_fails() {
365 let result = AgentPrefix::parse("llm_");
366 assert!(matches!(
367 result,
368 Err(AgentPrefixError::MustEndWithLetter { found: '_' })
369 ));
370 }
371
372 #[test]
373 fn parse_starts_with_underscore_fails() {
374 let result = AgentPrefix::parse("_llm");
375 assert!(matches!(
376 result,
377 Err(AgentPrefixError::MustStartWithLetter { found: '_' })
378 ));
379 }
380}