nntp_proxy/types/
protocol.rs

1//! Protocol-related type-safe wrappers for NNTP primitives
2
3use serde::{Deserialize, Serialize};
4use std::borrow::{Borrow, Cow};
5use std::fmt;
6use std::str::FromStr;
7
8use super::ValidationError;
9
10/// A validated NNTP message ID
11///
12/// Message IDs in NNTP must be enclosed in angle brackets per RFC 3977 Section 3.6.
13/// This type ensures message IDs are always properly formatted.
14///
15/// # Format
16/// - Must start with '<' and end with '>'
17/// - Cannot be empty (must contain at least 3 characters: `<x>`)
18/// - Example: `<12345@example.com>`
19///
20/// # Performance
21/// Uses `Cow<'a, str>` to support both zero-copy parsing (borrowed) and owned storage.
22/// - Parser creates `MessageId<'a>` with `Cow::Borrowed` for zero-copy performance
23/// - Cache/storage converts to `MessageId<'static>` with `Cow::Owned` for persistence
24///
25/// # See Also
26/// - [`from_borrowed`](Self::from_borrowed) for zero-copy parsing
27/// - [`extract_from_command`](Self::extract_from_command) for parsing from NNTP commands
28#[doc(alias = "msgid")]
29#[doc(alias = "article_id")]
30#[doc(alias = "message_identifier")]
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
32pub struct MessageId<'a>(Cow<'a, str>);
33
34impl<'a> MessageId<'a> {
35    /// Create a new owned MessageId from a String, validating the format
36    ///
37    /// # Examples
38    /// ```
39    /// use nntp_proxy::types::MessageId;
40    ///
41    /// let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
42    /// assert_eq!(msgid.as_str(), "<12345@example.com>");
43    ///
44    /// // Invalid: missing angle brackets
45    /// assert!(MessageId::new("12345@example.com".to_string()).is_err());
46    /// ```
47    pub fn new(s: String) -> Result<Self, ValidationError> {
48        if s.len() < 3 {
49            return Err(ValidationError::InvalidMessageId(
50                "Message ID too short (minimum 3 characters)".to_string(),
51            ));
52        }
53        if !s.starts_with('<') || !s.ends_with('>') {
54            return Err(ValidationError::InvalidMessageId(
55                "Message ID must be enclosed in angle brackets".to_string(),
56            ));
57        }
58        Ok(Self(Cow::Owned(s)))
59    }
60
61    /// Create a borrowed MessageId from a string slice, validating the format (zero-copy).
62    ///
63    /// This is the fastest way to create a MessageId when you have a borrowed string.
64    /// No allocation occurs - the MessageId borrows from the input.
65    ///
66    /// # Performance
67    /// **Zero-copy**: This method does NOT allocate. The returned MessageId borrows from `s`.
68    /// Use this in parsers for maximum performance.
69    ///
70    /// # Examples
71    /// ```
72    /// use nntp_proxy::types::MessageId;
73    ///
74    /// let msgid = MessageId::from_borrowed("<12345@example.com>").unwrap();
75    /// assert_eq!(msgid.as_str(), "<12345@example.com>");
76    /// ```
77    #[inline]
78    pub fn from_borrowed(s: &'a str) -> Result<Self, ValidationError> {
79        if s.len() < 3 {
80            return Err(ValidationError::InvalidMessageId(
81                "Message ID too short (minimum 3 characters)".to_string(),
82            ));
83        }
84        if !s.starts_with('<') || !s.ends_with('>') {
85            return Err(ValidationError::InvalidMessageId(
86                "Message ID must be enclosed in angle brackets".to_string(),
87            ));
88        }
89        Ok(Self(Cow::Borrowed(s)))
90    }
91
92    /// Create a borrowed MessageId from a pre-validated string slice (zero-copy, unchecked).
93    ///
94    /// # Safety
95    /// The caller MUST ensure that:
96    /// - `s.len() >= 3`
97    /// - `s.starts_with('<')`
98    /// - `s.ends_with('>')`
99    ///
100    /// This is used by the parser after it has already validated these conditions
101    /// to avoid redundant checks.
102    ///
103    /// # Performance
104    /// **Zero-copy**: This is the absolute fastest way to create a MessageId.
105    /// No allocation, no validation overhead.
106    #[inline(always)]
107    pub unsafe fn from_str_unchecked(s: &'a str) -> Self {
108        Self(Cow::Borrowed(s))
109    }
110
111    /// Create an owned MessageId from a string, automatically adding angle brackets if needed
112    ///
113    /// # Examples
114    /// ```
115    /// use nntp_proxy::types::MessageId;
116    ///
117    /// let msgid = MessageId::from_str_or_wrap("12345@example.com").unwrap();
118    /// assert_eq!(msgid.as_str(), "<12345@example.com>");
119    ///
120    /// let msgid2 = MessageId::from_str_or_wrap("<12345@example.com>").unwrap();
121    /// assert_eq!(msgid2.as_str(), "<12345@example.com>");
122    /// ```
123    pub fn from_str_or_wrap(s: impl AsRef<str>) -> Result<MessageId<'static>, ValidationError> {
124        let s = s.as_ref();
125        if s.is_empty() {
126            return Err(ValidationError::InvalidMessageId(
127                "Message ID cannot be empty".to_string(),
128            ));
129        }
130
131        let wrapped = if s.starts_with('<') && s.ends_with('>') {
132            s.to_string()
133        } else {
134            format!("<{}>", s)
135        };
136
137        MessageId::new(wrapped)
138    }
139
140    /// Get the message ID as a string slice
141    #[must_use]
142    #[inline]
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146
147    /// Get the message ID without angle brackets
148    ///
149    /// # Examples
150    /// ```
151    /// use nntp_proxy::types::MessageId;
152    ///
153    /// let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
154    /// assert_eq!(msgid.without_brackets(), "12345@example.com");
155    /// ```
156    #[must_use]
157    #[inline]
158    pub fn without_brackets(&self) -> &str {
159        let s: &str = &self.0;
160        &s[1..s.len() - 1]
161    }
162
163    /// Extract a message ID from an NNTP command line (returns owned MessageId)
164    ///
165    /// Looks for a message ID (text enclosed in angle brackets) in the command.
166    /// Ensures '<' comes before '>' to form a proper pair.
167    ///
168    /// # Examples
169    /// ```
170    /// use nntp_proxy::types::MessageId;
171    ///
172    /// let msgid = MessageId::extract_from_command("ARTICLE <12345@example.com>").unwrap();
173    /// assert_eq!(msgid.as_str(), "<12345@example.com>");
174    /// ```
175    pub fn extract_from_command(command: &str) -> Option<MessageId<'static>> {
176        let start = command.find('<')?;
177        // Search for '>' only after the '<' position (end is relative to slice start)
178        let end = command[start..].find('>')?;
179        // Include the '>' character: start + end gives position of '>', +1 for exclusive end
180        MessageId::new(command[start..=start + end].to_string()).ok()
181    }
182
183    /// Converts this `MessageId` into an owned `MessageId<'static>`, consuming `self`.
184    ///
185    /// This method is useful when you need to store a `MessageId` beyond the lifetime of the input string.
186    ///
187    /// Consumes `self` and always returns an owned `MessageId<'static>`. If the underlying data is already owned,
188    /// this will not allocate, but will still call `into_owned()` on the inner `Cow`.
189    pub fn into_owned(self) -> MessageId<'static> {
190        MessageId(Cow::Owned(self.0.into_owned()))
191    }
192
193    /// Creates an owned `MessageId<'static>` by cloning the data if necessary.
194    ///
195    /// This method borrows `self` and returns an owned `MessageId<'static>`. If the underlying data is already owned,
196    /// this is a cheap clone. Otherwise, it allocates and copies the data.
197    pub fn to_owned(&self) -> MessageId<'static> {
198        MessageId(Cow::Owned(self.0.clone().into_owned()))
199    }
200}
201
202impl FromStr for MessageId<'static> {
203    type Err = ValidationError;
204
205    fn from_str(s: &str) -> Result<Self, Self::Err> {
206        MessageId::new(s.to_string())
207    }
208}
209
210impl<'a> AsRef<str> for MessageId<'a> {
211    fn as_ref(&self) -> &str {
212        &self.0
213    }
214}
215
216impl<'a> std::ops::Deref for MessageId<'a> {
217    type Target = str;
218
219    #[inline]
220    fn deref(&self) -> &Self::Target {
221        &self.0
222    }
223}
224
225impl<'a> Borrow<str> for MessageId<'a> {
226    fn borrow(&self) -> &str {
227        &self.0
228    }
229}
230
231impl<'a> fmt::Display for MessageId<'a> {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(f, "{}", self.0)
234    }
235}
236
237impl TryFrom<String> for MessageId<'static> {
238    type Error = ValidationError;
239
240    fn try_from(s: String) -> Result<Self, Self::Error> {
241        MessageId::new(s)
242    }
243}
244
245impl<'a> From<MessageId<'a>> for String {
246    fn from(msgid: MessageId<'a>) -> Self {
247        msgid.0.into_owned()
248    }
249}
250
251impl<'a> Serialize for MessageId<'a> {
252    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
253    where
254        S: serde::Serializer,
255    {
256        serializer.serialize_str(&self.0)
257    }
258}
259
260impl<'de> Deserialize<'de> for MessageId<'static> {
261    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262    where
263        D: serde::Deserializer<'de>,
264    {
265        let s = String::deserialize(deserializer)?;
266        MessageId::new(s).map_err(serde::de::Error::custom)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_valid_message_id() {
276        let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
277        assert_eq!(msgid.as_str(), "<12345@example.com>");
278        assert_eq!(msgid.without_brackets(), "12345@example.com");
279    }
280
281    #[test]
282    fn test_complex_message_id() {
283        let msgid =
284            MessageId::new("<very-long-id.123.abc@subdomain.example.org>".to_string()).unwrap();
285        assert_eq!(
286            msgid.without_brackets(),
287            "very-long-id.123.abc@subdomain.example.org"
288        );
289    }
290
291    #[test]
292    fn test_message_id_without_brackets_rejected() {
293        let result = MessageId::new("12345@example.com".to_string());
294        assert!(result.is_err());
295    }
296
297    #[test]
298    fn test_message_id_missing_start_bracket() {
299        let result = MessageId::new("12345@example.com>".to_string());
300        assert!(result.is_err());
301    }
302
303    #[test]
304    fn test_message_id_missing_end_bracket() {
305        let result = MessageId::new("<12345@example.com".to_string());
306        assert!(result.is_err());
307    }
308
309    #[test]
310    fn test_empty_message_id() {
311        let result = MessageId::new("".to_string());
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn test_message_id_too_short() {
317        let result = MessageId::new("<>".to_string());
318        assert!(result.is_err());
319    }
320
321    #[test]
322    fn test_minimal_valid_message_id() {
323        let msgid = MessageId::new("<x>".to_string()).unwrap();
324        assert_eq!(msgid.without_brackets(), "x");
325    }
326
327    #[test]
328    fn test_from_str_or_wrap_with_brackets() {
329        let msgid = MessageId::from_str_or_wrap("<12345@example.com>").unwrap();
330        assert_eq!(msgid.as_str(), "<12345@example.com>");
331    }
332
333    #[test]
334    fn test_from_str_or_wrap_without_brackets() {
335        let msgid = MessageId::from_str_or_wrap("12345@example.com").unwrap();
336        assert_eq!(msgid.as_str(), "<12345@example.com>");
337    }
338
339    #[test]
340    fn test_from_str_or_wrap_empty() {
341        let result = MessageId::from_str_or_wrap("");
342        assert!(result.is_err());
343    }
344
345    #[test]
346    fn test_extract_from_command() {
347        let msgid = MessageId::extract_from_command("ARTICLE <12345@example.com>").unwrap();
348        assert_eq!(msgid.as_str(), "<12345@example.com>");
349    }
350
351    #[test]
352    fn test_extract_from_command_body() {
353        let msgid = MessageId::extract_from_command("BODY <test@news.server.com>").unwrap();
354        assert_eq!(msgid.as_str(), "<test@news.server.com>");
355    }
356
357    #[test]
358    fn test_extract_from_command_with_extra_text() {
359        let msgid =
360            MessageId::extract_from_command("ARTICLE <12345@example.com> extra text").unwrap();
361        assert_eq!(msgid.as_str(), "<12345@example.com>");
362    }
363
364    #[test]
365    fn test_extract_from_command_no_message_id() {
366        let result = MessageId::extract_from_command("LIST");
367        assert!(result.is_none());
368    }
369
370    #[test]
371    fn test_extract_from_command_malformed() {
372        let result = MessageId::extract_from_command("ARTICLE >12345@example.com<");
373        assert!(result.is_none());
374    }
375
376    #[test]
377    fn test_display() {
378        let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
379        assert_eq!(format!("{}", msgid), "<12345@example.com>");
380    }
381
382    #[test]
383    fn test_as_ref() {
384        let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
385        let s: &str = msgid.as_ref();
386        assert_eq!(s, "<12345@example.com>");
387    }
388
389    #[test]
390    fn test_try_from_string() {
391        let msgid: MessageId = "<12345@example.com>".to_string().try_into().unwrap();
392        assert_eq!(msgid.as_str(), "<12345@example.com>");
393    }
394
395    #[test]
396    fn test_into_string() {
397        let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
398        let s: String = msgid.into();
399        assert_eq!(s, "<12345@example.com>");
400    }
401
402    #[test]
403    fn test_clone() {
404        let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
405        let cloned = msgid.clone();
406        assert_eq!(msgid, cloned);
407    }
408
409    #[test]
410    fn test_equality() {
411        let msgid1 = MessageId::new("<12345@example.com>".to_string()).unwrap();
412        let msgid2 = MessageId::new("<12345@example.com>".to_string()).unwrap();
413        let msgid3 = MessageId::new("<54321@example.com>".to_string()).unwrap();
414        assert_eq!(msgid1, msgid2);
415        assert_ne!(msgid1, msgid3);
416    }
417
418    #[test]
419    fn test_serde_roundtrip() {
420        let msgid = MessageId::new("<12345@example.com>".to_string()).unwrap();
421        let json = serde_json::to_string(&msgid).unwrap();
422        assert_eq!(json, "\"<12345@example.com>\"");
423
424        let deserialized: MessageId = serde_json::from_str(&json).unwrap();
425        assert_eq!(deserialized, msgid);
426    }
427
428    #[test]
429    fn test_serde_invalid() {
430        let json = "\"invalid-msgid\"";
431        let result: Result<MessageId, _> = serde_json::from_str(json);
432        assert!(result.is_err());
433    }
434}