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 '{value}' contains invalid characters. Only alphanumeric, hyphens, and underscores allowed"
99 )));
100 }
101
102 Ok(())
103 }
104}
105
106impl fmt::Display for ArticleId {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 write!(f, "{}", self.0)
109 }
110}
111
112impl TryFrom<&str> for ArticleId {
113 type Error = crate::error::AllSourceError;
114
115 fn try_from(value: &str) -> Result<Self> {
116 ArticleId::new(value.to_string())
117 }
118}
119
120impl TryFrom<String> for ArticleId {
121 type Error = crate::error::AllSourceError;
122
123 fn try_from(value: String) -> Result<Self> {
124 ArticleId::new(value)
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn test_create_valid_article_ids() {
134 let article_id = ArticleId::new("article123".to_string());
136 assert!(article_id.is_ok());
137 assert_eq!(article_id.unwrap().as_str(), "article123");
138
139 let article_id = ArticleId::new("my-awesome-article".to_string());
141 assert!(article_id.is_ok());
142
143 let article_id = ArticleId::new("my_awesome_article".to_string());
145 assert!(article_id.is_ok());
146
147 let article_id = ArticleId::new("MyAwesomeArticle".to_string());
149 assert!(article_id.is_ok());
150
151 let article_id = ArticleId::new("how-to-scale-to-1M-users_2024".to_string());
153 assert!(article_id.is_ok());
154 }
155
156 #[test]
157 fn test_reject_empty_article_id() {
158 let result = ArticleId::new(String::new());
159 assert!(result.is_err());
160
161 if let Err(e) = result {
162 assert!(e.to_string().contains("cannot be empty"));
163 }
164 }
165
166 #[test]
167 fn test_reject_too_long_article_id() {
168 let long_id = "a".repeat(257);
170 let result = ArticleId::new(long_id);
171 assert!(result.is_err());
172
173 if let Err(e) = result {
174 assert!(e.to_string().contains("cannot exceed 256 characters"));
175 }
176 }
177
178 #[test]
179 fn test_accept_max_length_article_id() {
180 let max_id = "a".repeat(256);
182 let result = ArticleId::new(max_id);
183 assert!(result.is_ok());
184 }
185
186 #[test]
187 fn test_reject_invalid_characters() {
188 let result = ArticleId::new("article 123".to_string());
190 assert!(result.is_err());
191
192 let result = ArticleId::new("article@123".to_string());
194 assert!(result.is_err());
195
196 let result = ArticleId::new("article.123".to_string());
197 assert!(result.is_err());
198
199 let result = ArticleId::new("article/123".to_string());
200 assert!(result.is_err());
201
202 let result = ArticleId::new("article?query=1".to_string());
203 assert!(result.is_err());
204 }
205
206 #[test]
207 fn test_display_trait() {
208 let article_id = ArticleId::new("test-article".to_string()).unwrap();
209 assert_eq!(format!("{article_id}"), "test-article");
210 }
211
212 #[test]
213 fn test_try_from_str() {
214 let article_id: Result<ArticleId> = "valid-article".try_into();
215 assert!(article_id.is_ok());
216 assert_eq!(article_id.unwrap().as_str(), "valid-article");
217
218 let invalid: Result<ArticleId> = "".try_into();
219 assert!(invalid.is_err());
220 }
221
222 #[test]
223 fn test_try_from_string() {
224 let article_id: Result<ArticleId> = "valid-article".to_string().try_into();
225 assert!(article_id.is_ok());
226
227 let invalid: Result<ArticleId> = String::new().try_into();
228 assert!(invalid.is_err());
229 }
230
231 #[test]
232 fn test_into_inner() {
233 let article_id = ArticleId::new("test-article".to_string()).unwrap();
234 let inner = article_id.into_inner();
235 assert_eq!(inner, "test-article");
236 }
237
238 #[test]
239 fn test_starts_with() {
240 let article_id = ArticleId::new("kubernetes-tutorial".to_string()).unwrap();
241 assert!(article_id.starts_with("kubernetes"));
242 assert!(!article_id.starts_with("docker"));
243 }
244
245 #[test]
246 fn test_ends_with() {
247 let article_id = ArticleId::new("kubernetes-tutorial".to_string()).unwrap();
248 assert!(article_id.ends_with("tutorial"));
249 assert!(!article_id.ends_with("guide"));
250 }
251
252 #[test]
253 fn test_equality() {
254 let id1 = ArticleId::new("article-a".to_string()).unwrap();
255 let id2 = ArticleId::new("article-a".to_string()).unwrap();
256 let id3 = ArticleId::new("article-b".to_string()).unwrap();
257
258 assert_eq!(id1, id2);
260 assert_ne!(id1, id3);
261 }
262
263 #[test]
264 fn test_cloning() {
265 let id1 = ArticleId::new("article".to_string()).unwrap();
266 let id2 = id1.clone();
267 assert_eq!(id1, id2);
268 }
269
270 #[test]
271 fn test_hash_consistency() {
272 use std::collections::HashSet;
273
274 let id1 = ArticleId::new("article".to_string()).unwrap();
275 let id2 = ArticleId::new("article".to_string()).unwrap();
276
277 let mut set = HashSet::new();
278 set.insert(id1);
279
280 assert!(set.contains(&id2));
282 }
283
284 #[test]
285 fn test_serde_serialization() {
286 let article_id = ArticleId::new("test-article".to_string()).unwrap();
287
288 let json = serde_json::to_string(&article_id).unwrap();
290 assert_eq!(json, "\"test-article\"");
291
292 let deserialized: ArticleId = serde_json::from_str(&json).unwrap();
294 assert_eq!(deserialized, article_id);
295 }
296
297 #[test]
298 fn test_new_unchecked() {
299 let article_id = ArticleId::new_unchecked("invalid chars!@#".to_string());
301 assert_eq!(article_id.as_str(), "invalid chars!@#");
302 }
303}