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}