Skip to main content

emergent_client/types/
primitive_name.rs

1//! Primitive name newtype for validated identifier strings.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt;
5use std::str::FromStr;
6
7/// A validated primitive name identifier.
8///
9/// Primitive names must be valid identifiers that can be used in
10/// filenames, environment variables, and configuration.
11/// Examples: "timer", "user_service", "email-handler"
12///
13/// # Validation Rules
14///
15/// - Cannot be empty
16/// - Must start with a lowercase letter
17/// - Contain only lowercase letters, digits, hyphens, underscores
18/// - Cannot exceed 64 characters
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct PrimitiveName(String);
21
22/// Error returned when creating an invalid primitive name.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum InvalidPrimitiveName {
25    /// Primitive name cannot be empty.
26    Empty,
27    /// Primitive name contains invalid characters.
28    InvalidCharacters { value: String },
29    /// Primitive name has invalid structure.
30    InvalidStructure { value: String, reason: &'static str },
31}
32
33impl fmt::Display for InvalidPrimitiveName {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::Empty => write!(f, "primitive name cannot be empty"),
37            Self::InvalidCharacters { value } => {
38                write!(f, "primitive name '{value}' contains invalid characters")
39            }
40            Self::InvalidStructure { value, reason } => {
41                write!(f, "primitive name '{value}' is invalid: {reason}")
42            }
43        }
44    }
45}
46
47impl std::error::Error for InvalidPrimitiveName {}
48
49impl PrimitiveName {
50    /// Maximum length for a primitive name.
51    pub const MAX_LENGTH: usize = 64;
52
53    /// Creates a new primitive name after validation.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the primitive name is invalid.
58    pub fn new(value: impl Into<String>) -> Result<Self, InvalidPrimitiveName> {
59        let value = value.into();
60
61        if value.is_empty() {
62            return Err(InvalidPrimitiveName::Empty);
63        }
64
65        if value.len() > Self::MAX_LENGTH {
66            return Err(InvalidPrimitiveName::InvalidStructure {
67                value,
68                reason: "cannot exceed 64 characters",
69            });
70        }
71
72        let mut chars = value.chars();
73        if let Some(first) = chars.next()
74            && !first.is_ascii_lowercase()
75        {
76            return Err(InvalidPrimitiveName::InvalidStructure {
77                value,
78                reason: "must start with lowercase letter",
79            });
80        }
81
82        if !value
83            .chars()
84            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
85        {
86            return Err(InvalidPrimitiveName::InvalidCharacters { value });
87        }
88
89        Ok(Self(value))
90    }
91
92    /// Returns the primitive name as a string slice.
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97
98    /// Returns true if this is the default "unknown" value.
99    ///
100    /// This is used to check if a source has been explicitly set.
101    #[must_use]
102    pub fn is_default(&self) -> bool {
103        self.0 == "unknown"
104    }
105}
106
107impl fmt::Display for PrimitiveName {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "{}", self.0)
110    }
111}
112
113impl FromStr for PrimitiveName {
114    type Err = InvalidPrimitiveName;
115
116    fn from_str(s: &str) -> Result<Self, Self::Err> {
117        Self::new(s)
118    }
119}
120
121impl AsRef<str> for PrimitiveName {
122    fn as_ref(&self) -> &str {
123        &self.0
124    }
125}
126
127impl Serialize for PrimitiveName {
128    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
129    where
130        S: Serializer,
131    {
132        self.0.serialize(serializer)
133    }
134}
135
136impl<'de> Deserialize<'de> for PrimitiveName {
137    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
138    where
139        D: Deserializer<'de>,
140    {
141        let s = String::deserialize(deserializer)?;
142        Self::new(s).map_err(serde::de::Error::custom)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn valid_primitive_names() {
152        assert!(PrimitiveName::new("timer").is_ok());
153        assert!(PrimitiveName::new("user_service").is_ok());
154        assert!(PrimitiveName::new("email-handler").is_ok());
155        assert!(PrimitiveName::new("filter123").is_ok());
156        assert!(PrimitiveName::new("my_handler_v2").is_ok());
157    }
158
159    #[test]
160    fn empty_is_invalid() {
161        let result = PrimitiveName::new("");
162        assert!(matches!(result, Err(InvalidPrimitiveName::Empty)));
163    }
164
165    #[test]
166    fn starting_with_digit_is_invalid() {
167        let result = PrimitiveName::new("1invalid");
168        assert!(matches!(
169            result,
170            Err(InvalidPrimitiveName::InvalidStructure { .. })
171        ));
172    }
173
174    #[test]
175    fn starting_with_uppercase_is_invalid() {
176        let result = PrimitiveName::new("Invalid");
177        assert!(matches!(
178            result,
179            Err(InvalidPrimitiveName::InvalidStructure { .. })
180        ));
181    }
182
183    #[test]
184    fn uppercase_in_middle_is_invalid() {
185        let result = PrimitiveName::new("inValid");
186        assert!(matches!(
187            result,
188            Err(InvalidPrimitiveName::InvalidCharacters { .. })
189        ));
190    }
191
192    #[test]
193    fn too_long_is_invalid() {
194        let result = PrimitiveName::new("a".repeat(65));
195        assert!(matches!(
196            result,
197            Err(InvalidPrimitiveName::InvalidStructure { .. })
198        ));
199    }
200
201    #[test]
202    fn max_length_is_valid() {
203        let result = PrimitiveName::new("a".repeat(64));
204        assert!(result.is_ok());
205    }
206
207    #[test]
208    fn special_characters_invalid() {
209        assert!(PrimitiveName::new("has space").is_err());
210        assert!(PrimitiveName::new("has.dot").is_err());
211        assert!(PrimitiveName::new("has@at").is_err());
212    }
213
214    #[test]
215    fn serde_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
216        let name = PrimitiveName::new("timer")?;
217        let json = serde_json::to_string(&name)?;
218        let restored: PrimitiveName = serde_json::from_str(&json)?;
219        assert_eq!(name, restored);
220        Ok(())
221    }
222
223    #[test]
224    fn from_str_works() -> Result<(), InvalidPrimitiveName> {
225        let name: PrimitiveName = "timer".parse()?;
226        assert_eq!(name.as_str(), "timer");
227        Ok(())
228    }
229}