searchfox_lib/
client.rs

1use crate::types::{RequestLog, ResponseLog};
2use anyhow::Result;
3use log::debug;
4use reqwest::{Client, Url};
5use std::time::{Duration, Instant};
6
7pub struct SearchfoxClient {
8    client: Client,
9    pub repo: String,
10    pub log_requests: bool,
11    request_counter: std::sync::atomic::AtomicUsize,
12}
13
14impl SearchfoxClient {
15    pub fn new(repo: String, log_requests: bool) -> Result<Self> {
16        let client = Self::create_tls13_client()?;
17        Ok(Self {
18            client,
19            repo,
20            log_requests,
21            request_counter: std::sync::atomic::AtomicUsize::new(0),
22        })
23    }
24
25    fn create_tls13_client() -> Result<Client> {
26        Client::builder()
27            .user_agent(Self::get_user_agent())
28            .use_rustls_tls()
29            .min_tls_version(reqwest::tls::Version::TLS_1_2)
30            .max_tls_version(reqwest::tls::Version::TLS_1_3)
31            .timeout(Duration::from_secs(30))
32            .build()
33            .map_err(|e| anyhow::anyhow!("Failed to build TLS client with rustls: {}", e))
34    }
35
36    fn get_user_agent() -> String {
37        let magic_word = std::env::var("SEARCHFOX_MAGIC_WORD")
38            .unwrap_or_else(|_| "sésame ouvre toi".to_string());
39        format!("searchfox-cli/{} ({})", crate::VERSION, magic_word)
40    }
41
42    pub fn log_request_start(&self, method: &str, url: &str) -> Option<RequestLog> {
43        if !self.log_requests {
44            return None;
45        }
46
47        let request_id = self
48            .request_counter
49            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
50            + 1;
51
52        let log = RequestLog {
53            url: url.to_string(),
54            method: method.to_string(),
55            start_time: Instant::now(),
56            request_id,
57        };
58
59        eprintln!(
60            "[REQ-{}] {} {} - START",
61            log.request_id, log.method, log.url
62        );
63        Some(log)
64    }
65
66    pub fn log_request_end(&self, request_log: RequestLog, status: u16, size_bytes: usize) {
67        let duration = request_log.start_time.elapsed();
68
69        let response_log = ResponseLog {
70            request_id: request_log.request_id,
71            status,
72            size_bytes,
73            duration,
74        };
75
76        eprintln!(
77            "[REQ-{}] {} {} - END ({}ms, {} bytes, HTTP {})",
78            response_log.request_id,
79            request_log.method,
80            request_log.url,
81            duration.as_millis(),
82            size_bytes,
83            status
84        );
85    }
86
87    pub async fn ping(&self) -> Result<Duration> {
88        if !self.log_requests {
89            return Ok(Duration::from_millis(0));
90        }
91
92        eprintln!(
93            "[PING] Testing network latency to searchfox.org (ICMP ping disabled, using HTTP HEAD)..."
94        );
95
96        let ping_url = "https://searchfox.org/";
97        let start = Instant::now();
98
99        let response = self
100            .client
101            .head(ping_url)
102            .timeout(Duration::from_secs(10))
103            .send()
104            .await?;
105
106        let latency = start.elapsed();
107
108        eprintln!(
109            "[PING] HTTP HEAD latency: {}ms (HTTP {})",
110            latency.as_millis(),
111            response.status()
112        );
113        eprintln!(
114            "[PING] Note: This includes minimal server processing time, not just network latency"
115        );
116
117        Ok(latency)
118    }
119
120    pub async fn get(&self, url: Url) -> Result<reqwest::Response> {
121        let request_log = self.log_request_start("GET", url.as_ref());
122        let response = self
123            .client
124            .get(url.clone())
125            .header("Accept", "application/json")
126            .send()
127            .await?;
128
129        if let Some(req_log) = request_log {
130            self.log_request_end(req_log, response.status().as_u16(), 0);
131        }
132
133        Ok(response)
134    }
135
136    pub async fn get_raw(&self, url: &str) -> Result<String> {
137        let request_log = self.log_request_start("GET", url);
138        let response = self.client.get(url).send().await?;
139
140        if !response.status().is_success() {
141            if let Some(req_log) = request_log {
142                self.log_request_end(req_log, response.status().as_u16(), 0);
143            }
144            anyhow::bail!("Request failed: {}", response.status());
145        }
146
147        let text = response.text().await?;
148        let size = text.len();
149
150        if let Some(req_log) = request_log {
151            self.log_request_end(req_log, 200, size);
152        }
153
154        Ok(text)
155    }
156
157    pub async fn get_html(&self, url: &str) -> Result<String> {
158        debug!("Fetching HTML from: {}", url);
159
160        let response = self
161            .client
162            .get(url)
163            .header("Accept", "text/html")
164            .send()
165            .await?;
166
167        if !response.status().is_success() {
168            anyhow::bail!("Request failed: {}", response.status());
169        }
170
171        Ok(response.text().await?)
172    }
173
174    pub fn client(&self) -> &Client {
175        &self.client
176    }
177}