Skip to main content

agent_uri/
type_class.rs

1//! Type classification for agents.
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::error::TypeClassError;
7
8/// Primary type classification for agents.
9///
10/// Represents what kind of agent this is at the most fundamental level.
11/// These are the core classes defined in the specification.
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub enum TypeClass {
14    /// Large language model based agent
15    Llm,
16    /// Deterministic rule/logic based agent
17    Rule,
18    /// Human-in-the-loop agent
19    Human,
20    /// Meta-agent that orchestrates other agents
21    Composite,
22    /// Agent that observes/monitors (read-only)
23    Sensor,
24    /// Agent that effects changes (write-only)
25    Actuator,
26    /// Mixed LLM + rule-based reasoning
27    Hybrid,
28    /// Extension class not in the core set
29    Extension(ExtensionClass),
30}
31
32/// An extension class name (custom type classes).
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct ExtensionClass(String);
35
36impl ExtensionClass {
37    /// Creates a new extension class.
38    ///
39    /// A single letter is a valid name: an agent ID is a `TypeID`, and the
40    /// `TypeID` specification admits prefixes of one to sixty-three characters.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`TypeClassError::Empty`] for an empty name, and
45    /// [`TypeClassError::InvalidChar`] naming the first character that is not a
46    /// lowercase ASCII letter.
47    ///
48    /// # Example
49    ///
50    /// ```
51    /// use agent_uri::{ExtensionClass, TypeClassError};
52    ///
53    /// assert_eq!(ExtensionClass::new("custom").unwrap().as_str(), "custom");
54    /// assert_eq!(
55    ///     ExtensionClass::new("v2"),
56    ///     Err(TypeClassError::InvalidChar { char: '2', position: 1 })
57    /// );
58    /// ```
59    pub fn new(name: &str) -> Result<Self, TypeClassError> {
60        if name.is_empty() {
61            return Err(TypeClassError::Empty);
62        }
63        // Position counted in characters, not bytes: an offset that lands
64        // mid-character points a reader at nothing (issues #33, #89).
65        if let Some((position, char)) = name
66            .chars()
67            .enumerate()
68            .find(|(_, c)| !c.is_ascii_lowercase())
69        {
70            return Err(TypeClassError::InvalidChar { char, position });
71        }
72        Ok(Self(name.to_string()))
73    }
74
75    /// Returns the class name as a string.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82impl fmt::Display for ExtensionClass {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(f, "{}", self.0)
85    }
86}
87
88impl TypeClass {
89    /// Returns the string representation of this type class.
90    #[must_use]
91    pub fn as_str(&self) -> &str {
92        match self {
93            Self::Llm => "llm",
94            Self::Rule => "rule",
95            Self::Human => "human",
96            Self::Composite => "composite",
97            Self::Sensor => "sensor",
98            Self::Actuator => "actuator",
99            Self::Hybrid => "hybrid",
100            Self::Extension(ext) => ext.as_str(),
101        }
102    }
103
104    /// Returns true if this is a core type class (not an extension).
105    #[must_use]
106    pub const fn is_core(&self) -> bool {
107        !matches!(self, Self::Extension(_))
108    }
109}
110
111impl fmt::Display for TypeClass {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(f, "{}", self.as_str())
114    }
115}
116
117impl FromStr for TypeClass {
118    type Err = TypeClassError;
119
120    fn from_str(s: &str) -> Result<Self, Self::Err> {
121        match s {
122            "llm" => Ok(Self::Llm),
123            "rule" => Ok(Self::Rule),
124            "human" => Ok(Self::Human),
125            "composite" => Ok(Self::Composite),
126            "sensor" => Ok(Self::Sensor),
127            "actuator" => Ok(Self::Actuator),
128            "hybrid" => Ok(Self::Hybrid),
129            other => {
130                let ext = ExtensionClass::new(other)?;
131                Ok(Self::Extension(ext))
132            }
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parse_core_classes() {
143        assert_eq!("llm".parse::<TypeClass>().unwrap(), TypeClass::Llm);
144        assert_eq!("rule".parse::<TypeClass>().unwrap(), TypeClass::Rule);
145        assert_eq!("human".parse::<TypeClass>().unwrap(), TypeClass::Human);
146        assert_eq!(
147            "composite".parse::<TypeClass>().unwrap(),
148            TypeClass::Composite
149        );
150        assert_eq!("sensor".parse::<TypeClass>().unwrap(), TypeClass::Sensor);
151        assert_eq!(
152            "actuator".parse::<TypeClass>().unwrap(),
153            TypeClass::Actuator
154        );
155        assert_eq!("hybrid".parse::<TypeClass>().unwrap(), TypeClass::Hybrid);
156    }
157
158    #[test]
159    fn core_classes_are_core() {
160        assert!(TypeClass::Llm.is_core());
161        assert!(TypeClass::Rule.is_core());
162        assert!(TypeClass::Human.is_core());
163    }
164
165    #[test]
166    fn parse_extension_class() {
167        let tc = "custom".parse::<TypeClass>().unwrap();
168        assert!(matches!(tc, TypeClass::Extension(_)));
169        assert!(!tc.is_core());
170        assert_eq!(tc.as_str(), "custom");
171    }
172
173    #[test]
174    fn extension_class_validation() {
175        // Empty
176        assert!(ExtensionClass::new("").is_err());
177        // Contains uppercase
178        assert!(ExtensionClass::new("Custom").is_err());
179        // Contains digit
180        assert!(ExtensionClass::new("v2").is_err());
181        // Valid
182        assert!(ExtensionClass::new("custom").is_ok());
183    }
184
185    #[test]
186    fn a_single_letter_is_a_valid_extension_class() {
187        // TypeID prefixes run from one to sixty-three characters, and an agent
188        // ID is a TypeID (issue #20).
189        assert_eq!(ExtensionClass::new("a").unwrap().as_str(), "a");
190    }
191
192    #[test]
193    fn a_one_character_name_that_is_not_a_lowercase_letter_is_refused() {
194        // Two bytes, one character, and not a-z (issue #33).
195        assert_eq!(
196            ExtensionClass::new("\u{00e9}"),
197            Err(TypeClassError::InvalidChar {
198                char: '\u{00e9}',
199                position: 0
200            })
201        );
202    }
203
204    #[test]
205    fn a_rejection_names_the_character_that_caused_it() {
206        // The old `&'static str` could only say "not all lowercase". A caller
207        // fixing a name wants to know which character and where.
208        assert_eq!(
209            ExtensionClass::new("v2"),
210            Err(TypeClassError::InvalidChar {
211                char: '2',
212                position: 1
213            })
214        );
215        assert_eq!(ExtensionClass::new(""), Err(TypeClassError::Empty));
216    }
217
218    #[test]
219    fn a_position_counts_characters_rather_than_bytes() {
220        // A byte offset past a multi-byte character points at nothing a reader
221        // can find in the string they wrote (issues #33, #89).
222        assert_eq!(
223            ExtensionClass::new("\u{00e9}\u{00e9}X"),
224            Err(TypeClassError::InvalidChar {
225                char: '\u{00e9}',
226                position: 0
227            })
228        );
229        assert_eq!(
230            ExtensionClass::new("ab\u{00e9}"),
231            Err(TypeClassError::InvalidChar {
232                char: '\u{00e9}',
233                position: 2
234            })
235        );
236    }
237
238    #[test]
239    fn a_type_class_rejection_is_a_std_error() {
240        // What the `&'static str` could not be: a type that composes with `?`
241        // and with every error-reporting crate.
242        fn assert_error<E: std::error::Error>(_: &E) {}
243
244        let error = "Custom".parse::<TypeClass>().unwrap_err();
245        assert_error(&error);
246        assert!(error.to_string().contains("lowercase"), "{error}");
247    }
248}