Skip to main content

nzb_nntp/
config.rs

1//! NNTP server and article configuration types.
2
3use serde::{Deserialize, Serialize};
4
5/// NNTP server configuration.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[non_exhaustive]
8pub struct ServerConfig {
9    /// Unique server identifier
10    pub id: String,
11    /// Display name
12    pub name: String,
13    /// Server hostname
14    pub host: String,
15    /// Server port
16    pub port: u16,
17    /// Use SSL/TLS
18    pub ssl: bool,
19    /// Verify SSL certificates
20    pub ssl_verify: bool,
21    /// Username for authentication
22    pub username: Option<String>,
23    /// Password for authentication
24    pub password: Option<String>,
25    /// Max simultaneous connections
26    pub connections: u16,
27    /// Server priority (0 = highest)
28    pub priority: u8,
29    /// Enable this server
30    pub enabled: bool,
31    /// Article retention in days (0 = unlimited)
32    pub retention: u32,
33    /// Number of pipelined requests per connection
34    pub pipelining: u8,
35    /// Server is optional (failure is non-fatal)
36    pub optional: bool,
37    /// Enable XFEATURE COMPRESS GZIP negotiation
38    #[serde(default)]
39    pub compress: bool,
40    /// Delay in milliseconds between opening new connections (0 = no delay).
41    /// Prevents connection bursts that trigger server-side rate limiting.
42    #[serde(default)]
43    pub ramp_up_delay_ms: u32,
44    /// TCP receive buffer size in bytes (SO_RCVBUF). 0 = OS default.
45    #[serde(default = "default_recv_buffer_size")]
46    pub recv_buffer_size: u32,
47    /// Optional SOCKS5 proxy URL: `socks5://[username:password@]host:port`
48    #[serde(default)]
49    pub proxy_url: Option<String>,
50}
51
52/// Default TCP receive buffer: 2 MiB.
53fn default_recv_buffer_size() -> u32 {
54    2 * 1024 * 1024
55}
56
57impl ServerConfig {
58    /// Create a new `ServerConfig` with the given id and host, using defaults for all other fields.
59    pub fn new(id: impl Into<String>, host: impl Into<String>) -> Self {
60        Self {
61            id: id.into(),
62            host: host.into(),
63            ..Self::default()
64        }
65    }
66}
67
68impl Default for ServerConfig {
69    fn default() -> Self {
70        Self {
71            id: uuid::Uuid::new_v4().to_string(),
72            name: String::new(),
73            host: String::new(),
74            port: 563,
75            ssl: true,
76            ssl_verify: true,
77            username: None,
78            password: None,
79            connections: 8,
80            priority: 0,
81            enabled: true,
82            retention: 0,
83            pipelining: 1,
84            optional: false,
85            compress: false,
86            ramp_up_delay_ms: 250,
87            recv_buffer_size: default_recv_buffer_size(),
88            proxy_url: None,
89        }
90    }
91}
92
93/// Entry from `LIST ACTIVE` response.
94///
95/// Each line: `groupname last first posting_flag`
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ListActiveEntry {
98    /// Newsgroup name (e.g., "alt.binaries.test")
99    pub name: String,
100    /// Highest article number
101    pub high: u64,
102    /// Lowest article number
103    pub low: u64,
104    /// Posting flag (y = posting allowed, n = no posting, m = moderated)
105    pub status: String,
106}
107
108/// A Usenet article segment to be downloaded.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Article {
111    /// Message-ID (e.g., "abc123@example.com")
112    pub message_id: String,
113    /// Segment number (1-based part number)
114    pub segment_number: u32,
115    /// Encoded size in bytes
116    pub bytes: u64,
117    /// Has this article been downloaded?
118    pub downloaded: bool,
119    /// Byte offset in the final file (set after yEnc decode)
120    pub data_begin: Option<u64>,
121    /// Size of decoded data for this segment
122    pub data_size: Option<u64>,
123    /// CRC32 of decoded data
124    pub crc32: Option<u32>,
125    /// Servers that have been tried for this article
126    pub tried_servers: Vec<String>,
127    /// Number of fetch attempts
128    pub tries: u32,
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_article_serde_roundtrip() {
137        let article = Article {
138            message_id: "abc123@example.com".to_string(),
139            segment_number: 1,
140            bytes: 500_000,
141            downloaded: false,
142            data_begin: Some(0),
143            data_size: Some(499_000),
144            crc32: Some(0xDEADBEEF),
145            tried_servers: vec!["server1".to_string()],
146            tries: 2,
147        };
148
149        let json = serde_json::to_string(&article).unwrap();
150        let deserialized: Article = serde_json::from_str(&json).unwrap();
151
152        assert_eq!(deserialized.message_id, "abc123@example.com");
153        assert_eq!(deserialized.segment_number, 1);
154        assert_eq!(deserialized.bytes, 500_000);
155        assert!(!deserialized.downloaded);
156        assert_eq!(deserialized.data_begin, Some(0));
157        assert_eq!(deserialized.data_size, Some(499_000));
158        assert_eq!(deserialized.crc32, Some(0xDEADBEEF));
159        assert_eq!(deserialized.tried_servers, vec!["server1"]);
160        assert_eq!(deserialized.tries, 2);
161    }
162
163    #[test]
164    fn test_server_config_serde_roundtrip() {
165        let config = ServerConfig {
166            id: "srv1".to_string(),
167            name: "My Server".to_string(),
168            host: "news.example.com".to_string(),
169            port: 563,
170            ssl: true,
171            ssl_verify: true,
172            username: Some("user".to_string()),
173            password: Some("pass".to_string()),
174            connections: 8,
175            priority: 0,
176            enabled: true,
177            retention: 3000,
178            pipelining: 1,
179            optional: false,
180            compress: true,
181            ramp_up_delay_ms: 500,
182            recv_buffer_size: 2 * 1024 * 1024,
183            proxy_url: Some("socks5://proxy:1080".to_string()),
184        };
185
186        let toml_str = toml::to_string(&config).unwrap();
187        let deserialized: ServerConfig = toml::from_str(&toml_str).unwrap();
188
189        assert_eq!(deserialized.id, "srv1");
190        assert_eq!(deserialized.host, "news.example.com");
191        assert_eq!(deserialized.port, 563);
192        assert!(deserialized.ssl);
193        assert_eq!(deserialized.connections, 8);
194        assert_eq!(deserialized.retention, 3000);
195        assert!(deserialized.compress);
196        assert_eq!(
197            deserialized.proxy_url,
198            Some("socks5://proxy:1080".to_string())
199        );
200    }
201
202    #[test]
203    fn test_server_config_defaults() {
204        let config = ServerConfig::default();
205        // ID should be a valid UUID (not empty)
206        assert!(!config.id.is_empty());
207        assert!(uuid::Uuid::parse_str(&config.id).is_ok());
208        assert_eq!(config.port, 563);
209        assert!(config.ssl);
210        assert!(config.ssl_verify);
211        assert_eq!(config.connections, 8);
212        assert_eq!(config.priority, 0);
213        assert!(config.enabled);
214        assert_eq!(config.retention, 0);
215        assert_eq!(config.pipelining, 1);
216        assert!(!config.optional);
217        assert!(!config.compress);
218        assert_eq!(config.ramp_up_delay_ms, 250);
219        assert!(config.proxy_url.is_none());
220    }
221
222    #[test]
223    fn test_list_active_entry_serde() {
224        let entry = ListActiveEntry {
225            name: "alt.binaries.test".to_string(),
226            high: 1_000_000,
227            low: 1,
228            status: "y".to_string(),
229        };
230
231        let json = serde_json::to_string(&entry).unwrap();
232        let deserialized: ListActiveEntry = serde_json::from_str(&json).unwrap();
233
234        assert_eq!(deserialized.name, "alt.binaries.test");
235        assert_eq!(deserialized.high, 1_000_000);
236        assert_eq!(deserialized.low, 1);
237        assert_eq!(deserialized.status, "y");
238    }
239}