allsource_core/domain/value_objects/
projection_name.rs1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct ProjectionName(String);
25
26impl ProjectionName {
27 pub fn new(value: String) -> Result<Self> {
43 Self::validate(&value)?;
44 Ok(Self(value))
45 }
46
47 pub(crate) fn new_unchecked(value: String) -> Self {
53 Self(value)
54 }
55
56 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60
61 pub fn into_inner(self) -> String {
63 self.0
64 }
65
66 pub fn starts_with(&self, prefix: &str) -> bool {
79 self.0.starts_with(prefix)
80 }
81
82 pub fn ends_with(&self, suffix: &str) -> bool {
95 self.0.ends_with(suffix)
96 }
97
98 pub fn contains(&self, pattern: &str) -> bool {
100 self.0.contains(pattern)
101 }
102
103 fn validate(value: &str) -> Result<()> {
105 if value.is_empty() {
107 return Err(crate::error::AllSourceError::InvalidInput(
108 "Projection name cannot be empty".to_string(),
109 ));
110 }
111
112 if value.len() > 100 {
114 return Err(crate::error::AllSourceError::InvalidInput(format!(
115 "Projection name cannot exceed 100 characters, got {}",
116 value.len()
117 )));
118 }
119
120 if !value
122 .chars()
123 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
124 {
125 return Err(crate::error::AllSourceError::InvalidInput(format!(
126 "Projection name '{value}' must be alphanumeric with hyphens or underscores"
127 )));
128 }
129
130 if value.starts_with('-')
132 || value.starts_with('_')
133 || value.ends_with('-')
134 || value.ends_with('_')
135 {
136 return Err(crate::error::AllSourceError::InvalidInput(format!(
137 "Projection name '{value}' cannot start or end with hyphen or underscore"
138 )));
139 }
140
141 Ok(())
142 }
143}
144
145impl fmt::Display for ProjectionName {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 write!(f, "{}", self.0)
148 }
149}
150
151impl TryFrom<&str> for ProjectionName {
152 type Error = crate::error::AllSourceError;
153
154 fn try_from(value: &str) -> Result<Self> {
155 ProjectionName::new(value.to_string())
156 }
157}
158
159impl TryFrom<String> for ProjectionName {
160 type Error = crate::error::AllSourceError;
161
162 fn try_from(value: String) -> Result<Self> {
163 ProjectionName::new(value)
164 }
165}
166
167impl AsRef<str> for ProjectionName {
168 fn as_ref(&self) -> &str {
169 &self.0
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_create_valid_names() {
179 let name = ProjectionName::new("user_snapshot".to_string());
181 assert!(name.is_ok());
182 assert_eq!(name.unwrap().as_str(), "user_snapshot");
183
184 let name = ProjectionName::new("event-counter".to_string());
186 assert!(name.is_ok());
187
188 let name = ProjectionName::new("projection123".to_string());
190 assert!(name.is_ok());
191
192 let name = ProjectionName::new("user-account_snapshot123".to_string());
194 assert!(name.is_ok());
195 }
196
197 #[test]
198 fn test_reject_empty_name() {
199 let result = ProjectionName::new(String::new());
200 assert!(result.is_err());
201
202 if let Err(e) = result {
203 assert!(e.to_string().contains("cannot be empty"));
204 }
205 }
206
207 #[test]
208 fn test_reject_too_long_name() {
209 let long_name = "a".repeat(101);
210 let result = ProjectionName::new(long_name);
211 assert!(result.is_err());
212
213 if let Err(e) = result {
214 assert!(e.to_string().contains("cannot exceed 100 characters"));
215 }
216 }
217
218 #[test]
219 fn test_accept_max_length_name() {
220 let max_name = "a".repeat(100);
221 let result = ProjectionName::new(max_name);
222 assert!(result.is_ok());
223 }
224
225 #[test]
226 fn test_reject_invalid_characters() {
227 let result = ProjectionName::new("user snapshot".to_string());
229 assert!(result.is_err());
230
231 let result = ProjectionName::new("user.snapshot".to_string());
233 assert!(result.is_err());
234
235 let result = ProjectionName::new("user:snapshot".to_string());
237 assert!(result.is_err());
238
239 let result = ProjectionName::new("user@snapshot".to_string());
241 assert!(result.is_err());
242 }
243
244 #[test]
245 fn test_reject_starting_with_special_char() {
246 let result = ProjectionName::new("-projection".to_string());
247 assert!(result.is_err());
248
249 let result = ProjectionName::new("_projection".to_string());
250 assert!(result.is_err());
251 }
252
253 #[test]
254 fn test_reject_ending_with_special_char() {
255 let result = ProjectionName::new("projection-".to_string());
256 assert!(result.is_err());
257
258 let result = ProjectionName::new("projection_".to_string());
259 assert!(result.is_err());
260 }
261
262 #[test]
263 fn test_starts_with() {
264 let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
265 assert!(name.starts_with("user"));
266 assert!(name.starts_with("user_"));
267 assert!(!name.starts_with("order"));
268 }
269
270 #[test]
271 fn test_ends_with() {
272 let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
273 assert!(name.ends_with("snapshot"));
274 assert!(name.ends_with("_snapshot"));
275 assert!(!name.ends_with("counter"));
276 }
277
278 #[test]
279 fn test_contains() {
280 let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
281 assert!(name.contains("user"));
282 assert!(name.contains("_"));
283 assert!(name.contains("snap"));
284 assert!(!name.contains("order"));
285 }
286
287 #[test]
288 fn test_display_trait() {
289 let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
290 assert_eq!(format!("{name}"), "user_snapshot");
291 }
292
293 #[test]
294 fn test_try_from_str() {
295 let name: Result<ProjectionName> = "user_snapshot".try_into();
296 assert!(name.is_ok());
297 assert_eq!(name.unwrap().as_str(), "user_snapshot");
298
299 let invalid: Result<ProjectionName> = "".try_into();
300 assert!(invalid.is_err());
301 }
302
303 #[test]
304 fn test_try_from_string() {
305 let name: Result<ProjectionName> = "event_counter".to_string().try_into();
306 assert!(name.is_ok());
307
308 let invalid: Result<ProjectionName> = String::new().try_into();
309 assert!(invalid.is_err());
310 }
311
312 #[test]
313 fn test_into_inner() {
314 let name = ProjectionName::new("test_projection".to_string()).unwrap();
315 let inner = name.into_inner();
316 assert_eq!(inner, "test_projection");
317 }
318
319 #[test]
320 fn test_equality() {
321 let name1 = ProjectionName::new("user_snapshot".to_string()).unwrap();
322 let name2 = ProjectionName::new("user_snapshot".to_string()).unwrap();
323 let name3 = ProjectionName::new("order_snapshot".to_string()).unwrap();
324
325 assert_eq!(name1, name2);
326 assert_ne!(name1, name3);
327 }
328
329 #[test]
330 fn test_cloning() {
331 let name1 = ProjectionName::new("test_projection".to_string()).unwrap();
332 let name2 = name1.clone();
333 assert_eq!(name1, name2);
334 }
335
336 #[test]
337 fn test_hash_consistency() {
338 use std::collections::HashSet;
339
340 let name1 = ProjectionName::new("user_snapshot".to_string()).unwrap();
341 let name2 = ProjectionName::new("user_snapshot".to_string()).unwrap();
342
343 let mut set = HashSet::new();
344 set.insert(name1);
345
346 assert!(set.contains(&name2));
347 }
348
349 #[test]
350 fn test_serde_serialization() {
351 let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
352
353 let json = serde_json::to_string(&name).unwrap();
355 assert_eq!(json, "\"user_snapshot\"");
356
357 let deserialized: ProjectionName = serde_json::from_str(&json).unwrap();
359 assert_eq!(deserialized, name);
360 }
361
362 #[test]
363 fn test_as_ref() {
364 let name = ProjectionName::new("test_projection".to_string()).unwrap();
365 let str_ref: &str = name.as_ref();
366 assert_eq!(str_ref, "test_projection");
367 }
368
369 #[test]
370 fn test_new_unchecked() {
371 let name = ProjectionName::new_unchecked("invalid name!".to_string());
373 assert_eq!(name.as_str(), "invalid name!");
374 }
375}