1use std::io::{Read, Write};
2use std::net::TcpStream;
3use std::time::Duration;
4use anyhow::{Context, Result};
5
6pub struct WhoisColorProtocol;
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct ServerCapabilities {
14 pub supports_color: bool,
15 pub color_schemes: Vec<String>,
16 pub protocol_version: String,
17 pub supports_markdown: bool,
18 pub supports_images: bool,
19 pub image_formats: Vec<String>,
20}
21
22impl Default for ServerCapabilities {
23 fn default() -> Self {
24 Self {
25 supports_color: false,
26 color_schemes: vec![],
27 protocol_version: "none".to_string(),
28 supports_markdown: false,
29 supports_images: false,
30 image_formats: vec![],
31 }
32 }
33}
34
35pub const PROTOCOL_VERSION: &str = "1.1";
37pub const LEGACY_VERSION: &str = "1.0";
38pub const CAPABILITY_PROBE: &str = "X-WHOIS-COLOR-PROBE: v1.1\r\n";
39pub const COLOR_REQUEST_PREFIX: &str = "X-WHOIS-COLOR: ";
40pub const MARKDOWN_REQUEST_PREFIX: &str = "X-WHOIS-MARKDOWN: ";
41pub const IMAGE_REQUEST_PREFIX: &str = "X-WHOIS-IMAGES: ";
42pub const CAPABILITY_RESPONSE_PREFIX: &str = "X-WHOIS-COLOR-SUPPORT: ";
43pub const CAPABILITY_TIMEOUT_MS: u64 = 2000; impl WhoisColorProtocol {
46 pub fn probe_capabilities(
50 &self,
51 server_address: &str,
52 verbose: bool
53 ) -> Result<ServerCapabilities> {
54 if verbose {
55 println!("Probing color capabilities for: {}", server_address);
56 }
57
58 let mut stream = TcpStream::connect(server_address)
59 .with_context(|| format!("Cannot connect to server for capability probe: {}", server_address))?;
60
61 stream.set_read_timeout(Some(Duration::from_millis(CAPABILITY_TIMEOUT_MS)))
63 .context("Failed to set read timeout for capability probe")?;
64
65 stream.set_write_timeout(Some(Duration::from_millis(CAPABILITY_TIMEOUT_MS)))
66 .context("Failed to set write timeout for capability probe")?;
67
68 let probe_query = format!("{}\r\n", CAPABILITY_PROBE);
71
72 if let Err(_) = stream.write_all(probe_query.as_bytes()) {
73 if verbose {
75 println!("Capability probe write failed, assuming standard WHOIS");
76 }
77 return Ok(ServerCapabilities::default());
78 }
79
80 let mut response = String::new();
82 match stream.read_to_string(&mut response) {
83 Ok(_) => {
84 let capabilities = self.parse_capability_response(&response);
85 if verbose {
86 println!("Server capabilities: {:?}", capabilities);
87 }
88 Ok(capabilities)
89 }
90 Err(_) => {
91 if verbose {
93 println!("No capability response, assuming standard WHOIS");
94 }
95 Ok(ServerCapabilities::default())
96 }
97 }
98 }
99
100 fn parse_capability_response(&self, response: &str) -> ServerCapabilities {
104 for line in response.lines() {
105 let line = line.trim();
106 if line.starts_with(CAPABILITY_RESPONSE_PREFIX) {
107 return self.parse_capability_line(&line[CAPABILITY_RESPONSE_PREFIX.len()..]);
108 }
109 }
110 ServerCapabilities::default()
111 }
112
113 fn parse_capability_line(&self, capability_data: &str) -> ServerCapabilities {
117 let parts: Vec<&str> = capability_data.split_whitespace().collect();
118 if parts.is_empty() {
119 return ServerCapabilities::default();
120 }
121
122 let protocol_version = parts[0].to_string();
123 let mut capabilities = ServerCapabilities {
124 supports_color: true,
125 protocol_version: protocol_version.clone(),
126 color_schemes: vec![],
127 supports_markdown: false,
128 supports_images: false,
129 image_formats: vec![],
130 };
131
132 for part in &parts[1..] {
134 if let Some(schemes_part) = part.strip_prefix("schemes=") {
135 capabilities.color_schemes = schemes_part
136 .split(',')
137 .map(|s| s.trim().to_string())
138 .filter(|s| !s.is_empty())
139 .collect();
140 } else if let Some(markdown_part) = part.strip_prefix("markdown=") {
141 capabilities.supports_markdown = markdown_part == "true";
142 } else if let Some(images_part) = part.strip_prefix("images=") {
143 capabilities.supports_images = !images_part.is_empty();
144 capabilities.image_formats = images_part
145 .split(',')
146 .map(|s| s.trim().to_string())
147 .filter(|s| !s.is_empty())
148 .collect();
149 }
150 }
151
152 capabilities
153 }
154
155 pub fn query_with_enhanced_protocol(
158 &self,
159 server_address: &str,
160 query: &str,
161 capabilities: &ServerCapabilities,
162 preferred_scheme: Option<&str>,
163 enable_markdown: bool,
164 enable_images: bool,
165 verbose: bool
166 ) -> Result<String> {
167 let mut stream = TcpStream::connect(server_address)
168 .with_context(|| format!("Cannot connect to WHOIS server: {}", server_address))?;
169
170 stream.set_read_timeout(Some(Duration::from_secs(10)))
171 .context("Failed to set read timeout")?;
172
173 stream.set_write_timeout(Some(Duration::from_secs(10)))
174 .context("Failed to set write timeout")?;
175
176 let query_string = if capabilities.supports_color || capabilities.supports_markdown || capabilities.supports_images {
177 self.build_enhanced_query(query, capabilities, preferred_scheme, enable_markdown, enable_images, verbose)
178 } else {
179 format!("{}\r\n", query)
181 };
182
183 if verbose {
184 if capabilities.supports_color {
185 println!("Sending color-enabled query");
186 }
187 if capabilities.supports_markdown && enable_markdown {
188 println!("Requesting Markdown format");
189 }
190 if capabilities.supports_images && enable_images {
191 println!("Requesting image support");
192 }
193 }
194
195 stream.write_all(query_string.as_bytes())
196 .context("Failed to write query to WHOIS server")?;
197
198 let mut response = String::new();
199 stream.read_to_string(&mut response)
200 .context("Failed to read response from WHOIS server")?;
201
202 Ok(response)
203 }
204
205 fn build_enhanced_query(
209 &self,
210 query: &str,
211 capabilities: &ServerCapabilities,
212 preferred_scheme: Option<&str>,
213 enable_markdown: bool,
214 enable_images: bool,
215 verbose: bool
216 ) -> String {
217 let mut headers = String::new();
218
219 if capabilities.supports_color {
221 if let Some(scheme) = self.select_color_scheme(capabilities, preferred_scheme) {
222 if verbose {
223 println!("Requesting server-side coloring with scheme: {}", scheme);
224 }
225 headers.push_str(&format!("{}scheme={}\r\n", COLOR_REQUEST_PREFIX, scheme));
226 }
227 }
228
229 if capabilities.supports_markdown && enable_markdown {
231 headers.push_str(&format!("{}true\r\n", MARKDOWN_REQUEST_PREFIX));
232 }
233
234 if capabilities.supports_images && enable_images && !capabilities.image_formats.is_empty() {
236 let formats = capabilities.image_formats.join(",");
237 headers.push_str(&format!("{}{}\r\n", IMAGE_REQUEST_PREFIX, formats));
238 }
239
240 if headers.is_empty() {
241 format!("{}\r\n", query)
243 } else {
244 format!("{}{}\r\n", headers, query)
246 }
247 }
248
249 #[allow(dead_code)]
253 fn build_color_query(
254 &self,
255 query: &str,
256 capabilities: &ServerCapabilities,
257 preferred_scheme: Option<&str>,
258 verbose: bool
259 ) -> String {
260 let scheme = self.select_color_scheme(capabilities, preferred_scheme);
261
262 if let Some(scheme) = scheme {
263 if verbose {
264 println!("Requesting server-side coloring with scheme: {}", scheme);
265 }
266 format!("{}scheme={}\r\n{}\r\n", COLOR_REQUEST_PREFIX, scheme, query)
267 } else {
268 if verbose {
270 println!("No suitable color scheme, falling back to standard query");
271 }
272 format!("{}\r\n", query)
273 }
274 }
275
276 fn select_color_scheme(
278 &self,
279 capabilities: &ServerCapabilities,
280 preferred_scheme: Option<&str>
281 ) -> Option<String> {
282 if !capabilities.supports_color {
283 return None;
284 }
285
286 if let Some(preferred) = preferred_scheme {
288 if capabilities.color_schemes.contains(&preferred.to_string()) {
289 return Some(preferred.to_string());
290 }
291 }
292
293 capabilities.color_schemes.first().cloned()
295 }
296
297 pub fn is_server_colored(&self, response: &str) -> bool {
300 response.contains("\x1b[") ||
302 response.contains("X-WHOIS-COLOR-APPLIED:")
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_parse_capability_response_v10() {
313 let protocol = WhoisColorProtocol;
314
315 let response = "X-WHOIS-COLOR-SUPPORT: v1.0 schemes=ripe,bgptools,mtf\r\n";
316 let capabilities = protocol.parse_capability_response(response);
317
318 assert!(capabilities.supports_color);
319 assert_eq!(capabilities.protocol_version, "v1.0");
320 assert_eq!(capabilities.color_schemes, vec!["ripe", "bgptools", "mtf"]);
321 assert!(!capabilities.supports_markdown); assert!(!capabilities.supports_images); }
324
325 #[test]
326 fn test_parse_capability_response_v11() {
327 let protocol = WhoisColorProtocol;
328
329 let response = "X-WHOIS-COLOR-SUPPORT: v1.1 schemes=ripe,bgptools,mtf markdown=true images=png,jpg\r\n";
330 let capabilities = protocol.parse_capability_response(response);
331
332 assert!(capabilities.supports_color);
333 assert_eq!(capabilities.protocol_version, "v1.1");
334 assert_eq!(capabilities.color_schemes, vec!["ripe", "bgptools", "mtf"]);
335 assert!(capabilities.supports_markdown);
336 assert!(capabilities.supports_images);
337 assert_eq!(capabilities.image_formats, vec!["png", "jpg"]);
338 }
339
340 #[test]
341 fn test_parse_capability_response_minimal() {
342 let protocol = WhoisColorProtocol;
343
344 let response = "X-WHOIS-COLOR-SUPPORT: v1.0\r\n";
345 let capabilities = protocol.parse_capability_response(response);
346
347 assert!(capabilities.supports_color);
348 assert_eq!(capabilities.protocol_version, "v1.0");
349 assert!(capabilities.color_schemes.is_empty());
350 }
351
352 #[test]
353 fn test_parse_capability_response_no_support() {
354 let protocol = WhoisColorProtocol;
355
356 let response = "Some other response\r\n";
357 let capabilities = protocol.parse_capability_response(response);
358
359 assert!(!capabilities.supports_color);
360 assert_eq!(capabilities.protocol_version, "none");
361 assert!(capabilities.color_schemes.is_empty());
362 }
363
364 #[test]
365 fn test_select_color_scheme_preferred() {
366 let protocol = WhoisColorProtocol;
367 let capabilities = ServerCapabilities {
368 supports_color: true,
369 color_schemes: vec!["ripe".to_string(), "bgptools".to_string()],
370 protocol_version: "v1.0".to_string(),
371 supports_markdown: false,
372 supports_images: false,
373 image_formats: vec![],
374 };
375
376 let scheme = protocol.select_color_scheme(&capabilities, Some("bgptools"));
377 assert_eq!(scheme, Some("bgptools".to_string()));
378 }
379
380 #[test]
381 fn test_select_color_scheme_fallback() {
382 let protocol = WhoisColorProtocol;
383 let capabilities = ServerCapabilities {
384 supports_color: true,
385 color_schemes: vec!["ripe".to_string(), "bgptools".to_string()],
386 protocol_version: "v1.0".to_string(),
387 supports_markdown: false,
388 supports_images: false,
389 image_formats: vec![],
390 };
391
392 let scheme = protocol.select_color_scheme(&capabilities, Some("invalid"));
393 assert_eq!(scheme, Some("ripe".to_string()));
394 }
395
396 #[test]
397 fn test_select_color_scheme_no_support() {
398 let protocol = WhoisColorProtocol;
399 let capabilities = ServerCapabilities::default();
400
401 let scheme = protocol.select_color_scheme(&capabilities, Some("ripe"));
402 assert_eq!(scheme, None);
403 }
404
405 #[test]
406 fn test_build_enhanced_query_v10_compat() {
407 let protocol = WhoisColorProtocol;
408 let capabilities = ServerCapabilities {
409 supports_color: true,
410 color_schemes: vec!["ripe".to_string()],
411 protocol_version: "v1.0".to_string(),
412 supports_markdown: false,
413 supports_images: false,
414 image_formats: vec![],
415 };
416
417 let query = protocol.build_enhanced_query("example.com", &capabilities, Some("ripe"), false, false, false);
418 assert_eq!(query, "X-WHOIS-COLOR: scheme=ripe\r\nexample.com\r\n");
419 }
420
421 #[test]
422 fn test_build_enhanced_query_v11_full() {
423 let protocol = WhoisColorProtocol;
424 let capabilities = ServerCapabilities {
425 supports_color: true,
426 color_schemes: vec!["ripe".to_string()],
427 protocol_version: "v1.1".to_string(),
428 supports_markdown: true,
429 supports_images: true,
430 image_formats: vec!["png".to_string(), "jpg".to_string()],
431 };
432
433 let query = protocol.build_enhanced_query("example.com", &capabilities, Some("ripe"), true, true, false);
434 let expected = "X-WHOIS-COLOR: scheme=ripe\r\nX-WHOIS-MARKDOWN: true\r\nX-WHOIS-IMAGES: png,jpg\r\nexample.com\r\n";
435 assert_eq!(query, expected);
436 }
437
438 #[test]
439 fn test_build_color_query_legacy() {
440 let protocol = WhoisColorProtocol;
441 let capabilities = ServerCapabilities {
442 supports_color: true,
443 color_schemes: vec!["ripe".to_string()],
444 protocol_version: "v1.0".to_string(),
445 supports_markdown: false,
446 supports_images: false,
447 image_formats: vec![],
448 };
449
450 let query = protocol.build_color_query("example.com", &capabilities, Some("ripe"), false);
451 assert_eq!(query, "X-WHOIS-COLOR: scheme=ripe\r\nexample.com\r\n");
452 }
453
454 #[test]
455 fn test_build_color_query_no_scheme() {
456 let protocol = WhoisColorProtocol;
457 let capabilities = ServerCapabilities::default();
458
459 let query = protocol.build_color_query("example.com", &capabilities, Some("ripe"), false);
460 assert_eq!(query, "example.com\r\n");
461 }
462
463 #[test]
464 fn test_is_server_colored() {
465 let protocol = WhoisColorProtocol;
466
467 assert!(protocol.is_server_colored("text with \x1b[31mcolor\x1b[0m"));
468 assert!(protocol.is_server_colored("X-WHOIS-COLOR-APPLIED: ripe\ntext"));
469 assert!(!protocol.is_server_colored("plain text"));
470 }
471}