Skip to main content

crates_docs/utils/
mod.rs

1//! Utility functions module
2
3use crate::error::{Error, Result};
4use reqwest::Client;
5use reqwest_middleware::ClientBuilder;
6use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::sync::Semaphore;
10
11/// HTTP client builder with retry support
12///
13/// This builder creates a `reqwest_middleware::ClientWithMiddleware` that includes
14/// automatic retry functionality for transient failures.
15pub struct HttpClientBuilder {
16    timeout: Duration,
17    connect_timeout: Duration,
18    read_timeout: Duration,
19    pool_max_idle_per_host: usize,
20    pool_idle_timeout: Duration,
21    user_agent: String,
22    enable_gzip: bool,
23    enable_brotli: bool,
24    max_retries: u32,
25    retry_initial_delay: Duration,
26    retry_max_delay: Duration,
27}
28
29impl Default for HttpClientBuilder {
30    fn default() -> Self {
31        Self {
32            timeout: Duration::from_secs(30),
33            connect_timeout: Duration::from_secs(10),
34            read_timeout: Duration::from_secs(30),
35            pool_max_idle_per_host: 10,
36            pool_idle_timeout: Duration::from_secs(90),
37            user_agent: format!("CratesDocsMCP/{}", crate::VERSION),
38            enable_gzip: true,
39            enable_brotli: true,
40            max_retries: 3,
41            retry_initial_delay: Duration::from_millis(100),
42            retry_max_delay: Duration::from_secs(10),
43        }
44    }
45}
46
47impl HttpClientBuilder {
48    /// Create a new HTTP client builder
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Set request timeout
55    #[must_use]
56    pub fn timeout(mut self, timeout: Duration) -> Self {
57        self.timeout = timeout;
58        self
59    }
60
61    /// Set connection timeout
62    #[must_use]
63    pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
64        self.connect_timeout = connect_timeout;
65        self
66    }
67
68    /// Set read timeout
69    #[must_use]
70    pub fn read_timeout(mut self, read_timeout: Duration) -> Self {
71        self.read_timeout = read_timeout;
72        self
73    }
74
75    /// Set connection pool size
76    #[must_use]
77    pub fn pool_max_idle_per_host(mut self, max_idle: usize) -> Self {
78        self.pool_max_idle_per_host = max_idle;
79        self
80    }
81
82    /// Set pool idle timeout
83    #[must_use]
84    pub fn pool_idle_timeout(mut self, idle_timeout: Duration) -> Self {
85        self.pool_idle_timeout = idle_timeout;
86        self
87    }
88
89    /// Set User-Agent
90    #[must_use]
91    pub fn user_agent(mut self, user_agent: String) -> Self {
92        self.user_agent = user_agent;
93        self
94    }
95
96    /// Enable/disable Gzip compression
97    #[must_use]
98    pub fn enable_gzip(mut self, enable: bool) -> Self {
99        self.enable_gzip = enable;
100        self
101    }
102
103    /// Enable/disable Brotli compression
104    #[must_use]
105    pub fn enable_brotli(mut self, enable: bool) -> Self {
106        self.enable_brotli = enable;
107        self
108    }
109
110    /// Set max retry attempts
111    #[must_use]
112    pub fn max_retries(mut self, max_retries: u32) -> Self {
113        self.max_retries = max_retries;
114        self
115    }
116
117    /// Set retry initial delay
118    #[must_use]
119    pub fn retry_initial_delay(mut self, delay: Duration) -> Self {
120        self.retry_initial_delay = delay;
121        self
122    }
123
124    /// Set retry max delay
125    #[must_use]
126    pub fn retry_max_delay(mut self, delay: Duration) -> Self {
127        self.retry_max_delay = delay;
128        self
129    }
130
131    /// Build HTTP client with middleware chain
132    ///
133    /// This method builds a `reqwest_middleware::ClientWithMiddleware` that includes
134    /// automatic retry functionality using exponential backoff for transient failures.
135    ///
136    /// # Returns
137    ///
138    /// Returns a `ClientWithMiddleware` that can be used like a regular `reqwest::Client`
139    /// but with automatic retry on transient errors.
140    pub fn build(self) -> Result<reqwest_middleware::ClientWithMiddleware> {
141        let mut builder = Client::builder()
142            .timeout(self.timeout)
143            .connect_timeout(self.connect_timeout)
144            .pool_max_idle_per_host(self.pool_max_idle_per_host)
145            .pool_idle_timeout(self.pool_idle_timeout)
146            .user_agent(&self.user_agent);
147
148        // reqwest 0.13 enables gzip and brotli by default
149        // To disable, use .no_gzip() and .no_brotli()
150        if !self.enable_gzip {
151            builder = builder.no_gzip();
152        }
153
154        if !self.enable_brotli {
155            builder = builder.no_brotli();
156        }
157
158        let client = builder
159            .build()
160            .map_err(|e| Error::http_request("BUILD", "client", 0, e.to_string()))?;
161
162        // Create retry policy with exponential backoff
163        let retry_policy = ExponentialBackoff::builder()
164            .retry_bounds(self.retry_initial_delay, self.retry_max_delay)
165            .build_with_max_retries(self.max_retries);
166
167        // Build client with retry middleware
168        Ok(ClientBuilder::new(client)
169            .with(RetryTransientMiddleware::new_with_policy(retry_policy))
170            .build())
171    }
172
173    /// Build HTTP client without retry support
174    ///
175    /// This method returns a plain `reqwest::Client` without any middleware.
176    /// Use [`build`](Self::build) for retry support.
177    pub fn build_plain(self) -> Result<Client> {
178        let mut builder = Client::builder()
179            .timeout(self.timeout)
180            .connect_timeout(self.connect_timeout)
181            .pool_max_idle_per_host(self.pool_max_idle_per_host)
182            .pool_idle_timeout(self.pool_idle_timeout)
183            .user_agent(&self.user_agent);
184
185        if !self.enable_gzip {
186            builder = builder.no_gzip();
187        }
188
189        if !self.enable_brotli {
190            builder = builder.no_brotli();
191        }
192
193        builder
194            .build()
195            .map_err(|e| Error::http_request("BUILD", "client", 0, e.to_string()))
196    }
197}
198
199/// Create HTTP client builder from performance config
200///
201/// This function creates an `HttpClientBuilder` pre-configured with settings
202/// from the provided `PerformanceConfig`. The resulting client will include
203/// automatic retry functionality.
204#[must_use]
205pub fn create_http_client_from_config(
206    config: &crate::config::PerformanceConfig,
207) -> HttpClientBuilder {
208    HttpClientBuilder::new()
209        .timeout(Duration::from_secs(config.http_client_timeout_secs))
210        .connect_timeout(Duration::from_secs(config.http_client_connect_timeout_secs))
211        .read_timeout(Duration::from_secs(config.http_client_read_timeout_secs))
212        .pool_max_idle_per_host(config.http_client_pool_size)
213        .pool_idle_timeout(Duration::from_secs(
214            config.http_client_pool_idle_timeout_secs,
215        ))
216        .max_retries(config.http_client_max_retries)
217        .retry_initial_delay(Duration::from_millis(
218            config.http_client_retry_initial_delay_ms,
219        ))
220        .retry_max_delay(Duration::from_millis(config.http_client_retry_max_delay_ms))
221}
222
223/// Rate limiter
224pub struct RateLimiter {
225    semaphore: Arc<Semaphore>,
226    max_permits: usize,
227}
228
229impl RateLimiter {
230    /// Create a new rate limiter
231    #[must_use]
232    pub fn new(max_permits: usize) -> Self {
233        Self {
234            semaphore: Arc::new(Semaphore::new(max_permits)),
235            max_permits,
236        }
237    }
238
239    /// Acquire permit (blocks until available)
240    pub async fn acquire(&self) -> Result<tokio::sync::SemaphorePermit<'_>> {
241        self.semaphore
242            .acquire()
243            .await
244            .map_err(|e| Error::Other(format!("Failed to acquire rate limit permit: {e}")))
245    }
246
247    /// Try to acquire permit (non-blocking)
248    #[must_use]
249    pub fn try_acquire(&self) -> Option<tokio::sync::SemaphorePermit<'_>> {
250        self.semaphore.try_acquire().ok()
251    }
252
253    /// Get current number of available permits
254    #[must_use]
255    pub fn available_permits(&self) -> usize {
256        self.semaphore.available_permits()
257    }
258
259    /// Get maximum number of permits
260    #[must_use]
261    pub fn max_permits(&self) -> usize {
262        self.max_permits
263    }
264}
265
266/// Response compression utilities
267pub mod compression {
268    use crate::error::{Error, Result};
269    use flate2::write::GzEncoder;
270    use flate2::Compression;
271    use std::io::Write;
272
273    /// Compress data (Gzip)
274    pub fn gzip_compress(data: &[u8]) -> Result<Vec<u8>> {
275        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
276        encoder
277            .write_all(data)
278            .map_err(|e| Error::Other(format!("Gzip compression failed: {e}")))?;
279        encoder
280            .finish()
281            .map_err(|e| Error::Other(format!("Gzip compression finalize failed: {e}")))
282    }
283
284    /// Decompress data (Gzip)
285    pub fn gzip_decompress(data: &[u8]) -> Result<Vec<u8>> {
286        let mut decoder = flate2::read::GzDecoder::new(data);
287        let mut decompressed = Vec::new();
288        std::io::Read::read_to_end(&mut decoder, &mut decompressed)
289            .map_err(|e| Error::Other(format!("Gzip decompression failed: {e}")))?;
290        Ok(decompressed)
291    }
292}
293
294/// String utilities
295pub mod string {
296    /// Truncate string and add ellipsis (UTF-8 safe)
297    ///
298    /// # Arguments
299    /// * `s` - The string to truncate
300    /// * `max_len` - Maximum number of characters (not bytes) to keep, including ellipsis
301    ///
302    /// # Examples
303    /// ```
304    /// use crates_docs::utils::string::truncate_with_ellipsis;
305    /// // Basic ASCII truncation
306    /// assert_eq!(truncate_with_ellipsis("hello world", 8), "hello...");
307    /// assert_eq!(truncate_with_ellipsis("short", 10), "short");
308    /// // UTF-8 safe: works with multi-byte characters
309    /// assert_eq!(truncate_with_ellipsis("你好世界", 3), "...");
310    /// assert_eq!(truncate_with_ellipsis("你好世界", 4), "你好世界"); // 4 chars <= max_len, no truncation
311    /// assert_eq!(truncate_with_ellipsis("你好世界", 5), "你好世界"); // 4 chars <= max_len, no truncation
312    /// assert_eq!(truncate_with_ellipsis("你好世界你好", 4), "你...");   // 4 chars > max_len-3, truncate
313    /// ```
314    #[must_use]
315    pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
316        // If max_len is 3 or less, just return ellipsis
317        if max_len <= 3 {
318            return "...".to_string();
319        }
320
321        // Collect characters to properly handle UTF-8
322        let chars: Vec<char> = s.chars().collect();
323
324        // If string is short enough, return it as-is
325        if chars.len() <= max_len {
326            return s.to_string();
327        }
328
329        // Truncate to max_len - 3 characters and add ellipsis
330        let truncated: String = chars.iter().take(max_len - 3).collect();
331        format!("{truncated}...")
332    }
333
334    /// Safely parse number
335    pub fn parse_number<T: std::str::FromStr>(s: &str, default: T) -> T {
336        s.parse().unwrap_or(default)
337    }
338
339    /// Check if string is empty or blank
340    #[must_use]
341    pub fn is_blank(s: &str) -> bool {
342        s.trim().is_empty()
343    }
344}
345
346/// Time utilities
347pub mod time {
348    use chrono::{DateTime, Utc};
349
350    /// Get current timestamp (milliseconds)
351    #[must_use]
352    pub fn current_timestamp_ms() -> i64 {
353        Utc::now().timestamp_millis()
354    }
355
356    /// Format datetime
357    #[must_use]
358    pub fn format_datetime(dt: &DateTime<Utc>) -> String {
359        dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string()
360    }
361
362    /// Calculate elapsed time (milliseconds)
363    #[must_use]
364    pub fn elapsed_ms(start: std::time::Instant) -> u128 {
365        start.elapsed().as_millis()
366    }
367}
368
369/// Validation utilities
370pub mod validation {
371    use crate::error::Error;
372
373    /// Validate crate name
374    pub fn validate_crate_name(name: &str) -> Result<(), Error> {
375        if name.is_empty() {
376            return Err(Error::Other("Crate name cannot be empty".to_string()));
377        }
378
379        if name.len() > 100 {
380            return Err(Error::Other("Crate name is too long".to_string()));
381        }
382
383        // Basic validation: only allow letters, digits, underscores, hyphens
384        if !name
385            .chars()
386            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
387        {
388            return Err(Error::Other(
389                "Crate name contains invalid characters".to_string(),
390            ));
391        }
392
393        Ok(())
394    }
395
396    /// Validate version number
397    pub fn validate_version(version: &str) -> Result<(), Error> {
398        if version.is_empty() {
399            return Err(Error::Other("Version cannot be empty".to_string()));
400        }
401
402        if version.len() > 50 {
403            return Err(Error::Other("Version is too long".to_string()));
404        }
405
406        // Simple validation: should contain digits and dots
407        if !version.chars().any(|c| c.is_ascii_digit()) {
408            return Err(Error::Other("Version must contain digits".to_string()));
409        }
410
411        Ok(())
412    }
413
414    /// Validate search query
415    pub fn validate_search_query(query: &str) -> Result<(), Error> {
416        if query.is_empty() {
417            return Err(Error::Other("Search query cannot be empty".to_string()));
418        }
419
420        if query.len() > 200 {
421            return Err(Error::Other("Search query is too long".to_string()));
422        }
423
424        Ok(())
425    }
426}
427
428/// Performance monitoring
429pub mod metrics {
430    use std::sync::atomic::{AtomicU64, Ordering};
431    use std::sync::Arc;
432    use std::time::Instant;
433
434    /// Performance counter
435    #[derive(Clone)]
436    pub struct PerformanceCounter {
437        total_requests: Arc<AtomicU64>,
438        successful_requests: Arc<AtomicU64>,
439        failed_requests: Arc<AtomicU64>,
440        total_response_time_ms: Arc<AtomicU64>,
441    }
442
443    impl PerformanceCounter {
444        /// Create a new performance counter
445        #[must_use]
446        pub fn new() -> Self {
447            Self {
448                total_requests: Arc::new(AtomicU64::new(0)),
449                successful_requests: Arc::new(AtomicU64::new(0)),
450                failed_requests: Arc::new(AtomicU64::new(0)),
451                total_response_time_ms: Arc::new(AtomicU64::new(0)),
452            }
453        }
454
455        /// Record request start
456        #[must_use]
457        pub fn record_request_start(&self) -> Instant {
458            self.total_requests.fetch_add(1, Ordering::Relaxed);
459            Instant::now()
460        }
461
462        /// Record request completion
463        #[allow(clippy::cast_possible_truncation)]
464        pub fn record_request_complete(&self, start: Instant, success: bool) {
465            let duration_ms = start.elapsed().as_millis() as u64;
466            self.total_response_time_ms
467                .fetch_add(duration_ms, Ordering::Relaxed);
468
469            if success {
470                self.successful_requests.fetch_add(1, Ordering::Relaxed);
471            } else {
472                self.failed_requests.fetch_add(1, Ordering::Relaxed);
473            }
474        }
475
476        /// Get statistics
477        #[must_use]
478        pub fn get_stats(&self) -> PerformanceStats {
479            let total = self.total_requests.load(Ordering::Relaxed);
480            let success = self.successful_requests.load(Ordering::Relaxed);
481            let failed = self.failed_requests.load(Ordering::Relaxed);
482            let total_time = self.total_response_time_ms.load(Ordering::Relaxed);
483
484            #[allow(clippy::cast_precision_loss)]
485            let avg_response_time = if total > 0 {
486                total_time as f64 / total as f64
487            } else {
488                0.0
489            };
490
491            #[allow(clippy::cast_precision_loss)]
492            let success_rate = if total > 0 {
493                success as f64 / total as f64 * 100.0
494            } else {
495                0.0
496            };
497
498            PerformanceStats {
499                total_requests: total,
500                successful_requests: success,
501                failed_requests: failed,
502                average_response_time_ms: avg_response_time,
503                success_rate_percent: success_rate,
504            }
505        }
506
507        /// Reset counter
508        pub fn reset(&self) {
509            self.total_requests.store(0, Ordering::Relaxed);
510            self.successful_requests.store(0, Ordering::Relaxed);
511            self.failed_requests.store(0, Ordering::Relaxed);
512            self.total_response_time_ms.store(0, Ordering::Relaxed);
513        }
514    }
515
516    impl Default for PerformanceCounter {
517        fn default() -> Self {
518            Self::new()
519        }
520    }
521
522    /// Performance statistics
523    #[derive(Debug, Clone, serde::Serialize)]
524    pub struct PerformanceStats {
525        /// Total requests
526        pub total_requests: u64,
527        /// Successful requests
528        pub successful_requests: u64,
529        /// Failed requests
530        pub failed_requests: u64,
531        /// Average response time (milliseconds)
532        pub average_response_time_ms: f64,
533        /// Success rate (percentage)
534        pub success_rate_percent: f64,
535    }
536}