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}