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