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_final_url(&self, url: &str) -> Result<String> {
201        let url = reqwest::Url::parse(url).map_err(|e| anyhow::anyhow!("Invalid URL: {}", e))?;
202        let response = self.client.head(url).send().await?;
203        Ok(response.url().as_str().to_string())
204    }
205
206    pub async fn get_html(&self, url: &str) -> Result<String> {
207        debug!("Fetching HTML from: {}", url);
208
209        let response = self
210            .client
211            .get(url)
212            .header("Accept", "text/html")
213            .send()
214            .await?;
215
216        if !response.status().is_success() {
217            anyhow::bail!("Request failed: {}", response.status());
218        }
219
220        Ok(response.text().await?)
221    }
222
223    pub async fn get_html_with_meta(
224        &self,
225        url: &str,
226        etag: Option<&str>,
227        last_modified: Option<&str>,
228    ) -> Result<Option<(String, Option<String>, Option<String>)>> {
229        debug!("Fetching HTML from: {}", url);
230
231        let mut request = self.client.get(url).header("Accept", "text/html");
232        if let Some(etag) = etag {
233            request = request.header("If-None-Match", etag);
234        }
235        if let Some(lm) = last_modified {
236            request = request.header("If-Modified-Since", lm);
237        }
238
239        let response = request.send().await?;
240        let status = response.status();
241
242        if status == reqwest::StatusCode::NOT_MODIFIED {
243            return Ok(None);
244        }
245
246        if !status.is_success() {
247            anyhow::bail!("Request failed: {}", status);
248        }
249
250        let etag = response
251            .headers()
252            .get("etag")
253            .and_then(|v| v.to_str().ok())
254            .map(String::from);
255        let last_modified = response
256            .headers()
257            .get("last-modified")
258            .and_then(|v| v.to_str().ok())
259            .map(String::from);
260
261        let text = response.text().await?;
262        Ok(Some((text, etag, last_modified)))
263    }
264
265    pub fn client(&self) -> &Client {
266        &self.client
267    }
268
269    pub fn set_cache_enabled(&mut self, enabled: bool) {
270        self.cache_enabled = enabled;
271    }
272
273    pub fn set_force_refetch(&mut self, force_refetch: bool) {
274        self.force_refetch = force_refetch;
275    }
276
277    pub(crate) fn force_refetch(&self) -> bool {
278        self.force_refetch
279    }
280
281    pub(crate) fn cache_get(&self, url: &str) -> Option<crate::cache::CacheEntry> {
282        if !self.cache_enabled || self.force_refetch {
283            return None;
284        }
285        self.cache
286            .as_ref()?
287            .lock()
288            .ok()
289            .and_then(|c| crate::cache::get(&c, url))
290    }
291
292    pub(crate) fn cache_set(
293        &self,
294        url: &str,
295        content: &str,
296        etag: Option<&str>,
297        last_modified: Option<&str>,
298    ) {
299        if !self.cache_enabled {
300            return;
301        }
302        if let Some(ref m) = self.cache {
303            if let Ok(c) = m.lock() {
304                crate::cache::set(&c, url, content, etag, last_modified);
305            }
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn no_cache_disables_reads_and_writes() {
316        let mut client =
317            SearchfoxClient::new_for_test("mozilla-central".into(), "https://example.com".into())
318                .unwrap();
319
320        client.cache_set("source:https://example.com/a", "v1", None, None);
321        assert!(client.cache_get("source:https://example.com/a").is_some());
322
323        client.set_cache_enabled(false);
324        assert!(client.cache_get("source:https://example.com/a").is_none());
325
326        client.cache_set("source:https://example.com/b", "v2", None, None);
327        client.set_cache_enabled(true);
328        assert!(client.cache_get("source:https://example.com/b").is_none());
329    }
330
331    #[test]
332    fn force_refetch_bypasses_reads_but_keeps_writes() {
333        let mut client =
334            SearchfoxClient::new_for_test("mozilla-central".into(), "https://example.com".into())
335                .unwrap();
336
337        client.cache_set("source:https://example.com/a", "v1", None, None);
338        assert!(client.cache_get("source:https://example.com/a").is_some());
339
340        client.set_force_refetch(true);
341        assert!(client.cache_get("source:https://example.com/a").is_none());
342
343        client.cache_set("source:https://example.com/b", "v2", None, None);
344        client.set_force_refetch(false);
345        assert_eq!(
346            client
347                .cache_get("source:https://example.com/b")
348                .unwrap()
349                .content,
350            "v2"
351        );
352    }
353}