paramodel_elements/
names.rs1#[derive(Debug, thiserror::Error, PartialEq, Eq)]
17pub enum NameError {
18 #[error("name must not be empty")]
20 Empty,
21
22 #[error("name is {length} bytes, exceeds maximum of {max}")]
24 TooLong { length: usize, max: usize },
25
26 #[error("name contains invalid character '{ch}' at byte offset {offset}")]
29 InvalidChar { ch: char, offset: usize },
30
31 #[error("name must start with a letter or underscore, got '{ch}'")]
33 BadStart { ch: char },
34}
35
36pub trait Name: Sized + AsRef<str> {
43 const KIND: &'static str;
45
46 const MAX_LEN: usize = 64;
49
50 fn validate_char(offset: usize, ch: char) -> Result<(), NameError> {
55 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
56 Ok(())
57 } else {
58 Err(NameError::InvalidChar { ch, offset })
59 }
60 }
61
62 fn validate(s: &str) -> Result<(), NameError> {
68 if s.is_empty() {
69 return Err(NameError::Empty);
70 }
71 if s.len() > Self::MAX_LEN {
72 return Err(NameError::TooLong {
73 length: s.len(),
74 max: Self::MAX_LEN,
75 });
76 }
77 let first = s.chars().next().expect("non-empty checked above");
80 if !(first.is_ascii_alphabetic() || first == '_') {
81 return Err(NameError::BadStart { ch: first });
82 }
83 for (offset, ch) in s.char_indices() {
84 Self::validate_char(offset, ch)?;
85 }
86 Ok(())
87 }
88}
89
90#[macro_export]
105macro_rules! name_type {
106 (
107 $(#[$meta:meta])*
108 $vis:vis struct $Name:ident {
109 kind: $kind:literal
110 $(, max_len: $max_len:expr )?
111 $(,)?
112 }
113 ) => {
114 $(#[$meta])*
115 #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
116 $vis struct $Name(String);
117
118 impl $Name {
119 pub fn new(candidate: impl Into<String>) -> Result<Self, $crate::names::NameError> {
121 let s = candidate.into();
122 <Self as $crate::names::Name>::validate(&s)?;
123 Ok(Self(s))
124 }
125
126 #[must_use]
128 pub fn as_str(&self) -> &str {
129 &self.0
130 }
131
132 #[must_use]
134 pub fn into_inner(self) -> String {
135 self.0
136 }
137 }
138
139 impl $crate::names::Name for $Name {
140 const KIND: &'static str = $kind;
141 $( const MAX_LEN: usize = $max_len; )?
142 }
143
144 impl AsRef<str> for $Name {
145 fn as_ref(&self) -> &str {
146 &self.0
147 }
148 }
149
150 impl std::fmt::Display for $Name {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 f.write_str(&self.0)
153 }
154 }
155
156 impl std::fmt::Debug for $Name {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 write!(f, "{}({:?})", <Self as $crate::names::Name>::KIND, self.0)
159 }
160 }
161
162 impl std::str::FromStr for $Name {
163 type Err = $crate::names::NameError;
164 fn from_str(s: &str) -> Result<Self, $crate::names::NameError> {
165 Self::new(s.to_owned())
166 }
167 }
168
169 impl TryFrom<&str> for $Name {
170 type Error = $crate::names::NameError;
171 fn try_from(s: &str) -> Result<Self, $crate::names::NameError> {
172 Self::new(s.to_owned())
173 }
174 }
175
176 impl TryFrom<String> for $Name {
177 type Error = $crate::names::NameError;
178 fn try_from(s: String) -> Result<Self, $crate::names::NameError> {
179 Self::new(s)
180 }
181 }
182
183 impl serde::Serialize for $Name {
184 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
185 s.serialize_str(&self.0)
186 }
187 }
188
189 impl<'de> serde::Deserialize<'de> for $Name {
190 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
191 let s = String::deserialize(deserializer)?;
192 Self::new(s).map_err(serde::de::Error::custom)
193 }
194 }
195 };
196}
197
198name_type! {
207 pub struct ParameterName { kind: "ParameterName" }
212}
213
214name_type! {
215 pub struct ElementName { kind: "ElementName" }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn simple_names_are_accepted() {
227 for s in ["threads", "max_connections", "api-v1", "dataset.main", "_reserved"] {
228 ParameterName::new(s).expect(s);
229 }
230 }
231
232 #[test]
233 fn empty_names_are_rejected() {
234 assert_eq!(ParameterName::new(""), Err(NameError::Empty));
235 }
236
237 #[test]
238 fn names_must_start_with_letter_or_underscore() {
239 assert!(matches!(
240 ParameterName::new("1starts-with-digit"),
241 Err(NameError::BadStart { ch: '1' })
242 ));
243 assert!(matches!(
244 ParameterName::new(".leading-dot"),
245 Err(NameError::BadStart { ch: '.' })
246 ));
247 }
248
249 #[test]
250 fn names_reject_forbidden_chars() {
251 let err = ParameterName::new("has space").unwrap_err();
252 assert_eq!(
253 err,
254 NameError::InvalidChar { ch: ' ', offset: 3 }
255 );
256 }
257
258 #[test]
259 fn names_reject_overlong_candidates() {
260 let long = "a".repeat(65);
261 let err = ParameterName::new(long).unwrap_err();
262 assert_eq!(err, NameError::TooLong { length: 65, max: 64 });
263 }
264
265 #[test]
266 fn debug_format_includes_kind() {
267 let p = ParameterName::new("threads").unwrap();
268 assert_eq!(format!("{p:?}"), "ParameterName(\"threads\")");
269
270 let e = ElementName::new("jvector").unwrap();
271 assert_eq!(format!("{e:?}"), "ElementName(\"jvector\")");
272 }
273
274 #[test]
275 fn different_kinds_are_type_distinct() {
276 let _p = ParameterName::new("x").unwrap();
280 let _e = ElementName::new("x").unwrap();
281 }
283
284 #[test]
285 fn serde_roundtrip() {
286 let name = ParameterName::new("threads").unwrap();
287 let json = serde_json::to_string(&name).unwrap();
288 assert_eq!(json, "\"threads\"");
289 let back: ParameterName = serde_json::from_str(&json).unwrap();
290 assert_eq!(name, back);
291 }
292
293 #[test]
294 fn deserialise_rejects_invalid_names() {
295 let err = serde_json::from_str::<ParameterName>("\"has space\"");
296 assert!(err.is_err());
297 }
298
299 use proptest::prelude::*;
301
302 proptest! {
303 #[test]
304 fn valid_names_roundtrip(
305 s in "[A-Za-z_][A-Za-z0-9_\\-.]{0,63}"
306 ) {
307 let name = ParameterName::new(s.clone()).expect(&s);
308 prop_assert_eq!(name.as_str(), s.as_str());
309 }
310 }
311}
312