1use crate::core::{Result, SerperError};
6use std::collections::HashMap;
7
8pub mod url {
10 use super::*;
11 use ::url::Url;
12
13 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 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 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
72pub mod string {
74 use super::*;
75
76 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 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 pub fn sanitize(value: &str) -> String {
147 value
148 .chars()
149 .filter(|c| !c.is_control() || c.is_whitespace())
150 .collect()
151 }
152
153 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
174pub mod collections {
176 use super::*;
177
178 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 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
216pub mod retry {
218 use super::*;
219 use std::time::Duration;
220 use tokio::time::sleep;
221
222 #[derive(Debug, Clone)]
224 pub struct RetryConfig {
225 pub max_attempts: usize,
227 pub initial_delay: Duration,
229 pub backoff_multiplier: f64,
231 pub max_delay: Duration,
233 }
234
235 impl RetryConfig {
236 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 pub fn with_max_attempts(mut self, attempts: usize) -> Self {
248 self.max_attempts = attempts;
249 self
250 }
251
252 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 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)); 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}