Skip to main content

rfham_core/
names.rs

1//! Validated string identifier types and the [`StringLike`] trait.
2//!
3//! Three newtype wrappers enforce invariants on string values used as identifiers:
4//!
5//! | Type | Max length | Allowed content |
6//! |------|-----------|-----------------|
7//! | [`Name`] | 32 | Starts with ASCII letter; then `[a-zA-Z0-9_-]`; normalised to lowercase on parse |
8//! | [`DisplayName`] | 48 | Any string under the limit (human-readable labels) |
9//! | [`Tag`] | 24 | Non-empty; no whitespace |
10//!
11//! The [`name_fn!`] macro generates zero-cost `fn` accessors that return a typed [`Name`]
12//! literal, avoiding repeated `new_unchecked` calls at call sites.
13//!
14//! # Examples
15//!
16//! ```rust
17//! use rfham_core::id::{Name, StringLike};
18//! use std::str::FromStr;
19//!
20//! let n: Name = Name::from_str("Yaesu").unwrap();
21//! assert_eq!(n.as_str(), "yaesu"); // normalised to lowercase
22//! assert!(Name::is_valid("elecraft"));
23//! assert!(!Name::is_valid("9bad"));   // must start with a letter
24//! assert!(!Name::is_valid(""));
25//! ```
26//!
27//! Pre-defined brand-name accessors:
28//!
29//! ```rust
30//! use rfham_core::id::{brand_name_icom, brand_name_yaesu, StringLike};
31//!
32//! assert_eq!(brand_name_icom().as_str(), "icom");
33//! assert_eq!(brand_name_yaesu().as_str(), "yaesu");
34//! ```
35
36use crate::error::CoreError as Error;
37use core::{fmt::Display, hash::Hash, str::FromStr};
38use serde::{Deserialize, Serialize};
39
40// ------------------------------------------------------------------------------------------------
41// Public Macros
42// ------------------------------------------------------------------------------------------------
43
44#[macro_export]
45macro_rules! name_fn {
46    ($vis:vis $fn_name:ident => $name:literal) => {
47        #[inline(always)]
48        $vis fn $fn_name() -> $crate::names::Name {
49            $crate::names::Name::new_unchecked($name)
50        }
51    };
52}
53// ------------------------------------------------------------------------------------------------
54// Public Types
55// ------------------------------------------------------------------------------------------------
56
57pub trait StringLike:
58    Clone + Display + PartialEq + Eq + PartialOrd + Ord + Hash + FromStr + AsRef<str> + Into<String>
59{
60    const MAX_LENGTH: usize;
61
62    fn new_unchecked<S: Into<String>>(name: S) -> Self;
63    fn as_str(&self) -> &str;
64    fn is_valid(s: &str) -> bool;
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
68pub struct Name(String);
69
70#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
71pub struct DisplayName(String);
72
73#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
74pub struct Tag(String);
75
76pub const RFHAM_URN_PREFIX: &str = "urn:rfham:";
77
78// ------------------------------------------------------------------------------------------------
79// Public Functions
80// ------------------------------------------------------------------------------------------------
81
82name_fn!(pub brand_name_baofeng =>  "baofeng");
83name_fn!(pub brand_name_chameleon =>  "chameleon");
84name_fn!(pub brand_name_elecraft =>  "elecraft");
85name_fn!(pub brand_name_gabil =>  "gabil");
86name_fn!(pub brand_name_icom =>  "icom");
87name_fn!(pub brand_name_kenwood =>  "kenwood");
88name_fn!(pub brand_name_yaesu => "yaesu");
89
90// ------------------------------------------------------------------------------------------------
91// Implementations ❯ Name
92// ------------------------------------------------------------------------------------------------
93
94impl Display for Name {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "{}", self.0)
97    }
98}
99
100impl From<Name> for String {
101    fn from(value: Name) -> Self {
102        value.0
103    }
104}
105
106impl AsRef<str> for Name {
107    fn as_ref(&self) -> &str {
108        self.0.as_ref()
109    }
110}
111
112impl FromStr for Name {
113    type Err = Error;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        if Self::is_valid(s) {
117            Ok(Self(s.to_ascii_lowercase()))
118        } else {
119            Err(Error::InvalidValueFromStr(s.to_string(), "Name"))
120        }
121    }
122}
123
124impl StringLike for Name {
125    const MAX_LENGTH: usize = 32;
126    fn new_unchecked<S: Into<String>>(name: S) -> Self {
127        Self(name.into())
128    }
129
130    fn as_str(&self) -> &str {
131        self.0.as_str()
132    }
133
134    fn is_valid(s: &str) -> bool {
135        let mut chars = s.chars();
136        !s.is_empty()
137            && s.len() < Self::MAX_LENGTH
138            && chars.next().unwrap().is_ascii_alphabetic()
139            && chars.all(|c| c.is_ascii_alphanumeric() || ['-', '_'].contains(&c))
140    }
141}
142
143// ------------------------------------------------------------------------------------------------
144// Implementations ❯ DisplayName
145// ------------------------------------------------------------------------------------------------
146
147impl Display for DisplayName {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "{}", self.0)
150    }
151}
152
153impl From<DisplayName> for String {
154    fn from(value: DisplayName) -> Self {
155        value.0
156    }
157}
158
159impl AsRef<str> for DisplayName {
160    fn as_ref(&self) -> &str {
161        self.0.as_ref()
162    }
163}
164
165impl FromStr for DisplayName {
166    type Err = Error;
167
168    fn from_str(s: &str) -> Result<Self, Self::Err> {
169        if Self::is_valid(s) {
170            Ok(Self(s.to_ascii_lowercase()))
171        } else {
172            Err(Error::InvalidValueFromStr(s.to_string(), "DisplayName"))
173        }
174    }
175}
176
177impl StringLike for DisplayName {
178    const MAX_LENGTH: usize = 48;
179
180    fn new_unchecked<S: Into<String>>(display_name: S) -> Self {
181        Self(display_name.into())
182    }
183
184    fn as_str(&self) -> &str {
185        self.0.as_str()
186    }
187
188    fn is_valid(s: &str) -> bool {
189        s.len() < Self::MAX_LENGTH
190    }
191}
192
193// ------------------------------------------------------------------------------------------------
194// Implementations ❯ Tag
195// ------------------------------------------------------------------------------------------------
196
197impl Display for Tag {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(f, "{}", self.0)
200    }
201}
202
203impl From<Tag> for String {
204    fn from(value: Tag) -> Self {
205        value.0
206    }
207}
208
209impl AsRef<str> for Tag {
210    fn as_ref(&self) -> &str {
211        self.0.as_ref()
212    }
213}
214
215impl FromStr for Tag {
216    type Err = Error;
217
218    fn from_str(s: &str) -> Result<Self, Self::Err> {
219        if Self::is_valid(s) {
220            Ok(Self(s.to_ascii_lowercase()))
221        } else {
222            Err(Error::InvalidValueFromStr(s.to_string(), "Tag"))
223        }
224    }
225}
226
227impl StringLike for Tag {
228    const MAX_LENGTH: usize = 24;
229
230    fn new_unchecked<S: Into<String>>(tag: S) -> Self {
231        Self(tag.into())
232    }
233
234    fn as_str(&self) -> &str {
235        self.0.as_str()
236    }
237
238    fn is_valid(s: &str) -> bool {
239        !s.is_empty() && s.len() < Self::MAX_LENGTH && s.chars().all(|c| !c.is_whitespace())
240    }
241}
242
243// ------------------------------------------------------------------------------------------------
244// Unit Tests
245// ------------------------------------------------------------------------------------------------
246
247#[cfg(test)]
248mod tests {
249    use super::{DisplayName, Name, Tag};
250    use crate::names::StringLike;
251    use pretty_assertions::assert_eq;
252    use std::str::FromStr;
253
254    #[test]
255    fn name_valid_inputs() {
256        assert!(Name::is_valid("abc"));
257        assert!(Name::is_valid("a-b_c123"));
258        assert!(Name::is_valid("a")); // single char
259        assert!(Name::is_valid("elecraft"));
260    }
261
262    #[test]
263    fn name_invalid_inputs() {
264        assert!(!Name::is_valid("")); // empty
265        assert!(!Name::is_valid("1abc")); // starts with digit
266        assert!(!Name::is_valid("-abc")); // starts with dash
267        assert!(!Name::is_valid(&"a".repeat(32))); // at or beyond max length
268    }
269
270    #[test]
271    fn name_from_str_lowercases() {
272        let n = Name::from_str("Yaesu").unwrap();
273        assert_eq!(n.as_str(), "yaesu");
274        let n = Name::from_str("ICOM").unwrap();
275        assert_eq!(n.as_str(), "icom");
276    }
277
278    #[test]
279    fn name_from_str_invalid_returns_error() {
280        assert!("9bad".parse::<Name>().is_err());
281        assert!("".parse::<Name>().is_err());
282    }
283
284    #[test]
285    fn display_name_valid() {
286        assert!(DisplayName::is_valid("Yaesu FT-991A"));
287        assert!(DisplayName::is_valid("")); // empty is valid for DisplayName
288    }
289
290    #[test]
291    fn display_name_too_long_is_invalid() {
292        assert!(!DisplayName::is_valid(&"x".repeat(48)));
293    }
294
295    #[test]
296    fn tag_valid_no_whitespace() {
297        assert!(Tag::is_valid("contest"));
298        assert!(Tag::is_valid("dx"));
299        assert!(Tag::is_valid("sota-activation"));
300    }
301
302    #[test]
303    fn tag_invalid_inputs() {
304        assert!(!Tag::is_valid("")); // empty
305        assert!(!Tag::is_valid("has space")); // contains whitespace
306        assert!(!Tag::is_valid("tab\there")); // tab counts as whitespace
307        assert!(!Tag::is_valid(&"x".repeat(24))); // at max length
308    }
309}