Skip to main content

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    pub(crate) base_url: String,
12    request_counter: std::sync::atomic::AtomicUsize,
13    cache: Option<std::sync::Mutex<rusqlite::Connection>>,
14    cache_enabled: bool,
15    force_refetch: bool,
16}
17
18impl SearchfoxClient {
19    pub fn new(repo: String, log_requests: bool) -> Result<Self> {
20        let client = Self::create_tls13_client()?;
21        let cache = crate::cache::open().map(|conn| {
22            crate::cache::prune(&conn);
23            std::sync::Mutex::new(conn)
24        });
25        Ok(Self {
26            client,
27            repo,
28            log_requests,
29            base_url: "https://searchfox.org".to_string(),
30            request_counter: std::sync::atomic::AtomicUsize::new(0),
31            cache,
32            cache_enabled: true,
33            force_refetch: false,
34        })
35    }
36
37    #[cfg(test)]
38    pub(crate) fn new_for_test(repo: String, base_url: String) -> Result<Self> {
39        let client = Self::create_tls13_client()?;
40        let conn = crate::cache::open_in_memory()?;
41        Ok(Self {
42            client,
43            repo,
44            log_requests: false,
45            base_url,
46            request_counter: std::sync::atomic::AtomicUsize::new(0),
47            cache: Some(std::sync::Mutex::new(conn)),
48            cache_enabled: true,
49            force_refetch: false,
50        })
51    }
52
53    #[cfg(test)]
54    pub(crate) fn cache_set_stale(
55        &self,
56        key: &str,
57        content: &str,
58        etag: Option<&str>,
59        last_modified: Option<&str>,
60    ) {
61        if let Some(ref m) = self.cache {
62            if let Ok(c) = m.lock() {
63                crate::cache::set_with_timestamp(&c, key, content, etag, last_modified, 0);
64            }
65        }
66    }
67
68    fn create_tls13_client() -> Result<Client> {
69        Client::builder()
70            .user_agent(Self::get_user_agent())
71            .use_rustls_tls()
72            .min_tls_version(reqwest::tls::Version::TLS_1_2)
73            .max_tls_version(reqwest::tls::Version::TLS_1_3)
74            .timeout(Duration::from_secs(30))
75            .build()
76            .map_err(|e| anyhow::anyhow!("Failed to build TLS client with rustls: {}", e))
77    }
78
79    fn get_user_agent() -> String {
80        let magic_word = std::env::var("SEARCHFOX_MAGIC_WORD")
81            .unwrap_or_else(|_| "sésame ouvre toi".to_string());
82        format!("searchfox-cli/{} ({})", crate::VERSION, magic_word)
83    }
84
85    pub fn log_request_start(&self, method: &str, url: &str) -> Option<RequestLog> {
86        if !self.log_requests {
87            return None;
88        }
89
90        let request_id = self
91            .request_counter
92            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
93            + 1;
94
95        let log = RequestLog {
96            url: url.to_string(),
97            method: method.to_string(),
98            start_time: Instant::now(),
99            request_id,
100        };
101
102        eprintln!(
103            "[REQ-{}] {} {} - START",
104            log.request_id, log.method, log.url
105        );
106        Some(log)
107    }
108
109    pub fn log_request_end(&self, request_log: RequestLog, status: u16, size_bytes: usize) {
110        let duration = request_log.start_time.elapsed();
111
112        let response_log = ResponseLog {
113            request_id: request_log.request_id,
114            status,
115            size_bytes,
116            duration,
117        };
118
119        eprintln!(
120            "[REQ-{}] {} {} - END ({}ms, {} bytes, HTTP {})",
121            response_log.request_id,
122            request_log.method,
123            request_log.url,
124            duration.as_millis(),
125            size_bytes,
126            status
127        );
128    }
129
130    pub async fn ping(&self) -> Result<Duration> {
131        if !self.log_requests {
132            return Ok(Duration::from_millis(0));
133        }
134
135        eprintln!(
136            "[PING] Testing network latency to searchfox.org (ICMP ping disabled, using HTTP HEAD)..."
137        );
138
139        let ping_url = "https://searchfox.org/";
140        let start = Instant::now();
141
142        let response = self
143            .client
144            .head(ping_url)
145            .timeout(Duration::from_secs(10))
146            .send()
147            .await?;
148
149        let latency = start.elapsed();
150
151        eprintln!(
152            "[PING] HTTP HEAD latency: {}ms (HTTP {})",
153            latency.as_millis(),
154            response.status()
155        );
156        eprintln!(
157            "[PING] Note: This includes minimal server processing time, not just network latency"
158        );
159
160        Ok(latency)
161    }
162
163    pub async fn get(&self, url: Url) -> Result<reqwest::Response> {
164        let request_log = self.log_request_start("GET", url.as_ref());
165        let response = self
166            .client
167            .get(url.clone())
168            .header("Accept", "application/json")
169            .send()
170            .await?;
171
172        if let Some(req_log) = request_log {
173            self.log_request_end(req_log, response.status().as_u16(), 0);
174        }
175
176        Ok(response)
177    }
178
179    pub async fn get_raw(&self, url: &str) -> Result<String> {
180        let request_log = self.log_request_start("GET", url);
181        let response = self.client.get(url).send().await?;
182
183        if !response.status().is_success() {
184            if let Some(req_log) = request_log {
185                self.log_request_end(req_log, response.status().as_u16(), 0);
186            }
187            anyhow::bail!("Request failed: {}", response.status());
188        }
189
190        let text = response.text().await?;
191        let size = text.len();
192
193        if let Some(req_log) = request_log {
194            self.log_request_end(req_log, 200, size);
195        }
196
197        Ok(text)
198    }
199
200    pub async fn get_html(&self, url: &str) -> Result<String> {
201        debug!("Fetching HTML from: {}", url);
202
203        let response = self
204            .client
205            .get(url)
206            .header("Accept", "text/html")
207            .send()
208            .await?;
209
210        if !response.status().is_success() {
211            anyhow::bail!("Request failed: {}", response.status());
212        }
213
214        Ok(response.text().await?)
215    }
216
217    pub async fn get_html_with_meta(
218        &self,
219        url: &str,
220        etag: Option<&str>,
221        last_modified: Option<&str>,
222    ) -> Result<Option<(String, Option<String>, Option<String>)>> {
223        debug!("Fetching HTML from: {}", url);
224
225        let mut request = self.client.get(url).header("Accept", "text/html");
226        if let Some(etag) = etag {
227            request = request.header("If-None-Match", etag);
228        }
229        if let Some(lm) = last_modified {
230            request = request.header("If-Modified-Since", lm);
231        }
232
233        let response = request.send().await?;
234        let status = response.status();
235
236        if status == reqwest::StatusCode::NOT_MODIFIED {
237            return Ok(None);
238        }
239
240        if !status.is_success() {
241            anyhow::bail!("Request failed: {}", status);
242        }
243
244        let etag = response
245            .headers()
246            .get("etag")
247            .and_then(|v| v.to_str().ok())
248            .map(String::from);
249        let last_modified = response
250            .headers()
251            .get("last-modified")
252            .and_then(|v| v.to_str().ok())
253            .map(String::from);
254
255        let text = response.text().await?;
256        Ok(Some((text, etag, last_modified)))
257    }
258
259    pub fn client(&self) -> &Client {
260        &self.client
261    }
262
263    pub fn set_cache_enabled(&mut self, enabled: bool) {
264        self.cache_enabled = enabled;
265    }
266
267    pub fn set_force_refetch(&mut self, force_refetch: bool) {
268        self.force_refetch = force_refetch;
269    }
270
271    pub(crate) fn force_refetch(&self) -> bool {
272        self.force_refetch
273    }
274
275    pub(crate) fn cache_get(&self, url: &str) -> Option<crate::cache::CacheEntry> {
276        if !self.cache_enabled || self.force_refetch {
277            return None;
278        }
279        self.cache
280            .as_ref()?
281            .lock()
282            .ok()
283            .and_then(|c| crate::cache::get(&c, url))
284    }
285
286    pub(crate) fn cache_set(
287        &self,
288        url: &str,
289        content: &str,
290        etag: Option<&str>,
291        last_modified: Option<&str>,
292    ) {
293        if !self.cache_enabled {
294            return;
295        }
296        if let Some(ref m) = self.cache {
297            if let Ok(c) = m.lock() {
298                crate::cache::set(&c, url, content, etag, last_modified);
299            }
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn no_cache_disables_reads_and_writes() {
310        let mut client =
311            SearchfoxClient::new_for_test("mozilla-central".into(), "https://example.com".into())
312                .unwrap();
313
314        client.cache_set("source:https://example.com/a", "v1", None, None);
315        assert!(client.cache_get("source:https://example.com/a").is_some());
316
317        client.set_cache_enabled(false);
318        assert!(client.cache_get("source:https://example.com/a").is_none());
319
320        client.cache_set("source:https://example.com/b", "v2", None, None);
321        client.set_cache_enabled(true);
322        assert!(client.cache_get("source:https://example.com/b").is_none());
323    }
324
325    #[test]
326    fn force_refetch_bypasses_reads_but_keeps_writes() {
327        let mut client =
328            SearchfoxClient::new_for_test("mozilla-central".into(), "https://example.com".into())
329                .unwrap();
330
331        client.cache_set("source:https://example.com/a", "v1", None, None);
332        assert!(client.cache_get("source:https://example.com/a").is_some());
333
334        client.set_force_refetch(true);
335        assert!(client.cache_get("source:https://example.com/a").is_none());
336
337        client.cache_set("source:https://example.com/b", "v2", None, None);
338        client.set_force_refetch(false);
339        assert_eq!(
340            client
341                .cache_get("source:https://example.com/b")
342                .unwrap()
343                .content,
344            "v2"
345        );
346    }
347}