serper_sdk/utils/
mod.rs

1/// Utility functions and helpers
2///
3/// This module provides common utility functions used throughout the SDK,
4/// including validation helpers, formatting utilities, and convenience functions.
5use crate::core::{Result, SerperError};
6use std::collections::HashMap;
7
8/// URL validation utilities  
9pub mod url {
10    use super::*;
11    use ::url::Url;
12
13    /// Validates that a URL is properly formatted
14    ///
15    /// # Arguments
16    ///
17    /// * `url` - The URL string to validate
18    ///
19    /// # Returns
20    ///
21    /// Result indicating whether the URL is valid
22    pub fn validate_url(url: &str) -> Result<()> {
23        if url.trim().is_empty() {
24            return Err(SerperError::validation_error("URL cannot be empty"));
25        }
26
27        Url::parse(url)
28            .map_err(|_| SerperError::validation_error(format!("Invalid URL: {}", url)))?;
29
30        Ok(())
31    }
32
33    /// Validates that a URL uses HTTPS
34    ///
35    /// # Arguments
36    ///
37    /// * `url` - The URL string to validate
38    ///
39    /// # Returns
40    ///
41    /// Result indicating whether the URL uses HTTPS
42    pub fn validate_https(url: &str) -> Result<()> {
43        validate_url(url)?;
44
45        if !url.starts_with("https://") {
46            return Err(SerperError::validation_error("URL must use HTTPS"));
47        }
48
49        Ok(())
50    }
51
52    /// Extracts the domain from a URL
53    ///
54    /// # Arguments
55    ///
56    /// * `url` - The URL string
57    ///
58    /// # Returns
59    ///
60    /// Result containing the domain or an error
61    pub fn extract_domain(url: &str) -> Result<String> {
62        let parsed = Url::parse(url)
63            .map_err(|_| SerperError::validation_error(format!("Invalid URL: {}", url)))?;
64
65        parsed
66            .host_str()
67            .map(|host| host.to_string())
68            .ok_or_else(|| SerperError::validation_error("URL has no domain"))
69    }
70}
71
72/// String validation and formatting utilities
73pub mod string {
74    use super::*;
75
76    /// Validates that a string is not empty after trimming
77    ///
78    /// # Arguments
79    ///
80    /// * `value` - The string to validate
81    /// * `field_name` - Name of the field for error messages
82    ///
83    /// # Returns
84    ///
85    /// Result indicating whether the string is valid
86    pub fn validate_non_empty(value: &str, field_name: &str) -> Result<()> {
87        if value.trim().is_empty() {
88            return Err(SerperError::validation_error(format!(
89                "{} cannot be empty",
90                field_name
91            )));
92        }
93        Ok(())
94    }
95
96    /// Validates string length constraints
97    ///
98    /// # Arguments
99    ///
100    /// * `value` - The string to validate
101    /// * `min_len` - Minimum length (optional)
102    /// * `max_len` - Maximum length (optional)
103    /// * `field_name` - Name of the field for error messages
104    ///
105    /// # Returns
106    ///
107    /// Result indicating whether the string length is valid
108    pub fn validate_length(
109        value: &str,
110        min_len: Option<usize>,
111        max_len: Option<usize>,
112        field_name: &str,
113    ) -> Result<()> {
114        let len = value.len();
115
116        if let Some(min) = min_len
117            && len < min
118        {
119            return Err(SerperError::validation_error(format!(
120                "{} must be at least {} characters",
121                field_name, min
122            )));
123        }
124
125        if let Some(max) = max_len
126            && len > max
127        {
128            return Err(SerperError::validation_error(format!(
129                "{} must be at most {} characters",
130                field_name, max
131            )));
132        }
133
134        Ok(())
135    }
136
137    /// Sanitizes a string by removing control characters
138    ///
139    /// # Arguments
140    ///
141    /// * `value` - The string to sanitize
142    ///
143    /// # Returns
144    ///
145    /// A sanitized string
146    pub fn sanitize(value: &str) -> String {
147        value
148            .chars()
149            .filter(|c| !c.is_control() || c.is_whitespace())
150            .collect()
151    }
152
153    /// Truncates a string to a maximum length with ellipsis
154    ///
155    /// # Arguments
156    ///
157    /// * `value` - The string to truncate
158    /// * `max_len` - Maximum length
159    ///
160    /// # Returns
161    ///
162    /// A truncated string
163    pub fn truncate(value: &str, max_len: usize) -> String {
164        if value.len() <= max_len {
165            value.to_string()
166        } else if max_len <= 3 {
167            "...".to_string()
168        } else {
169            format!("{}...", &value[..max_len - 3])
170        }
171    }
172}
173
174/// Collection utilities
175pub mod collections {
176    use super::*;
177
178    /// Merges two HashMaps, with values from the second map taking precedence
179    ///
180    /// # Arguments
181    ///
182    /// * `base` - The base HashMap
183    /// * `overlay` - The overlay HashMap
184    ///
185    /// # Returns
186    ///
187    /// A merged HashMap
188    pub fn merge_hashmaps<K, V>(base: HashMap<K, V>, overlay: HashMap<K, V>) -> HashMap<K, V>
189    where
190        K: std::hash::Hash + Eq,
191    {
192        let mut result = base;
193        result.extend(overlay);
194        result
195    }
196
197    /// Filters a HashMap by keys matching a predicate
198    ///
199    /// # Arguments
200    ///
201    /// * `map` - The HashMap to filter
202    /// * `predicate` - Function to test each key
203    ///
204    /// # Returns
205    ///
206    /// A filtered HashMap
207    pub fn filter_map_by_key<K, V, F>(map: HashMap<K, V>, predicate: F) -> HashMap<K, V>
208    where
209        K: std::hash::Hash + Eq,
210        F: Fn(&K) -> bool,
211    {
212        map.into_iter().filter(|(k, _)| predicate(k)).collect()
213    }
214}
215
216/// Retry utilities for handling transient failures
217pub mod retry {
218    use super::*;
219    use std::time::Duration;
220    use tokio::time::sleep;
221
222    /// Retry configuration
223    #[derive(Debug, Clone)]
224    pub struct RetryConfig {
225        /// Maximum number of retry attempts
226        pub max_attempts: usize,
227        /// Initial delay between retries
228        pub initial_delay: Duration,
229        /// Multiplier for exponential backoff
230        pub backoff_multiplier: f64,
231        /// Maximum delay between retries
232        pub max_delay: Duration,
233    }
234
235    impl RetryConfig {
236        /// Creates a new retry configuration with default values
237        pub fn new() -> Self {
238            Self {
239                max_attempts: 3,
240                initial_delay: Duration::from_millis(100),
241                backoff_multiplier: 2.0,
242                max_delay: Duration::from_secs(10),
243            }
244        }
245
246        /// Sets the maximum number of attempts
247        pub fn with_max_attempts(mut self, attempts: usize) -> Self {
248            self.max_attempts = attempts;
249            self
250        }
251
252        /// Sets the initial delay
253        pub fn with_initial_delay(mut self, delay: Duration) -> Self {
254            self.initial_delay = delay;
255            self
256        }
257    }
258
259    impl Default for RetryConfig {
260        fn default() -> Self {
261            Self::new()
262        }
263    }
264
265    /// Executes a function with retry logic
266    ///
267    /// # Arguments
268    ///
269    /// * `config` - Retry configuration
270    /// * `operation` - Async function to retry
271    ///
272    /// # Returns
273    ///
274    /// Result containing the operation result or final error
275    pub async fn with_retry<F, Fut, T, E>(config: RetryConfig, operation: F) -> Result<T>
276    where
277        F: Fn() -> Fut,
278        Fut: std::future::Future<Output = std::result::Result<T, E>>,
279        E: Into<SerperError>,
280    {
281        let mut last_error = None;
282        let mut delay = config.initial_delay;
283
284        for attempt in 0..config.max_attempts {
285            match operation().await {
286                Ok(result) => return Ok(result),
287                Err(error) => {
288                    last_error = Some(error.into());
289
290                    if attempt + 1 < config.max_attempts {
291                        sleep(delay).await;
292                        delay = std::cmp::min(
293                            Duration::from_millis(
294                                (delay.as_millis() as f64 * config.backoff_multiplier) as u64,
295                            ),
296                            config.max_delay,
297                        );
298                    }
299                }
300            }
301        }
302
303        Err(last_error.unwrap_or_else(|| SerperError::config_error("Unknown retry error")))
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    mod url_tests {
312        use super::*;
313
314        #[test]
315        fn test_validate_url() {
316            assert!(url::validate_url("https://example.com").is_ok());
317            assert!(url::validate_url("http://example.com").is_ok());
318            assert!(url::validate_url("").is_err());
319            assert!(url::validate_url("not-a-url").is_err());
320        }
321
322        #[test]
323        fn test_validate_https() {
324            assert!(url::validate_https("https://example.com").is_ok());
325            assert!(url::validate_https("http://example.com").is_err());
326            assert!(url::validate_https("").is_err());
327        }
328
329        #[test]
330        fn test_extract_domain() {
331            assert_eq!(
332                url::extract_domain("https://example.com/path").unwrap(),
333                "example.com"
334            );
335            assert_eq!(
336                url::extract_domain("http://sub.example.com").unwrap(),
337                "sub.example.com"
338            );
339            assert!(url::extract_domain("not-a-url").is_err());
340        }
341    }
342
343    mod string_tests {
344        use super::*;
345
346        #[test]
347        fn test_validate_non_empty() {
348            assert!(string::validate_non_empty("test", "field").is_ok());
349            assert!(string::validate_non_empty("", "field").is_err());
350            assert!(string::validate_non_empty("   ", "field").is_err());
351        }
352
353        #[test]
354        fn test_validate_length() {
355            assert!(string::validate_length("test", Some(3), Some(5), "field").is_ok());
356            assert!(string::validate_length("te", Some(3), Some(5), "field").is_err());
357            assert!(string::validate_length("toolong", Some(3), Some(5), "field").is_err());
358        }
359
360        #[test]
361        fn test_sanitize() {
362            assert_eq!(string::sanitize("test\x00string"), "teststring");
363            assert_eq!(string::sanitize("test\nstring"), "test\nstring");
364        }
365
366        #[test]
367        fn test_truncate() {
368            assert_eq!(string::truncate("short", 10), "short");
369            assert_eq!(string::truncate("toolongstring", 8), "toolo...");
370            assert_eq!(string::truncate("test", 3), "...");
371        }
372    }
373
374    mod collections_tests {
375        use super::*;
376
377        #[test]
378        fn test_merge_hashmaps() {
379            let mut base = HashMap::new();
380            base.insert("a", 1);
381            base.insert("b", 2);
382
383            let mut overlay = HashMap::new();
384            overlay.insert("b", 3);
385            overlay.insert("c", 4);
386
387            let result = collections::merge_hashmaps(base, overlay);
388            assert_eq!(result.get("a"), Some(&1));
389            assert_eq!(result.get("b"), Some(&3)); // Overlay wins
390            assert_eq!(result.get("c"), Some(&4));
391        }
392    }
393
394    mod retry_tests {
395        use crate::utils::retry::RetryConfig;
396        use std::time::Duration;
397
398        #[test]
399        fn test_retry_config() {
400            let config = RetryConfig::new()
401                .with_max_attempts(5)
402                .with_initial_delay(Duration::from_millis(50));
403
404            assert_eq!(config.max_attempts, 5);
405            assert_eq!(config.initial_delay, Duration::from_millis(50));
406        }
407    }
408}