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/// Consecutive underscores are permitted, because a prefix is a `TypeID`
25/// prefix and `TypeID` permits them. See [`modifiers`](Self::modifiers) for
26/// what that means for the class/modifier reading.
27///
28/// # Examples
29///
30/// ```
31/// use agent_uri::AgentPrefix;
32///
33/// let prefix = AgentPrefix::parse("llm").unwrap();
34/// assert_eq!(prefix.type_class().as_str(), "llm");
35/// assert!(prefix.modifiers().is_empty());
36///
37/// let prefix = AgentPrefix::parse("llm_chat_streaming").unwrap();
38/// assert_eq!(prefix.type_class().as_str(), "llm");
39/// assert_eq!(prefix.modifiers(), &["chat", "streaming"]);
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct AgentPrefix {
43    type_class: TypeClass,
44    modifiers: Vec<String>,
45    /// Original string (normalized)
46    normalized: String,
47}
48
49impl AgentPrefix {
50    /// Parses an agent prefix from a string.
51    ///
52    /// # Errors
53    ///
54    /// Returns `AgentPrefixError` if:
55    /// - The prefix is empty
56    /// - The prefix exceeds 63 characters
57    /// - The prefix contains invalid characters
58    /// - The prefix doesn't start with a letter
59    /// - The prefix doesn't end with a letter
60    /// - The prefix contains digits
61    /// - The leading type class is not a valid core class or extension class
62    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        // Check first character - safe because we already verified input is not empty
75        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        // Check last character - safe because we already verified input is not empty
83        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        // Validate all characters and check for digits
91        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        // Split into class and modifiers. A run of underscores yields empty
104        // segments, which are not modifiers and are dropped; see `modifiers`.
105        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    /// Returns the type class.
128    #[must_use]
129    pub const fn type_class(&self) -> &TypeClass {
130        &self.type_class
131    }
132
133    /// Returns the modifiers (subclasses).
134    ///
135    /// Reading a prefix as a type class followed by refining modifiers is a
136    /// convention, not syntax: the specification says custom prefixes SHOULD
137    /// follow `type_modifier_modifier`, and a prefix that does not is still
138    /// valid. So this is a lossy view rather than a decomposition. Empty
139    /// segments, which a run of underscores produces, are not modifiers and do
140    /// not appear here, which means the class and the modifiers do not always
141    /// reassemble into the prefix. [`as_str`](Self::as_str) is the authority on
142    /// what the prefix actually is.
143    ///
144    /// ```
145    /// use agent_uri::AgentPrefix;
146    ///
147    /// // `llm__chat` is a well-formed TypeID prefix, so it parses.
148    /// let prefix = AgentPrefix::parse("llm__chat").unwrap();
149    ///
150    /// assert_eq!(prefix.type_class().as_str(), "llm");
151    /// assert_eq!(prefix.modifiers(), &["chat"]);
152    /// assert_eq!(prefix.as_str(), "llm__chat");
153    /// ```
154    #[must_use]
155    pub fn modifiers(&self) -> &[String] {
156        &self.modifiers
157    }
158
159    /// Returns the normalized string representation.
160    ///
161    /// This is the prefix exactly as it was written, and the only view of it
162    /// that is lossless.
163    #[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        // A one-letter type class is a valid TypeID prefix segment (issue #20).
259        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        // A TypeID prefix may carry a run of underscores, so this crate must
276        // accept one: refusing it would reject identifiers other TypeID
277        // implementations mint. The empty segment between them is not a
278        // modifier, and the exact text survives in `as_str`.
279        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        // The class/modifier view is lossy on purpose. The values are not: two
307        // prefixes that differ in text remain different prefixes, so nothing
308        // downstream can conflate the two identities.
309        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        // Digit in middle (not at end, which would trigger MustEndWithLetter first)
347        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}