Skip to main content

paramodel_elements/
names.rs

1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Validated name newtypes.
5//!
6//! Names of parameters, elements, axes, plugs, and sockets all share the
7//! same shape: a validated UTF-8 identifier. The `Name` trait captures
8//! the validation contract; each domain name is a distinct newtype so
9//! the compiler can catch "passed an `ElementName` where a `ParameterName`
10//! was expected" at call sites.
11
12/// Errors from name construction.
13///
14/// Kept separate from the crate's top-level `Error` so it can be reused
15/// in lower layers without a cyclic reference.
16#[derive(Debug, thiserror::Error, PartialEq, Eq)]
17pub enum NameError {
18    /// The candidate name was empty or made of only whitespace.
19    #[error("name must not be empty")]
20    Empty,
21
22    /// The candidate name was longer than the permitted byte length.
23    #[error("name is {length} bytes, exceeds maximum of {max}")]
24    TooLong { length: usize, max: usize },
25
26    /// The candidate name contained a character outside the allowed class
27    /// for this name kind.
28    #[error("name contains invalid character '{ch}' at byte offset {offset}")]
29    InvalidChar { ch: char, offset: usize },
30
31    /// The first character must be an ASCII letter or underscore.
32    #[error("name must start with a letter or underscore, got '{ch}'")]
33    BadStart { ch: char },
34}
35
36/// The validation contract shared by every name kind in the system.
37///
38/// Implementers pick the permitted character class and length cap by
39/// overriding [`Self::validate_char`] and [`Self::MAX_LEN`]. The default
40/// [`Self::validate`] implementation enforces the shared "non-empty,
41/// legal identifier start, every character passes `validate_char`" rule.
42pub trait Name: Sized + AsRef<str> {
43    /// Human-readable kind label, used in error messages and `Debug`.
44    const KIND: &'static str;
45
46    /// Maximum byte length (UTF-8). Default 64 — override when a larger
47    /// ceiling is natural (plan names, for example).
48    const MAX_LEN: usize = 64;
49
50    /// Called once per character to decide membership.
51    ///
52    /// The default admits ASCII identifier characters plus `-` and `.`,
53    /// which is a reasonable class for almost every name kind we ship.
54    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    /// Validate the given string as a name of this kind.
63    ///
64    /// Called from each newtype's constructor. Implementations that need
65    /// stricter or looser rules override [`Self::validate_char`] and / or
66    /// this method wholesale.
67    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        // First char must be a letter or underscore so the name is a
78        // well-formed identifier even when it lives in generated code.
79        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// -----------------------------------------------------------------------
91// The macro that stamps out concrete name newtypes.
92// -----------------------------------------------------------------------
93
94/// Declare a validated name newtype that implements [`Name`].
95///
96/// Used across the crate wherever a validated ASCII-identifier newtype
97/// is needed (parameter names, element names, label keys, port names,
98/// …). Each invocation produces a newtype with:
99///
100/// - `new`, `as_str`, `into_inner`
101/// - `Display`, `Debug`, `PartialEq`/`Eq`/`Hash`/`Ord`/`PartialOrd`
102/// - serde `Serialize` / `Deserialize` as a transparent string
103/// - `FromStr`, `TryFrom<&str>`, `TryFrom<String>`
104#[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            /// Construct a new $Name, validating the candidate.
120            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            /// Borrow the inner string.
127            #[must_use]
128            pub fn as_str(&self) -> &str {
129                &self.0
130            }
131
132            /// Consume the name and return the inner string.
133            #[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
198// -----------------------------------------------------------------------
199// Concrete name types.
200//
201// Further domain-specific names (AxisName, PortName, LabelKey, TagKey,
202// FacetKey, ExportName, ...) land here in later crates or later commits,
203// but the canonical ones shared across the whole system stay in core.
204// -----------------------------------------------------------------------
205
206name_type! {
207    /// Name of a parameter within the scope of an element.
208    ///
209    /// Parameter names are scoped to their owning element — two elements
210    /// may share a parameter name without conflict. See SRD-0004.
211    pub struct ParameterName { kind: "ParameterName" }
212}
213
214name_type! {
215    /// Name of an element within a test plan.
216    ///
217    /// Element names are unique within a plan. See SRD-0007.
218    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        // This is a compile-time check disguised as a test: uncomment
277        // the assertion below and the build fails because the two are
278        // different types. We keep the test here to document intent.
279        let _p = ParameterName::new("x").unwrap();
280        let _e = ElementName::new("x").unwrap();
281        // assert_eq!(_p, _e);  // would not compile
282    }
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    // Dead-simple property: round-trip via Display.
300    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