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}