allsource_core/domain/value_objects/
article_id.rs1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct ArticleId(String);
24
25impl ArticleId {
26 pub fn new(value: String) -> Result<Self> {
42 Self::validate(&value)?;
43 Ok(Self(value))
44 }
45
46 pub(crate) fn new_unchecked(value: String) -> Self {
52 Self(value)
53 }
54
55 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59
60 pub fn into_inner(self) -> String {
62 self.0
63 }
64
65 pub fn starts_with(&self, prefix: &str) -> bool {
67 self.0.starts_with(prefix)
68 }
69
70 pub fn ends_with(&self, suffix: &str) -> bool {
72 self.0.ends_with(suffix)
73 }
74
75 fn validate(value: &str) -> Result<()> {
77 if value.is_empty() {
79 return Err(crate::error::AllSourceError::InvalidInput(
80 "Article ID cannot be empty".to_string(),
81 ));
82 }
83
84 if value.len() > 256 {
86 return Err(crate::error::AllSourceError::InvalidInput(format!(
87 "Article ID cannot exceed 256 characters, got {}",
88 value.len()
89 )));
90 }
91
92 if !value
94 .chars()
95 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
96 {
97 return Err(crate::error::AllSourceError::InvalidInput(format!(
98 "Article ID '{}' contains invalid characters. Only alphanumeric, hyphens, and underscores allowed",
99 value
100 )));
101 }
102
103 Ok(())
104 }
105}
106
107impl fmt::Display for ArticleId {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 write!(f, "{}", self.0)
110 }
111}
112
113impl TryFrom<&str> for ArticleId {
114 type Error = crate::error::AllSourceError;
115
116 fn try_from(value: &str) -> Result<Self> {
117 ArticleId::new(value.to_string())
118 }
119}
120
121impl TryFrom<String> for ArticleId {
122 type Error = crate::error::AllSourceError;
123
124 fn try_from(value: String) -> Result<Self> {
125 ArticleId::new(value)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_create_valid_article_ids() {
135 let article_id = ArticleId::new("article123".to_string());
137 assert!(article_id.is_ok());
138 assert_eq!(article_id.unwrap().as_str(), "article123");
139
140 let article_id = ArticleId::new("my-awesome-article".to_string());
142 assert!(article_id.is_ok());
143
144 let article_id = ArticleId::new("my_awesome_article".to_string());
146 assert!(article_id.is_ok());
147
148 let article_id = ArticleId::new("MyAwesomeArticle".to_string());
150 assert!(article_id.is_ok());
151
152 let article_id = ArticleId::new("how-to-scale-to-1M-users_2024".to_string());
154 assert!(article_id.is_ok());
155 }
156
157 #[test]
158 fn test_reject_empty_article_id() {
159 let result = ArticleId::new("".to_string());
160 assert!(result.is_err());
161
162 if let Err(e) = result {
163 assert!(e.to_string().contains("cannot be empty"));
164 }
165 }
166
167 #[test]
168 fn test_reject_too_long_article_id() {
169 let long_id = "a".repeat(257);
171 let result = ArticleId::new(long_id);
172 assert!(result.is_err());
173
174 if let Err(e) = result {
175 assert!(e.to_string().contains("cannot exceed 256 characters"));
176 }
177 }
178
179 #[test]
180 fn test_accept_max_length_article_id() {
181 let max_id = "a".repeat(256);
183 let result = ArticleId::new(max_id);
184 assert!(result.is_ok());
185 }
186
187 #[test]
188 fn test_reject_invalid_characters() {
189 let result = ArticleId::new("article 123".to_string());
191 assert!(result.is_err());
192
193 let result = ArticleId::new("article@123".to_string());
195 assert!(result.is_err());
196
197 let result = ArticleId::new("article.123".to_string());
198 assert!(result.is_err());
199
200 let result = ArticleId::new("article/123".to_string());
201 assert!(result.is_err());
202
203 let result = ArticleId::new("article?query=1".to_string());
204 assert!(result.is_err());
205 }
206
207 #[test]
208 fn test_display_trait() {
209 let article_id = ArticleId::new("test-article".to_string()).unwrap();
210 assert_eq!(format!("{}", article_id), "test-article");
211 }
212
213 #[test]
214 fn test_try_from_str() {
215 let article_id: Result<ArticleId> = "valid-article".try_into();
216 assert!(article_id.is_ok());
217 assert_eq!(article_id.unwrap().as_str(), "valid-article");
218
219 let invalid: Result<ArticleId> = "".try_into();
220 assert!(invalid.is_err());
221 }
222
223 #[test]
224 fn test_try_from_string() {
225 let article_id: Result<ArticleId> = "valid-article".to_string().try_into();
226 assert!(article_id.is_ok());
227
228 let invalid: Result<ArticleId> = String::new().try_into();
229 assert!(invalid.is_err());
230 }
231
232 #[test]
233 fn test_into_inner() {
234 let article_id = ArticleId::new("test-article".to_string()).unwrap();
235 let inner = article_id.into_inner();
236 assert_eq!(inner, "test-article");
237 }
238
239 #[test]
240 fn test_starts_with() {
241 let article_id = ArticleId::new("kubernetes-tutorial".to_string()).unwrap();
242 assert!(article_id.starts_with("kubernetes"));
243 assert!(!article_id.starts_with("docker"));
244 }
245
246 #[test]
247 fn test_ends_with() {
248 let article_id = ArticleId::new("kubernetes-tutorial".to_string()).unwrap();
249 assert!(article_id.ends_with("tutorial"));
250 assert!(!article_id.ends_with("guide"));
251 }
252
253 #[test]
254 fn test_equality() {
255 let id1 = ArticleId::new("article-a".to_string()).unwrap();
256 let id2 = ArticleId::new("article-a".to_string()).unwrap();
257 let id3 = ArticleId::new("article-b".to_string()).unwrap();
258
259 assert_eq!(id1, id2);
261 assert_ne!(id1, id3);
262 }
263
264 #[test]
265 fn test_cloning() {
266 let id1 = ArticleId::new("article".to_string()).unwrap();
267 let id2 = id1.clone();
268 assert_eq!(id1, id2);
269 }
270
271 #[test]
272 fn test_hash_consistency() {
273 use std::collections::HashSet;
274
275 let id1 = ArticleId::new("article".to_string()).unwrap();
276 let id2 = ArticleId::new("article".to_string()).unwrap();
277
278 let mut set = HashSet::new();
279 set.insert(id1);
280
281 assert!(set.contains(&id2));
283 }
284
285 #[test]
286 fn test_serde_serialization() {
287 let article_id = ArticleId::new("test-article".to_string()).unwrap();
288
289 let json = serde_json::to_string(&article_id).unwrap();
291 assert_eq!(json, "\"test-article\"");
292
293 let deserialized: ArticleId = serde_json::from_str(&json).unwrap();
295 assert_eq!(deserialized, article_id);
296 }
297
298 #[test]
299 fn test_new_unchecked() {
300 let article_id = ArticleId::new_unchecked("invalid chars!@#".to_string());
302 assert_eq!(article_id.as_str(), "invalid chars!@#");
303 }
304}