1use std::fmt;
4use std::str::FromStr;
5
6use crate::error::TypeClassError;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub enum TypeClass {
14 Llm,
16 Rule,
18 Human,
20 Composite,
22 Sensor,
24 Actuator,
26 Hybrid,
28 Extension(ExtensionClass),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct ExtensionClass(String);
35
36impl ExtensionClass {
37 pub fn new(name: &str) -> Result<Self, TypeClassError> {
60 if name.is_empty() {
61 return Err(TypeClassError::Empty);
62 }
63 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 #[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 #[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 #[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 assert!(ExtensionClass::new("").is_err());
177 assert!(ExtensionClass::new("Custom").is_err());
179 assert!(ExtensionClass::new("v2").is_err());
181 assert!(ExtensionClass::new("custom").is_ok());
183 }
184
185 #[test]
186 fn a_single_letter_is_a_valid_extension_class() {
187 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 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 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 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 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}