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