Skip to main content

allsource_core/domain/value_objects/
article_id.rs

1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Value Object: ArticleId
6///
7/// Represents a unique identifier for an article in the paywall system.
8/// Articles are identified by a slug or URL-safe identifier provided by the creator.
9///
10/// Domain Rules:
11/// - Cannot be empty
12/// - Must be between 1 and 256 characters
13/// - Must be URL-safe (alphanumeric, hyphens, underscores only)
14/// - Case-sensitive
15/// - Immutable once created
16///
17/// This is a Value Object:
18/// - Defined by its value, not identity
19/// - Immutable
20/// - Self-validating
21/// - Compared by value equality
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct ArticleId(String);
24
25impl ArticleId {
26    /// Create a new ArticleId with validation
27    ///
28    /// # Errors
29    /// Returns error if:
30    /// - ID is empty
31    /// - ID is longer than 256 characters
32    /// - ID contains invalid characters (only a-z, A-Z, 0-9, -, _ allowed)
33    ///
34    /// # Examples
35    /// ```
36    /// use allsource_core::domain::value_objects::ArticleId;
37    ///
38    /// let article_id = ArticleId::new("my-awesome-article".to_string()).unwrap();
39    /// assert_eq!(article_id.as_str(), "my-awesome-article");
40    /// ```
41    pub fn new(value: String) -> Result<Self> {
42        Self::validate(&value)?;
43        Ok(Self(value))
44    }
45
46    /// Create ArticleId without validation (for internal use, e.g., from trusted storage)
47    ///
48    /// # Safety
49    /// This bypasses validation. Only use when loading from trusted sources
50    /// where validation has already occurred.
51    pub(crate) fn new_unchecked(value: String) -> Self {
52        Self(value)
53    }
54
55    /// Get the string value
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59
60    /// Get the inner String (consumes self)
61    pub fn into_inner(self) -> String {
62        self.0
63    }
64
65    /// Check if this article ID starts with a specific prefix
66    pub fn starts_with(&self, prefix: &str) -> bool {
67        self.0.starts_with(prefix)
68    }
69
70    /// Check if this article ID ends with a specific suffix
71    pub fn ends_with(&self, suffix: &str) -> bool {
72        self.0.ends_with(suffix)
73    }
74
75    /// Validate an article ID string
76    fn validate(value: &str) -> Result<()> {
77        // Rule: Cannot be empty
78        if value.is_empty() {
79            return Err(crate::error::AllSourceError::InvalidInput(
80                "Article ID cannot be empty".to_string(),
81            ));
82        }
83
84        // Rule: Maximum length 256 characters
85        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        // Rule: URL-safe characters only (alphanumeric, hyphens, underscores)
93        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        // Simple alphanumeric
136        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        // With hyphens
141        let article_id = ArticleId::new("my-awesome-article".to_string());
142        assert!(article_id.is_ok());
143
144        // With underscores
145        let article_id = ArticleId::new("my_awesome_article".to_string());
146        assert!(article_id.is_ok());
147
148        // Mixed case
149        let article_id = ArticleId::new("MyAwesomeArticle".to_string());
150        assert!(article_id.is_ok());
151
152        // Complex format
153        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        // Create a 257-character string (exceeds max of 256)
170        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        // Exactly 256 characters should be OK
182        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        // Space is invalid
190        let result = ArticleId::new("article 123".to_string());
191        assert!(result.is_err());
192
193        // Special characters are invalid
194        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        // Value equality
260        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        // Should find the same value (value equality)
282        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        // Serialize
290        let json = serde_json::to_string(&article_id).unwrap();
291        assert_eq!(json, "\"test-article\"");
292
293        // Deserialize
294        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        // Should create without validation (for internal use)
301        let article_id = ArticleId::new_unchecked("invalid chars!@#".to_string());
302        assert_eq!(article_id.as_str(), "invalid chars!@#");
303    }
304}