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}