Skip to main content

nntp_rs/article/
types.rs

1//! Article type definitions
2//!
3//! This module contains the core data structures for representing Usenet articles.
4
5use std::collections::HashMap;
6use std::fmt::Write;
7
8use crate::{NntpError, Result};
9
10/// Netnews article structure (RFC 5536)
11///
12/// An article consists of headers and a body, separated by a blank line.
13/// Articles must conform to RFC 5536 and include all required headers.
14///
15/// # Required Headers (RFC 5536 Section 3.1)
16///
17/// - Date: When the article was created
18/// - From: Author's identity
19/// - Message-ID: Unique identifier
20/// - Newsgroups: Target newsgroups (comma-separated)
21/// - Path: Transit path (managed by servers)
22/// - Subject: Article subject line
23///
24/// # Examples
25///
26/// ```
27/// use nntp_rs::article::{Article, Headers};
28/// use std::collections::HashMap;
29///
30/// let headers = Headers {
31///     date: "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
32///     from: "user@example.com".to_string(),
33///     message_id: "<abc123@example.com>".to_string(),
34///     newsgroups: vec!["comp.lang.rust".to_string()],
35///     path: "news.example.com!not-for-mail".to_string(),
36///     subject: "Test Article".to_string(),
37///     references: None,
38///     reply_to: None,
39///     organization: None,
40///     followup_to: None,
41///     expires: None,
42///     control: None,
43///     distribution: None,
44///     keywords: None,
45///     summary: None,
46///     supersedes: None,
47///     approved: None,
48///     lines: None,
49///     user_agent: None,
50///     xref: None,
51///     extra: HashMap::new(),
52/// };
53///
54/// // In practice, use ArticleBuilder to create articles
55/// use nntp_rs::article::ArticleBuilder;
56///
57/// let article = ArticleBuilder::new()
58///     .subject("Test Article")
59///     .newsgroups(vec!["comp.lang.rust"])
60///     .from("user@example.com")
61///     .body("This is the article body.")
62///     .build()
63///     .unwrap();
64/// ```
65#[derive(Debug, Clone)]
66pub struct Article {
67    /// Article headers
68    pub headers: Headers,
69    /// Article body (after blank line separator)
70    pub body: String,
71    /// Original raw article text for round-trip preservation
72    pub(crate) raw: Option<String>,
73}
74
75/// Netnews article headers (RFC 5536)
76///
77/// Contains all standard headers defined in RFC 5536, plus an `extra`
78/// HashMap for non-standard extension headers.
79#[derive(Debug, Clone)]
80pub struct Headers {
81    // Required headers (RFC 5536 Section 3.1)
82    /// Date when article was created (RFC 5536 Section 3.1.1)
83    /// Format: RFC 5322 date-time (e.g., "Mon, 20 Jan 2025 12:00:00 +0000")
84    pub date: String,
85
86    /// Author's identity (RFC 5536 Section 3.1.2)
87    /// Format: RFC 5322 mailbox (e.g., "John Doe <user@example.com>")
88    pub from: String,
89
90    /// Unique article identifier (RFC 5536 Section 3.1.3)
91    /// Format: "<local-part@domain>" (e.g., "<abc123@example.com>")
92    pub message_id: String,
93
94    /// Target newsgroups, comma-separated (RFC 5536 Section 3.1.4)
95    /// Example: ["comp.lang.rust", "comp.lang.c"]
96    pub newsgroups: Vec<String>,
97
98    /// Transit path through servers (RFC 5536 Section 3.1.5)
99    /// Format: "server1!server2!not-for-mail"
100    /// Managed by news servers, typically not set by clients
101    pub path: String,
102
103    /// Article subject line (RFC 5536 Section 3.1.6)
104    pub subject: String,
105
106    // Optional headers (RFC 5536 Section 3.2)
107    /// References to previous articles in thread (RFC 5536 Section 3.2.12)
108    /// Format: List of message-IDs (e.g., `["<msg1@example.com>", "<msg2@example.com>"]`)
109    pub references: Option<Vec<String>>,
110
111    /// Reply-To address (RFC 5536 Section 3.2.13)
112    /// Format: RFC 5322 mailbox list
113    pub reply_to: Option<String>,
114
115    /// Poster's organization (RFC 5536 Section 3.2.10)
116    pub organization: Option<String>,
117
118    /// Where followups should be directed (RFC 5536 Section 3.2.3)
119    /// Format: Comma-separated newsgroup list or "poster" keyword
120    pub followup_to: Option<Vec<String>>,
121
122    /// Expiration date for the article (RFC 5536 Section 3.2.2)
123    /// Format: RFC 5322 date-time
124    pub expires: Option<String>,
125
126    /// Control message type (RFC 5536 Section 3.2.1)
127    /// Format: "command arguments" (e.g., `"cancel <msg-id>"`)
128    pub control: Option<String>,
129
130    /// Distribution scope (RFC 5536 Section 3.2.17)
131    /// Example: "local", "world"
132    pub distribution: Option<String>,
133
134    /// Article keywords (RFC 5536 Section 3.2.8)
135    /// Format: Comma-separated list
136    pub keywords: Option<String>,
137
138    /// Article summary (RFC 5536 Section 3.2.14)
139    pub summary: Option<String>,
140
141    /// Message-ID of article being replaced (RFC 5536 Section 3.2.12)
142    /// Format: Single message-ID (e.g., "<old-msg-id@example.com>")
143    /// Mutually exclusive with Control header (RFC 5536 Section 3.2.12)
144    pub supersedes: Option<String>,
145
146    /// Moderator approval (RFC 5536 Section 3.2.18)
147    /// Required for posting to moderated groups
148    pub approved: Option<String>,
149
150    /// Number of lines in body (RFC 5536 Section 3.2.9)
151    pub lines: Option<u32>,
152
153    /// Client software identification (RFC 5536 Section 3.2.16)
154    pub user_agent: Option<String>,
155
156    /// Cross-reference information (RFC 5536 Section 3.2.15)
157    /// Format: "server group:number group:number"
158    pub xref: Option<String>,
159
160    /// Additional non-standard headers
161    /// Includes X-* headers and other extensions
162    pub extra: HashMap<String, String>,
163}
164
165impl Article {
166    /// Create a new article with the given headers and body
167    pub fn new(headers: Headers, body: String) -> Self {
168        Self {
169            headers,
170            body,
171            raw: None,
172        }
173    }
174
175    /// Get the raw article text if available
176    pub fn raw(&self) -> Option<&str> {
177        self.raw.as_deref()
178    }
179
180    /// Check if this article is a control message (RFC 5537 Section 5)
181    ///
182    /// Returns `true` if the article has a Control header, indicating it
183    /// should trigger administrative actions rather than just being displayed.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use nntp_rs::article::{Article, Headers};
189    /// use std::collections::HashMap;
190    ///
191    /// let mut headers = Headers::new(
192    ///     "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
193    ///     "admin@example.com".to_string(),
194    ///     "<cancel123@example.com>".to_string(),
195    ///     vec!["comp.lang.rust".to_string()],
196    ///     "news.example.com!not-for-mail".to_string(),
197    ///     "cancel <spam123@example.com>".to_string(),
198    /// );
199    /// headers.control = Some("cancel <spam123@example.com>".to_string());
200    ///
201    /// let article = Article::new(headers, String::new());
202    /// assert!(article.is_control_message());
203    /// ```
204    pub fn is_control_message(&self) -> bool {
205        self.headers.control.is_some()
206    }
207
208    /// Parse the control message type from the Control header (RFC 5537 Section 5)
209    ///
210    /// Extracts the control command and arguments from the Control header field.
211    /// Returns `None` if this is not a control message or if the Control header
212    /// is malformed.
213    ///
214    /// # Control Message Types
215    ///
216    /// - **cancel** - Withdraws an article (RFC 5537 Section 5.3)
217    /// - **newgroup** - Creates or modifies a newsgroup (RFC 5537 Section 5.2.1)
218    /// - **rmgroup** - Removes a newsgroup (RFC 5537 Section 5.2.2)
219    /// - **checkgroups** - Provides authoritative group list (RFC 5537 Section 5.2.3)
220    /// - **ihave** - Legacy peer-to-peer article exchange (RFC 5537 Section 5.5)
221    /// - **sendme** - Legacy peer-to-peer article exchange (RFC 5537 Section 5.5)
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use nntp_rs::article::{Article, Headers, ControlMessage};
227    /// use std::collections::HashMap;
228    ///
229    /// let mut headers = Headers::new(
230    ///     "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
231    ///     "admin@example.com".to_string(),
232    ///     "<cancel123@example.com>".to_string(),
233    ///     vec!["comp.lang.rust".to_string()],
234    ///     "news.example.com!not-for-mail".to_string(),
235    ///     "cancel message".to_string(),
236    /// );
237    /// headers.control = Some("cancel <spam123@example.com>".to_string());
238    ///
239    /// let article = Article::new(headers, String::new());
240    /// match article.parse_control_message() {
241    ///     Some(ControlMessage::Cancel { message_id }) => {
242    ///         assert_eq!(message_id, "<spam123@example.com>");
243    ///     }
244    ///     _ => panic!("Expected cancel control message"),
245    /// }
246    /// ```
247    pub fn parse_control_message(&self) -> Option<ControlMessage> {
248        let control = self.headers.control.as_ref()?;
249        ControlMessage::parse(control)
250    }
251
252    /// Check if this article has MIME content (RFC 5536 Section 4)
253    ///
254    /// Returns `true` if the article contains a Content-Type header in its
255    /// extra headers, indicating that the body uses MIME formatting.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// use nntp_rs::article::{Article, Headers};
261    /// use std::collections::HashMap;
262    ///
263    /// let mut headers = Headers::new(
264    ///     "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
265    ///     "user@example.com".to_string(),
266    ///     "<msg123@example.com>".to_string(),
267    ///     vec!["comp.lang.rust".to_string()],
268    ///     "news.example.com!not-for-mail".to_string(),
269    ///     "Test Article".to_string(),
270    /// );
271    /// headers.extra.insert("Content-Type".to_string(), "text/plain; charset=utf-8".to_string());
272    ///
273    /// let article = Article::new(headers, "Article body".to_string());
274    /// assert!(article.is_mime());
275    /// ```
276    pub fn is_mime(&self) -> bool {
277        self.headers.extra.contains_key("Content-Type")
278    }
279
280    /// Get the Content-Type header value (RFC 5536 Section 4)
281    ///
282    /// Returns the Content-Type header if present, or `None` if this is not
283    /// a MIME article. The Content-Type header specifies the media type and
284    /// optional parameters like charset.
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use nntp_rs::article::{Article, Headers};
290    /// use std::collections::HashMap;
291    ///
292    /// let mut headers = Headers::new(
293    ///     "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
294    ///     "user@example.com".to_string(),
295    ///     "<msg123@example.com>".to_string(),
296    ///     vec!["comp.lang.rust".to_string()],
297    ///     "news.example.com!not-for-mail".to_string(),
298    ///     "Test Article".to_string(),
299    /// );
300    /// headers.extra.insert("Content-Type".to_string(), "text/plain; charset=utf-8".to_string());
301    ///
302    /// let article = Article::new(headers, "Article body".to_string());
303    /// assert_eq!(article.content_type(), Some("text/plain; charset=utf-8"));
304    /// ```
305    pub fn content_type(&self) -> Option<&str> {
306        self.headers.extra.get("Content-Type").map(|s| s.as_str())
307    }
308
309    /// Check if this article is a multipart MIME message (RFC 5536 Section 4)
310    ///
311    /// Returns `true` if the Content-Type header starts with "multipart/",
312    /// indicating that the body contains multiple parts separated by a boundary.
313    ///
314    /// # Examples
315    ///
316    /// ```
317    /// use nntp_rs::article::{Article, Headers};
318    /// use std::collections::HashMap;
319    ///
320    /// let mut headers = Headers::new(
321    ///     "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
322    ///     "user@example.com".to_string(),
323    ///     "<msg123@example.com>".to_string(),
324    ///     vec!["comp.lang.rust".to_string()],
325    ///     "news.example.com!not-for-mail".to_string(),
326    ///     "Test Article".to_string(),
327    /// );
328    /// headers.extra.insert(
329    ///     "Content-Type".to_string(),
330    ///     "multipart/mixed; boundary=\"boundary123\"".to_string()
331    /// );
332    ///
333    /// let article = Article::new(headers, "Article body".to_string());
334    /// assert!(article.is_multipart());
335    /// ```
336    pub fn is_multipart(&self) -> bool {
337        self.content_type()
338            .map(|ct| ct.trim().to_lowercase().starts_with("multipart/"))
339            .unwrap_or(false)
340    }
341
342    /// Extract the charset parameter from the Content-Type header (RFC 5536 Section 4)
343    ///
344    /// Returns the charset parameter value if present in the Content-Type header.
345    /// Common values include "utf-8", "iso-8859-1", "windows-1252", etc.
346    ///
347    /// # Examples
348    ///
349    /// ```
350    /// use nntp_rs::article::{Article, Headers};
351    /// use std::collections::HashMap;
352    ///
353    /// let mut headers = Headers::new(
354    ///     "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
355    ///     "user@example.com".to_string(),
356    ///     "<msg123@example.com>".to_string(),
357    ///     vec!["comp.lang.rust".to_string()],
358    ///     "news.example.com!not-for-mail".to_string(),
359    ///     "Test Article".to_string(),
360    /// );
361    /// headers.extra.insert(
362    ///     "Content-Type".to_string(),
363    ///     "text/plain; charset=utf-8".to_string()
364    /// );
365    ///
366    /// let article = Article::new(headers, "Article body".to_string());
367    /// assert_eq!(article.charset(), Some("utf-8"));
368    /// ```
369    pub fn charset(&self) -> Option<&str> {
370        let content_type = self.content_type()?;
371
372        // Look for charset parameter in Content-Type
373        // Format: "text/plain; charset=utf-8" or "text/plain; charset=\"utf-8\""
374        for param in content_type.split(';') {
375            let param = param.trim();
376
377            // Handle "charset=value" or "charset = value" with optional whitespace
378            if let Some(eq_pos) = param.find('=') {
379                let key = param[..eq_pos].trim();
380                if key.eq_ignore_ascii_case("charset") {
381                    let value = param[eq_pos + 1..].trim();
382                    // Remove quotes if present
383                    return Some(value.trim_matches('"').trim_matches('\''));
384                }
385            }
386        }
387
388        None
389    }
390
391    /// Serialize the article for posting with CRLF line endings and dot-stuffing
392    ///
393    /// Converts the article to the wire format required by NNTP POST/IHAVE:
394    /// - CRLF line endings (\r\n)
395    /// - Dot-stuffing: lines starting with '.' are prefixed with '.'
396    /// - Headers appear first, followed by blank line, then body
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// use nntp_rs::article::ArticleBuilder;
402    ///
403    /// let article = ArticleBuilder::new()
404    ///     .from("user@example.com")
405    ///     .subject("Test")
406    ///     .newsgroups(vec!["test.group"])
407    ///     .body("Hello world")
408    ///     .build()
409    ///     .unwrap();
410    ///
411    /// let wire_format = article.serialize_for_posting().unwrap();
412    /// assert!(wire_format.contains("\r\n"));
413    /// ```
414    pub fn serialize_for_posting(&self) -> Result<String> {
415        // Pre-allocate capacity: typical headers are ~1KB, body varies
416        let mut result = String::with_capacity(1024 + self.body.len());
417
418        // Write required headers
419        // SAFETY: write! to String is infallible (OOM aside)
420        #[expect(clippy::unwrap_used)]
421        {
422            write!(result, "Date: {}\r\n", self.headers.date).unwrap();
423            write!(result, "From: {}\r\n", self.headers.from).unwrap();
424            write!(result, "Message-ID: {}\r\n", self.headers.message_id).unwrap();
425            write!(
426                result,
427                "Newsgroups: {}\r\n",
428                self.headers.newsgroups.join(",")
429            )
430            .unwrap();
431            write!(result, "Path: {}\r\n", self.headers.path).unwrap();
432            write!(result, "Subject: {}\r\n", self.headers.subject).unwrap();
433
434            // Write optional headers
435            if let Some(ref references) = self.headers.references {
436                write!(result, "References: {}\r\n", references.join(" ")).unwrap();
437            }
438            if let Some(ref reply_to) = self.headers.reply_to {
439                write!(result, "Reply-To: {}\r\n", reply_to).unwrap();
440            }
441            if let Some(ref organization) = self.headers.organization {
442                write!(result, "Organization: {}\r\n", organization).unwrap();
443            }
444            if let Some(ref followup_to) = self.headers.followup_to {
445                write!(result, "Followup-To: {}\r\n", followup_to.join(",")).unwrap();
446            }
447            if let Some(ref expires) = self.headers.expires {
448                write!(result, "Expires: {}\r\n", expires).unwrap();
449            }
450            if let Some(ref control) = self.headers.control {
451                write!(result, "Control: {}\r\n", control).unwrap();
452            }
453            if let Some(ref distribution) = self.headers.distribution {
454                write!(result, "Distribution: {}\r\n", distribution).unwrap();
455            }
456            if let Some(ref keywords) = self.headers.keywords {
457                write!(result, "Keywords: {}\r\n", keywords).unwrap();
458            }
459            if let Some(ref summary) = self.headers.summary {
460                write!(result, "Summary: {}\r\n", summary).unwrap();
461            }
462            if let Some(ref supersedes) = self.headers.supersedes {
463                write!(result, "Supersedes: {}\r\n", supersedes).unwrap();
464            }
465            if let Some(ref approved) = self.headers.approved {
466                write!(result, "Approved: {}\r\n", approved).unwrap();
467            }
468            if let Some(ref user_agent) = self.headers.user_agent {
469                write!(result, "User-Agent: {}\r\n", user_agent).unwrap();
470            }
471
472            // Write extra headers
473            for (name, value) in &self.headers.extra {
474                write!(result, "{}: {}\r\n", name, value).unwrap();
475            }
476        }
477
478        // Blank line separates headers from body
479        result.push_str("\r\n");
480
481        // Write body with dot-stuffing
482        for line in self.body.lines() {
483            if line.starts_with('.') {
484                result.push('.');
485            }
486            result.push_str(line);
487            result.push_str("\r\n");
488        }
489
490        Ok(result)
491    }
492}
493
494/// Control message types (RFC 5537 Section 5)
495///
496/// Control messages are special articles that trigger administrative actions
497/// on news servers rather than being displayed to users.
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub enum ControlMessage {
500    /// Cancel an article (RFC 5537 Section 5.3)
501    ///
502    /// Format: `cancel <message-id>`
503    ///
504    /// Withdraws an article from circulation. The message-id specifies
505    /// which article to cancel.
506    Cancel {
507        /// Message-ID of the article to cancel
508        message_id: String,
509    },
510
511    /// Create or modify a newsgroup (RFC 5537 Section 5.2.1)
512    ///
513    /// Format: `newgroup <newsgroup-name> [moderated]`
514    ///
515    /// Creates a new newsgroup or modifies an existing one. The optional
516    /// `moderated` keyword indicates the group should be moderated.
517    Newgroup {
518        /// Name of the newsgroup to create
519        group: String,
520        /// Whether the group should be moderated
521        moderated: bool,
522    },
523
524    /// Remove a newsgroup (RFC 5537 Section 5.2.2)
525    ///
526    /// Format: `rmgroup <newsgroup-name>`
527    ///
528    /// Removes a newsgroup from the server.
529    Rmgroup {
530        /// Name of the newsgroup to remove
531        group: String,
532    },
533
534    /// Provide authoritative group list (RFC 5537 Section 5.2.3)
535    ///
536    /// Format: `checkgroups [scope] [#serial-number]`
537    ///
538    /// Provides an authoritative list of valid newsgroups for a hierarchy.
539    Checkgroups {
540        /// Optional scope/hierarchy
541        scope: Option<String>,
542        /// Optional serial number for versioning
543        serial: Option<String>,
544    },
545
546    /// Legacy peer-to-peer article exchange (RFC 5537 Section 5.5)
547    ///
548    /// Format: `ihave <msg-id> [<msg-id>...] <relayer-name>`
549    ///
550    /// Largely obsolete. Use NNTP IHAVE command instead (RFC 3977 Section 6.3.2).
551    Ihave {
552        /// List of message-IDs being offered
553        message_ids: Vec<String>,
554        /// Name of the relaying server
555        relayer: Option<String>,
556    },
557
558    /// Legacy peer-to-peer article exchange (RFC 5537 Section 5.5)
559    ///
560    /// Format: `sendme <msg-id> [<msg-id>...] <relayer-name>`
561    ///
562    /// Largely obsolete. Requests articles from a peer.
563    Sendme {
564        /// List of message-IDs being requested
565        message_ids: Vec<String>,
566        /// Name of the relaying server
567        relayer: Option<String>,
568    },
569
570    /// Unknown or unrecognized control message type
571    ///
572    /// Contains the raw control header value for custom handling.
573    Unknown {
574        /// The raw Control header value
575        value: String,
576    },
577}
578
579impl ControlMessage {
580    /// Parse a control message from a Control header value
581    ///
582    /// # Arguments
583    ///
584    /// * `control` - The value of the Control header field
585    ///
586    /// # Examples
587    ///
588    /// ```
589    /// use nntp_rs::article::ControlMessage;
590    ///
591    /// let msg = ControlMessage::parse("cancel <spam@example.com>").unwrap();
592    /// match msg {
593    ///     ControlMessage::Cancel { message_id } => {
594    ///         assert_eq!(message_id, "<spam@example.com>");
595    ///     }
596    ///     _ => panic!("Expected cancel"),
597    /// }
598    ///
599    /// let msg = ControlMessage::parse("newgroup comp.lang.rust moderated").unwrap();
600    /// match msg {
601    ///     ControlMessage::Newgroup { group, moderated } => {
602    ///         assert_eq!(group, "comp.lang.rust");
603    ///         assert!(moderated);
604    ///     }
605    ///     _ => panic!("Expected newgroup"),
606    /// }
607    /// ```
608    pub fn parse(control: &str) -> Option<ControlMessage> {
609        let control = control.trim();
610        if control.is_empty() {
611            return None;
612        }
613
614        let parts: Vec<&str> = control.split_whitespace().collect();
615        if parts.is_empty() {
616            return None;
617        }
618
619        let command = parts[0].to_lowercase();
620
621        match command.as_str() {
622            "cancel" => {
623                // Format: cancel <message-id>
624                if parts.len() < 2 {
625                    return Some(ControlMessage::Unknown {
626                        value: control.to_string(),
627                    });
628                }
629                Some(ControlMessage::Cancel {
630                    message_id: parts[1].to_string(),
631                })
632            }
633            "newgroup" => {
634                // Format: newgroup <group> [moderated]
635                if parts.len() < 2 {
636                    return Some(ControlMessage::Unknown {
637                        value: control.to_string(),
638                    });
639                }
640                let group = parts[1].to_string();
641                let moderated = parts
642                    .get(2)
643                    .map(|s| s.to_lowercase() == "moderated")
644                    .unwrap_or(false);
645                Some(ControlMessage::Newgroup { group, moderated })
646            }
647            "rmgroup" => {
648                // Format: rmgroup <group>
649                if parts.len() < 2 {
650                    return Some(ControlMessage::Unknown {
651                        value: control.to_string(),
652                    });
653                }
654                Some(ControlMessage::Rmgroup {
655                    group: parts[1].to_string(),
656                })
657            }
658            "checkgroups" => {
659                // Format: checkgroups [scope] [#serial]
660                let scope = parts
661                    .get(1)
662                    .filter(|s| !s.starts_with('#'))
663                    .map(|s| s.to_string());
664                let serial = parts
665                    .iter()
666                    .find(|s| s.starts_with('#'))
667                    .map(|s| s.to_string());
668                Some(ControlMessage::Checkgroups { scope, serial })
669            }
670            "ihave" => {
671                // Format: ihave <msg-id> [<msg-id>...] <relayer-name>
672                if parts.len() < 2 {
673                    return Some(ControlMessage::Unknown {
674                        value: control.to_string(),
675                    });
676                }
677                // Last part might be relayer name (if it doesn't look like a message-id)
678                let (message_ids, relayer) =
679                    if parts.len() > 2 && !parts[parts.len() - 1].starts_with('<') {
680                        let relayer = Some(parts[parts.len() - 1].to_string());
681                        let ids: Vec<String> = parts[1..parts.len() - 1]
682                            .iter()
683                            .map(|s| s.to_string())
684                            .collect();
685                        (ids, relayer)
686                    } else {
687                        let ids: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
688                        (ids, None)
689                    };
690                Some(ControlMessage::Ihave {
691                    message_ids,
692                    relayer,
693                })
694            }
695            "sendme" => {
696                // Format: sendme <msg-id> [<msg-id>...] <relayer-name>
697                if parts.len() < 2 {
698                    return Some(ControlMessage::Unknown {
699                        value: control.to_string(),
700                    });
701                }
702                // Last part might be relayer name (if it doesn't look like a message-id)
703                let (message_ids, relayer) =
704                    if parts.len() > 2 && !parts[parts.len() - 1].starts_with('<') {
705                        let relayer = Some(parts[parts.len() - 1].to_string());
706                        let ids: Vec<String> = parts[1..parts.len() - 1]
707                            .iter()
708                            .map(|s| s.to_string())
709                            .collect();
710                        (ids, relayer)
711                    } else {
712                        let ids: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
713                        (ids, None)
714                    };
715                Some(ControlMessage::Sendme {
716                    message_ids,
717                    relayer,
718                })
719            }
720            _ => {
721                // Unknown control message type
722                Some(ControlMessage::Unknown {
723                    value: control.to_string(),
724                })
725            }
726        }
727    }
728}
729
730impl Headers {
731    /// Create a new Headers struct with required fields
732    ///
733    /// # Arguments
734    ///
735    /// * `date` - RFC 5322 date-time string
736    /// * `from` - Author mailbox
737    /// * `message_id` - Unique message identifier
738    /// * `newsgroups` - List of target newsgroups
739    /// * `path` - Server transit path
740    /// * `subject` - Article subject
741    pub fn new(
742        date: String,
743        from: String,
744        message_id: String,
745        newsgroups: Vec<String>,
746        path: String,
747        subject: String,
748    ) -> Self {
749        Self {
750            date,
751            from,
752            message_id,
753            newsgroups,
754            path,
755            subject,
756            references: None,
757            reply_to: None,
758            organization: None,
759            followup_to: None,
760            expires: None,
761            control: None,
762            distribution: None,
763            keywords: None,
764            summary: None,
765            supersedes: None,
766            approved: None,
767            lines: None,
768            user_agent: None,
769            xref: None,
770            extra: HashMap::new(),
771        }
772    }
773
774    /// Validates all header fields according to RFC 5536 specifications
775    ///
776    /// Performs comprehensive validation of all header fields:
777    /// - Checks that required fields are non-empty
778    /// - Validates Message-ID format
779    /// - Validates newsgroup names
780    /// - Parses and validates date format and constraints
781    /// - Checks mutual exclusivity of Supersedes and Control headers
782    ///
783    /// # Arguments
784    ///
785    /// * `config` - Validation configuration (controls date validation behavior)
786    ///
787    /// # Examples
788    ///
789    /// ```
790    /// use nntp_rs::article::Headers;
791    /// use nntp_rs::validation::ValidationConfig;
792    /// use std::collections::HashMap;
793    ///
794    /// let headers = Headers {
795    ///     date: "Tue, 20 Jan 2026 12:00:00 +0000".to_string(),
796    ///     from: "user@example.com".to_string(),
797    ///     message_id: "<abc123@example.com>".to_string(),
798    ///     newsgroups: vec!["comp.lang.rust".to_string()],
799    ///     path: "news.example.com!not-for-mail".to_string(),
800    ///     subject: "Test Article".to_string(),
801    ///     references: None,
802    ///     reply_to: None,
803    ///     organization: None,
804    ///     followup_to: None,
805    ///     expires: None,
806    ///     control: None,
807    ///     distribution: None,
808    ///     keywords: None,
809    ///     summary: None,
810    ///     supersedes: None,
811    ///     approved: None,
812    ///     lines: None,
813    ///     user_agent: None,
814    ///     xref: None,
815    ///     extra: HashMap::new(),
816    /// };
817    ///
818    /// let config = ValidationConfig::default();
819    /// headers.validate(&config).unwrap();
820    /// ```
821    pub fn validate(&self, config: &crate::validation::ValidationConfig) -> Result<()> {
822        // Validate required fields are non-empty
823        if self.date.trim().is_empty() {
824            return Err(NntpError::InvalidResponse(
825                "Date header cannot be empty".to_string(),
826            ));
827        }
828        if self.from.trim().is_empty() {
829            return Err(NntpError::InvalidResponse(
830                "From header cannot be empty".to_string(),
831            ));
832        }
833        if self.message_id.trim().is_empty() {
834            return Err(NntpError::InvalidResponse(
835                "Message-ID header cannot be empty".to_string(),
836            ));
837        }
838        if self.newsgroups.is_empty() {
839            return Err(NntpError::InvalidResponse(
840                "Newsgroups header cannot be empty".to_string(),
841            ));
842        }
843        if self.path.trim().is_empty() {
844            return Err(NntpError::InvalidResponse(
845                "Path header cannot be empty".to_string(),
846            ));
847        }
848        if self.subject.trim().is_empty() {
849            return Err(NntpError::InvalidResponse(
850                "Subject header cannot be empty".to_string(),
851            ));
852        }
853
854        // Validate Message-ID format
855        crate::validation::validate_message_id(&self.message_id)?;
856
857        // Validate all newsgroup names
858        for newsgroup in &self.newsgroups {
859            crate::validation::validate_newsgroup_name(newsgroup)?;
860        }
861
862        // Validate followup_to newsgroups if present
863        if let Some(ref followup_to) = self.followup_to {
864            for newsgroup in followup_to {
865                // "poster" is a special keyword in Followup-To, not a newsgroup
866                if newsgroup != "poster" {
867                    crate::validation::validate_newsgroup_name(newsgroup)?;
868                }
869            }
870        }
871
872        // Parse and validate date
873        let parsed_date = crate::validation::parse_date(&self.date)?;
874        crate::validation::validate_date(&parsed_date, config)?;
875
876        // Validate expires date if present
877        if let Some(ref expires) = self.expires {
878            let expires_date = crate::validation::parse_date(expires)?;
879            // Expires should be in the future or current (not validated with config)
880            // Just validate it's a valid date format
881            let _ = expires_date;
882        }
883
884        // Validate References message-IDs if present
885        if let Some(ref references) = self.references {
886            for reference in references {
887                crate::validation::validate_message_id(reference)?;
888            }
889        }
890
891        // Validate Supersedes message-ID if present
892        if let Some(ref supersedes) = self.supersedes {
893            crate::validation::validate_message_id(supersedes)?;
894        }
895
896        // Check mutual exclusivity: Supersedes and Control (RFC 5536 Section 3.2.12)
897        if self.supersedes.is_some() && self.control.is_some() {
898            return Err(NntpError::InvalidResponse(
899                "Article cannot have both Supersedes and Control headers".to_string(),
900            ));
901        }
902
903        Ok(())
904    }
905
906    /// Parses the Path header into individual server components
907    ///
908    /// The Path header contains a "bang path" of servers that the article
909    /// passed through, separated by '!' characters. Servers are listed in
910    /// reverse chronological order (most recent first).
911    ///
912    /// # Examples
913    ///
914    /// ```
915    /// use nntp_rs::article::Headers;
916    /// use std::collections::HashMap;
917    ///
918    /// let headers = Headers {
919    ///     date: "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
920    ///     from: "user@example.com".to_string(),
921    ///     message_id: "<abc123@example.com>".to_string(),
922    ///     newsgroups: vec!["comp.lang.rust".to_string()],
923    ///     path: "news1.example.com!news2.example.net!not-for-mail".to_string(),
924    ///     subject: "Test".to_string(),
925    ///     references: None,
926    ///     reply_to: None,
927    ///     organization: None,
928    ///     followup_to: None,
929    ///     expires: None,
930    ///     control: None,
931    ///     distribution: None,
932    ///     keywords: None,
933    ///     summary: None,
934    ///     supersedes: None,
935    ///     approved: None,
936    ///     lines: None,
937    ///     user_agent: None,
938    ///     xref: None,
939    ///     extra: HashMap::new(),
940    /// };
941    ///
942    /// let path_components = headers.parse_path();
943    /// assert_eq!(path_components, vec!["news1.example.com", "news2.example.net", "not-for-mail"]);
944    /// ```
945    pub fn parse_path(&self) -> Vec<String> {
946        self.path
947            .split('!')
948            .map(|s| s.trim())
949            .filter(|s| !s.is_empty())
950            .map(|s| s.to_string())
951            .collect()
952    }
953
954    /// Returns the originating server from the Path header
955    ///
956    /// The originating server is the first component of the path,
957    /// representing the most recent server to handle the article.
958    ///
959    /// # Examples
960    ///
961    /// ```
962    /// use nntp_rs::article::Headers;
963    /// use std::collections::HashMap;
964    ///
965    /// let headers = Headers {
966    ///     date: "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
967    ///     from: "user@example.com".to_string(),
968    ///     message_id: "<abc123@example.com>".to_string(),
969    ///     newsgroups: vec!["comp.lang.rust".to_string()],
970    ///     path: "news1.example.com!news2.example.net!not-for-mail".to_string(),
971    ///     subject: "Test".to_string(),
972    ///     references: None,
973    ///     reply_to: None,
974    ///     organization: None,
975    ///     followup_to: None,
976    ///     expires: None,
977    ///     control: None,
978    ///     distribution: None,
979    ///     keywords: None,
980    ///     summary: None,
981    ///     supersedes: None,
982    ///     approved: None,
983    ///     lines: None,
984    ///     user_agent: None,
985    ///     xref: None,
986    ///     extra: HashMap::new(),
987    /// };
988    ///
989    /// assert_eq!(headers.originating_server(), Some("news1.example.com"));
990    /// ```
991    pub fn originating_server(&self) -> Option<&str> {
992        self.path.split('!').next().filter(|s| !s.trim().is_empty())
993    }
994
995    /// Returns the number of servers in the Path header
996    ///
997    /// This represents the number of "hops" the article has made
998    /// through the Usenet infrastructure.
999    ///
1000    /// # Examples
1001    ///
1002    /// ```
1003    /// use nntp_rs::article::Headers;
1004    /// use std::collections::HashMap;
1005    ///
1006    /// let headers = Headers {
1007    ///     date: "Mon, 20 Jan 2025 12:00:00 +0000".to_string(),
1008    ///     from: "user@example.com".to_string(),
1009    ///     message_id: "<abc123@example.com>".to_string(),
1010    ///     newsgroups: vec!["comp.lang.rust".to_string()],
1011    ///     path: "news1.example.com!news2.example.net!not-for-mail".to_string(),
1012    ///     subject: "Test".to_string(),
1013    ///     references: None,
1014    ///     reply_to: None,
1015    ///     organization: None,
1016    ///     followup_to: None,
1017    ///     expires: None,
1018    ///     control: None,
1019    ///     distribution: None,
1020    ///     keywords: None,
1021    ///     summary: None,
1022    ///     supersedes: None,
1023    ///     approved: None,
1024    ///     lines: None,
1025    ///     user_agent: None,
1026    ///     xref: None,
1027    ///     extra: HashMap::new(),
1028    /// };
1029    ///
1030    /// assert_eq!(headers.path_length(), 3);
1031    /// ```
1032    pub fn path_length(&self) -> usize {
1033        self.parse_path().len()
1034    }
1035}