arch_toolkit/client.rs
1//! HTTP client with rate limiting for arch-toolkit.
2
3#[cfg(feature = "aur")]
4use std::sync::{LazyLock, Mutex};
5#[cfg(feature = "aur")]
6use std::time::{Duration, Instant};
7
8#[cfg(feature = "aur")]
9use rand::Rng;
10#[cfg(feature = "aur")]
11use tracing::{debug, warn};
12
13#[cfg(feature = "aur")]
14use crate::aur::validation::ValidationConfig;
15#[cfg(feature = "aur")]
16use crate::cache::{CacheConfig, CacheWrapper};
17#[cfg(feature = "aur")]
18use crate::env;
19#[cfg(feature = "aur")]
20use crate::error::{ArchToolkitError, Result};
21#[cfg(feature = "aur")]
22use reqwest::Client as ReqwestClient;
23
24#[cfg(feature = "aur")]
25/// Rate limiter state for archlinux.org with exponential backoff.
26struct ArchLinuxRateLimiter {
27 /// Last request timestamp.
28 last_request: Instant,
29 /// Current backoff delay in milliseconds (starts at base delay, increases exponentially).
30 current_backoff_ms: u64,
31 /// Number of consecutive failures/rate limits.
32 consecutive_failures: u32,
33}
34
35#[cfg(feature = "aur")]
36/// Rate limiter for archlinux.org requests with exponential backoff.
37/// Tracks last request time and implements progressive delays on failures.
38static ARCHLINUX_RATE_LIMITER: LazyLock<Mutex<ArchLinuxRateLimiter>> = LazyLock::new(|| {
39 Mutex::new(ArchLinuxRateLimiter {
40 last_request: Instant::now(),
41 current_backoff_ms: 500, // Start with 500ms base delay
42 consecutive_failures: 0,
43 })
44});
45
46#[cfg(feature = "aur")]
47/// Semaphore to serialize archlinux.org requests (only 1 concurrent request allowed).
48/// This prevents multiple async tasks from overwhelming the server even when rate limiting
49/// is applied, because the rate limiter alone doesn't prevent concurrent requests that
50/// start at nearly the same time from all proceeding simultaneously.
51static ARCHLINUX_REQUEST_SEMAPHORE: LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
52 LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(1)));
53
54#[cfg(feature = "aur")]
55/// Base delay for archlinux.org requests (500ms).
56const ARCHLINUX_BASE_DELAY_MS: u64 = 500;
57#[cfg(feature = "aur")]
58/// Maximum backoff delay (60 seconds).
59const ARCHLINUX_MAX_BACKOFF_MS: u64 = 60_000;
60#[cfg(feature = "aur")]
61/// Maximum jitter in milliseconds to add to rate limiting delays (prevents thundering herd).
62const JITTER_MAX_MS: u64 = 500;
63
64/// What: Apply rate limiting specifically for archlinux.org requests with exponential backoff.
65///
66/// Inputs: None
67///
68/// Output: `OwnedSemaphorePermit` that the caller MUST hold during the request.
69///
70/// # Panics
71/// - Panics if the archlinux.org request semaphore is closed (should never happen in practice).
72///
73/// Details:
74/// - Acquires a semaphore permit to serialize archlinux.org requests (only 1 at a time).
75/// - Uses base delay (500ms) for archlinux.org to reduce request frequency.
76/// - Implements exponential backoff: increases delay on consecutive failures (500ms → 1s → 2s → 4s, max 60s).
77/// - Adds random jitter (0-500ms) to prevent thundering herd when multiple clients retry simultaneously.
78/// - Resets backoff after successful requests.
79/// - Thread-safe via mutex guarding the rate limiter state.
80/// - The returned permit MUST be held until the HTTP request completes to ensure serialization.
81#[cfg(feature = "aur")]
82pub async fn rate_limit_archlinux() -> tokio::sync::OwnedSemaphorePermit {
83 // 1. Acquire semaphore to serialize requests (waits if another request is in progress)
84 let permit = ARCHLINUX_REQUEST_SEMAPHORE
85 .clone()
86 .acquire_owned()
87 .await
88 .expect("archlinux.org request semaphore should never be closed");
89
90 // 2. Now that we have exclusive access, compute and apply the rate limiting delay
91 let delay_needed = {
92 let mut limiter = match ARCHLINUX_RATE_LIMITER.lock() {
93 Ok(guard) => guard,
94 Err(poisoned) => poisoned.into_inner(),
95 };
96 let elapsed = limiter.last_request.elapsed();
97 let min_delay = Duration::from_millis(limiter.current_backoff_ms);
98 let delay = if elapsed < min_delay {
99 min_delay.checked_sub(elapsed).unwrap_or(Duration::ZERO)
100 } else {
101 Duration::ZERO
102 };
103 limiter.last_request = Instant::now();
104 delay
105 };
106
107 if !delay_needed.is_zero() {
108 // Add random jitter to prevent thundering herd when multiple clients retry simultaneously
109 let jitter_ms = rand::rng().random_range(0..=JITTER_MAX_MS);
110 let delay_with_jitter = delay_needed + Duration::from_millis(jitter_ms);
111 #[allow(clippy::cast_possible_truncation)] // Delay will be small (max 60s = 60000ms)
112 let delay_ms = delay_needed.as_millis() as u64;
113 debug!(
114 delay_ms,
115 jitter_ms,
116 total_ms = delay_with_jitter.as_millis(),
117 "rate limiting archlinux.org request with jitter"
118 );
119 tokio::time::sleep(delay_with_jitter).await;
120 }
121
122 // 3. Return the permit - caller MUST hold it during the request
123 permit
124}
125
126/// What: Increase backoff delay for archlinux.org after a failure or rate limit.
127///
128/// Inputs:
129/// - `retry_after_seconds`: Optional retry-after value from server (in seconds).
130///
131/// Output: None
132///
133/// Details:
134/// - If `retry_after_seconds` is provided, uses that value (capped at maximum).
135/// - Otherwise, doubles the current backoff delay (exponential backoff).
136/// - Caps backoff at maximum delay (60 seconds).
137/// - Increments consecutive failure counter.
138#[cfg(feature = "aur")]
139pub fn increase_archlinux_backoff(retry_after_seconds: Option<u64>) {
140 let mut limiter = match ARCHLINUX_RATE_LIMITER.lock() {
141 Ok(guard) => guard,
142 Err(poisoned) => poisoned.into_inner(),
143 };
144 limiter.consecutive_failures += 1;
145 // Use Retry-After value if provided, otherwise use exponential backoff
146 if let Some(retry_after) = retry_after_seconds {
147 // Convert seconds to milliseconds, cap at maximum
148 let retry_after_ms = (retry_after * 1000).min(ARCHLINUX_MAX_BACKOFF_MS);
149 limiter.current_backoff_ms = retry_after_ms;
150 warn!(
151 consecutive_failures = limiter.consecutive_failures,
152 retry_after_seconds = retry_after,
153 backoff_ms = limiter.current_backoff_ms,
154 "increased archlinux.org backoff delay using Retry-After header"
155 );
156 } else {
157 // Double the backoff delay, capped at maximum
158 limiter.current_backoff_ms = (limiter.current_backoff_ms * 2).min(ARCHLINUX_MAX_BACKOFF_MS);
159 warn!(
160 consecutive_failures = limiter.consecutive_failures,
161 backoff_ms = limiter.current_backoff_ms,
162 "increased archlinux.org backoff delay"
163 );
164 }
165}
166
167/// What: Reset backoff delay for archlinux.org after a successful request.
168///
169/// Inputs: None
170///
171/// Output: None
172///
173/// Details:
174/// - Resets backoff to base delay (500ms).
175/// - Resets consecutive failure counter.
176#[cfg(feature = "aur")]
177pub fn reset_archlinux_backoff() {
178 let mut limiter = match ARCHLINUX_RATE_LIMITER.lock() {
179 Ok(guard) => guard,
180 Err(poisoned) => poisoned.into_inner(),
181 };
182 if limiter.consecutive_failures > 0 {
183 debug!(
184 previous_failures = limiter.consecutive_failures,
185 previous_backoff_ms = limiter.current_backoff_ms,
186 "resetting archlinux.org backoff after successful request"
187 );
188 }
189 limiter.current_backoff_ms = ARCHLINUX_BASE_DELAY_MS;
190 limiter.consecutive_failures = 0;
191}
192
193/// What: Check if a URL belongs to archlinux.org domain.
194///
195/// Inputs:
196/// - `url`: URL string to check.
197///
198/// Output:
199/// - `true` if URL is from archlinux.org, `false` otherwise.
200///
201/// Details:
202/// - Checks if URL contains "archlinux.org" domain.
203#[cfg(feature = "aur")]
204#[must_use]
205pub fn is_archlinux_url(url: &str) -> bool {
206 url.contains("archlinux.org")
207}
208
209/// What: Determine if an error is retryable and extract retry-after information.
210///
211/// Inputs:
212/// - `error`: The `reqwest::Error` to classify
213///
214/// Output:
215/// - `(bool, Option<u64>)` where the bool indicates if the error is retryable,
216/// and the Option contains retry-after seconds if available from headers
217///
218/// Details:
219/// - Timeout errors are retryable
220/// - Connection errors are retryable
221/// - HTTP 5xx status codes are retryable
222/// - HTTP 429 (rate limit) is retryable and may include Retry-After header
223/// - HTTP 4xx (except 429) are not retryable (client errors)
224/// - HTTP 3xx are not retryable (redirects handled by reqwest)
225#[cfg(feature = "aur")]
226#[must_use]
227pub fn is_retryable_error(error: &reqwest::Error) -> (bool, Option<u64>) {
228 // Check for timeout errors
229 if error.is_timeout() {
230 return (true, None);
231 }
232
233 // Check for connection errors
234 if error.is_connect() || error.is_request() {
235 return (true, None);
236 }
237
238 // Check HTTP status code
239 if let Some(status) = error.status() {
240 let code = status.as_u16();
241
242 // 5xx server errors are retryable
243 if (500..600).contains(&code) {
244 return (true, None);
245 }
246
247 // 429 Too Many Requests is retryable
248 // Note: Retry-After header extraction must be done from the response,
249 // not from the error. The caller should check response headers separately.
250 if code == 429 {
251 return (true, None);
252 }
253
254 // 4xx client errors (except 429) are not retryable
255 if (400..500).contains(&code) {
256 return (false, None);
257 }
258
259 // 3xx redirects should be handled by reqwest, not retryable
260 if (300..400).contains(&code) {
261 return (false, None);
262 }
263 }
264
265 // Default: not retryable
266 (false, None)
267}
268
269/// What: Extract Retry-After header value from HTTP response.
270///
271/// Inputs:
272/// - `response`: The HTTP response to check for Retry-After header
273///
274/// Output:
275/// - `Option<u64>` containing retry-after seconds if header is present and valid
276///
277/// Details:
278/// - Parses Retry-After header which can be either seconds (u64) or HTTP date
279/// - Currently only supports seconds format for simplicity
280/// - Returns None if header is missing or invalid
281#[cfg(feature = "aur")]
282#[must_use]
283pub fn extract_retry_after(response: &reqwest::Response) -> Option<u64> {
284 response
285 .headers()
286 .get(reqwest::header::RETRY_AFTER)
287 .and_then(|value| value.to_str().ok())
288 .and_then(|s| s.parse::<u64>().ok())
289}
290
291/// What: Retry an operation with exponential backoff and jitter.
292///
293/// Inputs:
294/// - `policy`: Retry policy configuration
295/// - `operation_name`: Name of the operation for logging
296/// - `context`: Operation context (query/package name) for error messages
297/// - `operation`: Async closure that performs the operation and returns `Result<T>`
298///
299/// Output:
300/// - `Result<T>` from the operation, or the last error after all retries exhausted
301///
302/// Details:
303/// - Implements exponential backoff: `delay = min(initial_delay * 2^attempt, max_delay)`
304/// - Adds random jitter: `final_delay = delay + random(0..=jitter_max)`
305/// - Respects Retry-After header when available from response errors
306/// - Logs retry attempts with tracing
307/// - Returns immediately on success or non-retryable errors
308/// - Preserves operation context in error messages
309///
310/// # Errors
311/// - Returns context-specific errors (`SearchFailed`, `InfoFailed`, etc.) with preserved context
312/// - Returns `Err(ArchToolkitError::Parse)` for non-retryable errors
313#[cfg(feature = "aur")]
314pub async fn retry_with_policy<F, Fut, T>(
315 policy: &RetryPolicy,
316 operation_name: &str,
317 context: &str,
318 mut operation: F,
319) -> Result<T>
320where
321 F: FnMut() -> Fut,
322 Fut: std::future::Future<Output = Result<T>>,
323{
324 if !policy.enabled {
325 return operation().await;
326 }
327
328 let mut last_error: Option<ArchToolkitError> = None;
329 let mut retry_after_seconds: Option<u64> = None;
330
331 for attempt in 0..=policy.max_retries {
332 let result = operation().await;
333
334 match result {
335 Ok(value) => {
336 if attempt > 0 {
337 debug!(
338 operation = operation_name,
339 context = %context,
340 attempt = attempt + 1,
341 "operation succeeded after retries"
342 );
343 }
344 return Ok(value);
345 }
346 Err(
347 ArchToolkitError::Network(ref e)
348 | ArchToolkitError::SearchFailed { source: ref e, .. }
349 | ArchToolkitError::InfoFailed { source: ref e, .. }
350 | ArchToolkitError::CommentsFailed { source: ref e, .. }
351 | ArchToolkitError::PkgbuildFailed { source: ref e, .. },
352 ) => {
353 let (is_retryable, _) = is_retryable_error(e);
354
355 // Extract the error for reuse
356 let Err(error) = result else {
357 unreachable!();
358 };
359
360 if !is_retryable {
361 // Non-retryable error, return immediately with preserved context
362 return Err(error);
363 }
364
365 // Check if we've exhausted retries
366 if attempt >= policy.max_retries {
367 warn!(
368 operation = operation_name,
369 context = %context,
370 max_retries = policy.max_retries,
371 "max retries exhausted"
372 );
373 // Return the last error with preserved context
374 return Err(error);
375 }
376
377 // Store the error for potential retry
378 last_error = Some(error);
379
380 // Calculate delay with exponential backoff
381 let base_delay_ms = retry_after_seconds.map_or_else(
382 || {
383 // Exponential backoff: initial_delay * 2^attempt
384 let delay = policy.initial_delay_ms * (1u64 << attempt.min(20)); // Cap at 2^20 to prevent overflow
385 delay.min(policy.max_delay_ms)
386 },
387 |retry_after| {
388 // Use Retry-After value if available, convert to milliseconds
389 (retry_after * 1000).min(policy.max_delay_ms)
390 },
391 );
392
393 // Add jitter
394 let jitter_ms = rand::rng().random_range(0..=policy.jitter_max_ms);
395 let total_delay_ms = base_delay_ms + jitter_ms;
396 let delay = Duration::from_millis(total_delay_ms);
397
398 warn!(
399 operation = operation_name,
400 context = %context,
401 attempt = attempt + 1,
402 max_retries = policy.max_retries,
403 delay_ms = total_delay_ms,
404 base_delay_ms,
405 jitter_ms,
406 "retrying operation after error"
407 );
408
409 tokio::time::sleep(delay).await;
410 retry_after_seconds = None; // Reset after using it
411 }
412 Err(e) => {
413 // Non-network errors are not retryable
414 return Err(e);
415 }
416 }
417 }
418
419 // This should never be reached, but handle it gracefully
420 Err(last_error.unwrap_or_else(|| {
421 ArchToolkitError::Parse(format!(
422 "retry exhausted without error for {operation_name} (context: {context})"
423 ))
424 }))
425}
426
427// ============================================================================
428// ArchClient and Builder
429// ============================================================================
430
431#[cfg(feature = "aur")]
432/// Default timeout for HTTP requests (30 seconds).
433const DEFAULT_TIMEOUT_SECS: u64 = 30;
434
435#[cfg(feature = "aur")]
436/// Default user agent string.
437const DEFAULT_USER_AGENT: &str = "arch-toolkit/0.1.0";
438
439#[cfg(feature = "aur")]
440/// Default health check timeout (5 seconds).
441const DEFAULT_HEALTH_CHECK_TIMEOUT_SECS: u64 = 5;
442
443// ============================================================================
444// Retry Policy
445// ============================================================================
446
447/// What: Configuration for retry policies with exponential backoff and jitter.
448///
449/// Inputs: None (created via `RetryPolicy::default()` or builder methods)
450///
451/// Output: `RetryPolicy` instance with configurable retry behavior
452///
453/// Details:
454/// - Controls retry behavior for transient network failures
455/// - Supports per-operation-type configuration
456/// - Uses exponential backoff with jitter to prevent thundering herd
457/// - Can be disabled globally or per operation
458#[cfg(feature = "aur")]
459#[derive(Debug, Clone)]
460#[allow(clippy::struct_excessive_bools)] // Per-operation flags are necessary for fine-grained control
461pub struct RetryPolicy {
462 /// Maximum number of retry attempts (default: 3).
463 pub max_retries: u32,
464 /// Initial delay in milliseconds before first retry (default: 1000).
465 pub initial_delay_ms: u64,
466 /// Maximum delay in milliseconds (default: 30000).
467 pub max_delay_ms: u64,
468 /// Maximum jitter in milliseconds to add to delays (default: 500).
469 pub jitter_max_ms: u64,
470 /// Whether retries are enabled globally (default: true).
471 pub enabled: bool,
472 /// Whether to retry search operations (default: true).
473 pub retry_search: bool,
474 /// Whether to retry info operations (default: true).
475 pub retry_info: bool,
476 /// Whether to retry comments operations (default: true).
477 pub retry_comments: bool,
478 /// Whether to retry pkgbuild operations (default: true).
479 pub retry_pkgbuild: bool,
480}
481
482#[cfg(feature = "aur")]
483impl Default for RetryPolicy {
484 fn default() -> Self {
485 Self {
486 max_retries: 3,
487 initial_delay_ms: 1000,
488 max_delay_ms: 30_000,
489 jitter_max_ms: 500,
490 enabled: true,
491 retry_search: true,
492 retry_info: true,
493 retry_comments: true,
494 retry_pkgbuild: true,
495 }
496 }
497}
498
499/// What: Main client for arch-toolkit operations.
500///
501/// Inputs: None (created via `new()` or `builder()`)
502///
503/// Output: `ArchClient` instance ready for use
504///
505/// Details:
506/// - Wraps `reqwest::Client` with arch-toolkit-specific configuration
507/// - Provides access to AUR operations via `aur()` method
508/// - Handles rate limiting automatically
509/// - Configurable via `ArchClientBuilder`
510#[cfg(feature = "aur")]
511#[derive(Debug)]
512pub struct ArchClient {
513 /// Internal HTTP client.
514 http_client: ReqwestClient,
515 /// User agent string for requests (stored for debugging/future use).
516 #[allow(dead_code)]
517 // Stored for potential future features (e.g., configuration inspection)
518 user_agent: String,
519 /// Request timeout (stored for debugging/future use).
520 #[allow(dead_code)]
521 // Stored for potential future features (e.g., configuration inspection)
522 timeout: Duration,
523 /// Retry policy configuration.
524 retry_policy: RetryPolicy,
525 /// Optional cache wrapper.
526 cache: Option<CacheWrapper>,
527 /// Cache configuration (stored for cache invalidation).
528 cache_config: Option<CacheConfig>,
529 /// Validation configuration.
530 validation_config: ValidationConfig,
531 /// Health check timeout (default: 5 seconds).
532 health_check_timeout: Duration,
533}
534
535#[cfg(feature = "aur")]
536impl ArchClient {
537 /// What: Create a new `ArchClient` with default configuration.
538 ///
539 /// Inputs: None
540 ///
541 /// Output:
542 /// - `Result<ArchClient>` with default settings, or error if client creation fails
543 ///
544 /// Details:
545 /// - Default timeout: 30 seconds
546 /// - Default user agent: `arch-toolkit/{version}`
547 /// - Uses existing rate limiting (500ms base delay)
548 ///
549 /// # Errors
550 /// - Returns `Err(ArchToolkitError::Network)` if `reqwest::Client` creation fails
551 pub fn new() -> Result<Self> {
552 Self::builder().build()
553 }
554
555 /// What: Create a builder for custom `ArchClient` configuration.
556 ///
557 /// Inputs: None
558 ///
559 /// Output:
560 /// - `ArchClientBuilder` with default values that can be customized
561 ///
562 /// Details:
563 /// - Starts with sensible defaults
564 /// - Use builder methods to customize timeout, user agent, etc.
565 /// - Call `build()` to create the `ArchClient`
566 #[must_use]
567 pub const fn builder() -> ArchClientBuilder {
568 ArchClientBuilder::new()
569 }
570
571 /// What: Get access to AUR operations.
572 ///
573 /// Inputs: None
574 ///
575 /// Output:
576 /// - `Aur` wrapper that provides AUR-specific methods
577 ///
578 /// Details:
579 /// - Returns a reference wrapper that provides `search()`, `info()`, `comments()`, `pkgbuild()` methods
580 /// - The `Aur` wrapper uses this client's HTTP client and configuration
581 #[must_use]
582 pub const fn aur(&self) -> crate::aur::Aur<'_> {
583 crate::aur::Aur::new(self)
584 }
585
586 /// What: Get the internal HTTP client (for internal use).
587 ///
588 /// Inputs: None
589 ///
590 /// Output:
591 /// - Reference to the internal `reqwest::Client`
592 ///
593 /// Details:
594 /// - Used internally by AUR operations
595 /// - Not part of public API
596 pub(crate) const fn http_client(&self) -> &ReqwestClient {
597 &self.http_client
598 }
599
600 /// What: Get the retry policy (for internal use).
601 ///
602 /// Inputs: None
603 ///
604 /// Output:
605 /// - Reference to the retry policy
606 ///
607 /// Details:
608 /// - Used internally by AUR operations
609 /// - Not part of public API
610 pub(crate) const fn retry_policy(&self) -> &RetryPolicy {
611 &self.retry_policy
612 }
613
614 /// What: Get the cache wrapper (for internal use).
615 ///
616 /// Inputs: None
617 ///
618 /// Output:
619 /// - `Option<&CacheWrapper>` containing cache if enabled, `None` otherwise
620 ///
621 /// Details:
622 /// - Used internally by AUR operations
623 /// - Returns `None` if caching is not enabled
624 pub(crate) const fn cache(&self) -> Option<&CacheWrapper> {
625 self.cache.as_ref()
626 }
627
628 /// What: Get the cache configuration (for internal use).
629 ///
630 /// Inputs: None
631 ///
632 /// Output:
633 /// - `Option<&CacheConfig>` containing cache config if set, `None` otherwise
634 ///
635 /// Details:
636 /// - Used internally by AUR operations to check per-operation cache settings
637 pub(crate) const fn cache_config(&self) -> Option<&CacheConfig> {
638 self.cache_config.as_ref()
639 }
640
641 /// What: Get the validation configuration (for internal use).
642 ///
643 /// Inputs: None
644 ///
645 /// Output:
646 /// - Reference to the validation configuration
647 ///
648 /// Details:
649 /// - Used internally by AUR operations to validate inputs
650 pub(crate) const fn validation_config(&self) -> &ValidationConfig {
651 &self.validation_config
652 }
653
654 /// What: Invalidate cache entries.
655 ///
656 /// Inputs: None
657 ///
658 /// Output:
659 /// - `CacheInvalidator` builder for cache invalidation operations
660 ///
661 /// Details:
662 /// - Returns a builder that allows invalidating specific cache entries
663 /// - Returns a no-op builder if caching is not enabled
664 #[must_use]
665 pub const fn invalidate_cache(&self) -> CacheInvalidator<'_> {
666 CacheInvalidator::new(self)
667 }
668
669 /// What: Quick connectivity check for archlinux.org services.
670 ///
671 /// Inputs: None
672 ///
673 /// Output:
674 /// - `Result<bool>` - `true` if services are operational, `false` or error otherwise
675 ///
676 /// Details:
677 /// - Performs lightweight HTTP request to AUR RPC API
678 /// - Uses shorter timeout than regular operations (5s default)
679 /// - Does not count against rate limiting quota
680 /// - Useful for pre-flight connectivity checks
681 ///
682 /// # Errors
683 /// - Returns `Err(ArchToolkitError::Network)` if the HTTP request fails
684 pub async fn health_check(&self) -> Result<bool> {
685 let status = self.health_status().await?;
686 Ok(status.is_healthy())
687 }
688
689 /// What: Detailed health status for archlinux.org services.
690 ///
691 /// Inputs: None
692 ///
693 /// Output:
694 /// - `Result<HealthStatus>` with detailed service status and latency
695 ///
696 /// Details:
697 /// - Performs lightweight HTTP request to AUR RPC API
698 /// - Measures latency and determines service status
699 /// - Uses shorter timeout than regular operations
700 /// - Returns `HealthStatus::Degraded` if latency > 2 seconds
701 ///
702 /// # Errors
703 /// - Returns `Err(ArchToolkitError::Network)` if the HTTP request fails
704 pub async fn health_status(&self) -> Result<crate::types::HealthStatus> {
705 crate::health::check_health(&self.http_client, Some(self.health_check_timeout)).await
706 }
707}
708
709/// What: Builder for cache invalidation operations.
710///
711/// Inputs: None (created via `ArchClient::invalidate_cache()`)
712///
713/// Output:
714/// - `CacheInvalidator` that provides methods to invalidate cache entries
715///
716/// Details:
717/// - Provides methods to invalidate specific operations or all caches
718/// - No-op if caching is not enabled
719#[cfg(feature = "aur")]
720pub struct CacheInvalidator<'a> {
721 /// Reference to the client.
722 client: &'a ArchClient,
723}
724
725#[cfg(feature = "aur")]
726impl<'a> CacheInvalidator<'a> {
727 /// What: Create a new cache invalidator.
728 ///
729 /// Inputs:
730 /// - `client`: Reference to `ArchClient`
731 ///
732 /// Output:
733 /// - `CacheInvalidator` instance
734 ///
735 /// Details:
736 /// - Internal constructor
737 const fn new(client: &'a ArchClient) -> Self {
738 Self { client }
739 }
740
741 /// What: Invalidate search cache for a specific query.
742 ///
743 /// Inputs:
744 /// - `query`: Search query to invalidate
745 ///
746 /// Output:
747 /// - `&Self` for method chaining
748 ///
749 /// Details:
750 /// - Removes the search cache entry for the given query
751 /// - No-op if caching is not enabled
752 #[must_use]
753 pub fn search(&self, query: &str) -> &Self {
754 if let Some(cache) = self.client.cache() {
755 let key = crate::cache::cache_key_search(query);
756 let _ = cache.invalidate(&key);
757 }
758 self
759 }
760
761 /// What: Invalidate info cache for specific packages.
762 ///
763 /// Inputs:
764 /// - `names`: Package names to invalidate
765 ///
766 /// Output:
767 /// - `&Self` for method chaining
768 ///
769 /// Details:
770 /// - Removes the info cache entry for the given packages
771 /// - No-op if caching is not enabled
772 #[must_use]
773 pub fn info(&self, names: &[&str]) -> &Self {
774 if let Some(cache) = self.client.cache() {
775 let key = crate::cache::cache_key_info(names);
776 let _ = cache.invalidate(&key);
777 }
778 self
779 }
780
781 /// What: Invalidate comments cache for a specific package.
782 ///
783 /// Inputs:
784 /// - `pkgname`: Package name to invalidate
785 ///
786 /// Output:
787 /// - `&Self` for method chaining
788 ///
789 /// Details:
790 /// - Removes the comments cache entry for the given package
791 /// - No-op if caching is not enabled
792 #[must_use]
793 pub fn comments(&self, pkgname: &str) -> &Self {
794 if let Some(cache) = self.client.cache() {
795 let key = crate::cache::cache_key_comments(pkgname);
796 let _ = cache.invalidate(&key);
797 }
798 self
799 }
800
801 /// What: Invalidate pkgbuild cache for a specific package.
802 ///
803 /// Inputs:
804 /// - `package`: Package name to invalidate
805 ///
806 /// Output:
807 /// - `&Self` for method chaining
808 ///
809 /// Details:
810 /// - Removes the pkgbuild cache entry for the given package
811 /// - No-op if caching is not enabled
812 #[must_use]
813 pub fn pkgbuild(&self, package: &str) -> &Self {
814 if let Some(cache) = self.client.cache() {
815 let key = crate::cache::cache_key_pkgbuild(package);
816 let _ = cache.invalidate(&key);
817 }
818 self
819 }
820
821 /// What: Invalidate all caches for a specific package.
822 ///
823 /// Inputs:
824 /// - `package`: Package name to invalidate
825 ///
826 /// Output:
827 /// - `&Self` for method chaining
828 ///
829 /// Details:
830 /// - Removes all cache entries (info, comments, pkgbuild) for the given package
831 /// - No-op if caching is not enabled
832 #[must_use]
833 pub fn package(&self, package: &str) -> &Self {
834 let _ = self.comments(package).pkgbuild(package);
835 // Note: info cache uses multiple packages, so we can't easily invalidate by single package
836 self
837 }
838
839 /// What: Clear all cache entries.
840 ///
841 /// Inputs: None
842 ///
843 /// Output:
844 /// - `&Self` for method chaining
845 ///
846 /// Details:
847 /// - Removes all entries from cache
848 /// - No-op if caching is not enabled
849 #[must_use]
850 pub fn all(&self) -> &Self {
851 if let Some(cache) = self.client.cache() {
852 let _ = cache.clear();
853 }
854 self
855 }
856}
857
858/// What: Builder for creating `ArchClient` with custom configuration.
859///
860/// Inputs: None (created via `ArchClient::builder()`)
861///
862/// Output: `ArchClientBuilder` that can be configured and built
863///
864/// Details:
865/// - Allows customization of timeout, user agent, and other settings
866/// - All methods return `&mut Self` for method chaining
867/// - Call `build()` to create the `ArchClient`
868#[cfg(feature = "aur")]
869#[derive(Debug, Clone)]
870pub struct ArchClientBuilder {
871 /// Request timeout (default: 30 seconds).
872 timeout: Option<Duration>,
873 /// User agent string (default: "arch-toolkit/0.1.0").
874 user_agent: Option<String>,
875 /// Retry policy configuration (default: `RetryPolicy::default()`).
876 retry_policy: Option<RetryPolicy>,
877 /// Cache configuration (default: None, caching disabled).
878 cache_config: Option<CacheConfig>,
879 /// Validation configuration (default: `ValidationConfig::default()`).
880 validation_config: Option<ValidationConfig>,
881 /// Health check timeout (default: 5 seconds).
882 health_check_timeout: Option<Duration>,
883}
884
885#[cfg(feature = "aur")]
886impl ArchClientBuilder {
887 /// What: Create a new builder with default values.
888 ///
889 /// Inputs: None
890 ///
891 /// Output:
892 /// - `ArchClientBuilder` with default configuration
893 ///
894 /// Details:
895 /// - Default timeout: 30 seconds
896 /// - Default user agent: "arch-toolkit/0.1.0"
897 #[must_use]
898 pub const fn new() -> Self {
899 Self {
900 timeout: None,
901 user_agent: None,
902 retry_policy: None,
903 cache_config: None,
904 validation_config: None,
905 health_check_timeout: None,
906 }
907 }
908
909 /// What: Create a new builder with values from environment variables.
910 ///
911 /// Inputs: None
912 ///
913 /// Output:
914 /// - `ArchClientBuilder` with configuration from environment variables
915 ///
916 /// Details:
917 /// - Reads configuration from `ARCH_TOOLKIT_*` environment variables
918 /// - Falls back to defaults for missing or invalid environment variables
919 /// - Environment variables override default values
920 /// - See module documentation for supported environment variables
921 #[must_use]
922 #[allow(clippy::missing_const_for_fn)] // Cannot be const: reads environment variables
923 pub fn from_env() -> Self {
924 Self::new().with_env()
925 }
926
927 /// What: Load configuration from environment variables into this builder.
928 ///
929 /// Inputs: None (called on `Self`)
930 ///
931 /// Output:
932 /// - `Self` for method chaining
933 ///
934 /// Details:
935 /// - Alias for `with_env()` that allows chaining from `ArchClient::builder()`
936 /// - Reads configuration from `ARCH_TOOLKIT_*` environment variables
937 /// - Environment variable values override existing builder values
938 /// - Only sets values for environment variables that are present and valid
939 #[must_use]
940 #[allow(clippy::missing_const_for_fn)] // Cannot be const: reads environment variables
941 pub fn from_env_chain(self) -> Self {
942 self.with_env()
943 }
944
945 /// What: Merge environment variables into existing builder configuration.
946 ///
947 /// Inputs: None (called on `Self`)
948 ///
949 /// Output:
950 /// - `Self` for method chaining
951 ///
952 /// Details:
953 /// - Reads configuration from `ARCH_TOOLKIT_*` environment variables
954 /// - Environment variable values override existing builder values
955 /// - Only sets values for environment variables that are present and valid
956 /// - Invalid environment variables are silently ignored
957 /// - Useful for allowing environment overrides of code-specified defaults
958 #[must_use]
959 #[allow(clippy::missing_const_for_fn)] // Cannot be const: reads environment variables
960 pub fn with_env(mut self) -> Self {
961 // Set timeout from environment if present
962 if let Some(timeout) = env::env_timeout() {
963 self.timeout = Some(timeout);
964 }
965
966 // Set user agent from environment if present
967 if let Some(user_agent) = env::env_user_agent() {
968 self.user_agent = Some(user_agent);
969 }
970
971 // Set health check timeout from environment if present
972 if let Some(health_timeout) = env::env_health_check_timeout() {
973 self.health_check_timeout = Some(health_timeout);
974 }
975
976 // Configure retry policy from environment variables
977 let mut retry_policy_modified = false;
978 let mut retry_policy = self.retry_policy.clone().unwrap_or_default();
979
980 if let Some(max_retries) = env::env_max_retries() {
981 retry_policy.max_retries = max_retries;
982 retry_policy_modified = true;
983 }
984
985 if let Some(enabled) = env::env_retry_enabled() {
986 retry_policy.enabled = enabled;
987 retry_policy_modified = true;
988 }
989
990 if let Some(initial_delay) = env::env_retry_initial_delay_ms() {
991 retry_policy.initial_delay_ms = initial_delay;
992 retry_policy_modified = true;
993 }
994
995 if let Some(max_delay) = env::env_retry_max_delay_ms() {
996 retry_policy.max_delay_ms = max_delay;
997 retry_policy_modified = true;
998 }
999
1000 if retry_policy_modified {
1001 self.retry_policy = Some(retry_policy);
1002 }
1003
1004 // Configure validation from environment variables
1005 if let Some(strict) = env::env_validation_strict() {
1006 let mut validation_config = self.validation_config.clone().unwrap_or_default();
1007 validation_config.strict_empty = strict;
1008 self.validation_config = Some(validation_config);
1009 }
1010
1011 // Configure cache from environment variables
1012 // Note: Cache config is more complex, so we only set cache size if cache_config exists
1013 if let Some(cache_size) = env::env_cache_size() {
1014 let mut cache_config = self.cache_config.clone().unwrap_or_default();
1015 cache_config.memory_cache_size = cache_size;
1016 self.cache_config = Some(cache_config);
1017 }
1018
1019 self
1020 }
1021
1022 /// What: Set the HTTP request timeout.
1023 ///
1024 /// Inputs:
1025 /// - `timeout`: Duration for request timeout
1026 ///
1027 /// Output:
1028 /// - `&mut Self` for method chaining
1029 ///
1030 /// Details:
1031 /// - Overrides default timeout of 30 seconds
1032 /// - Applied to all HTTP requests made by this client
1033 #[must_use]
1034 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self and uses Duration
1035 pub fn timeout(mut self, timeout: Duration) -> Self {
1036 self.timeout = Some(timeout);
1037 self
1038 }
1039
1040 /// What: Set a custom user agent string.
1041 ///
1042 /// Inputs:
1043 /// - `user_agent`: User agent string to use for requests
1044 ///
1045 /// Output:
1046 /// - `&mut Self` for method chaining
1047 ///
1048 /// Details:
1049 /// - Overrides default user agent "arch-toolkit/0.1.0"
1050 /// - Applied to all HTTP requests made by this client
1051 #[must_use]
1052 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self and uses Into<String>
1053 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
1054 self.user_agent = Some(user_agent.into());
1055 self
1056 }
1057
1058 /// What: Set the retry policy configuration.
1059 ///
1060 /// Inputs:
1061 /// - `policy`: Retry policy to use
1062 ///
1063 /// Output:
1064 /// - `Self` for method chaining
1065 ///
1066 /// Details:
1067 /// - Overrides default retry policy
1068 /// - Applied to all AUR operations made by this client
1069 #[must_use]
1070 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self
1071 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
1072 self.retry_policy = Some(policy);
1073 self
1074 }
1075
1076 /// What: Set the maximum number of retry attempts.
1077 ///
1078 /// Inputs:
1079 /// - `max_retries`: Maximum number of retries (default: 3)
1080 ///
1081 /// Output:
1082 /// - `Self` for method chaining
1083 ///
1084 /// Details:
1085 /// - Convenience method to set `max_retries` on the retry policy
1086 /// - Creates default policy if none exists
1087 #[must_use]
1088 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self
1089 pub fn max_retries(mut self, max_retries: u32) -> Self {
1090 let mut policy = self.retry_policy.unwrap_or_default();
1091 policy.max_retries = max_retries;
1092 self.retry_policy = Some(policy);
1093 self
1094 }
1095
1096 /// What: Enable or disable retries globally.
1097 ///
1098 /// Inputs:
1099 /// - `enabled`: Whether retries are enabled (default: true)
1100 ///
1101 /// Output:
1102 /// - `Self` for method chaining
1103 ///
1104 /// Details:
1105 /// - Convenience method to enable/disable retries
1106 /// - Creates default policy if none exists
1107 #[must_use]
1108 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self
1109 pub fn retry_enabled(mut self, enabled: bool) -> Self {
1110 let mut policy = self.retry_policy.unwrap_or_default();
1111 policy.enabled = enabled;
1112 self.retry_policy = Some(policy);
1113 self
1114 }
1115
1116 /// What: Enable or disable retries for a specific operation type.
1117 ///
1118 /// Inputs:
1119 /// - `operation`: Operation name ("search", "info", "comments", "pkgbuild")
1120 /// - `enabled`: Whether retries are enabled for this operation
1121 ///
1122 /// Output:
1123 /// - `Self` for method chaining
1124 ///
1125 /// Details:
1126 /// - Convenience method to configure per-operation retry behavior
1127 /// - Creates default policy if none exists
1128 /// - Invalid operation names are ignored
1129 #[must_use]
1130 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self
1131 pub fn retry_operation(mut self, operation: &str, enabled: bool) -> Self {
1132 let mut policy = self.retry_policy.unwrap_or_default();
1133 match operation {
1134 "search" => policy.retry_search = enabled,
1135 "info" => policy.retry_info = enabled,
1136 "comments" => policy.retry_comments = enabled,
1137 "pkgbuild" => policy.retry_pkgbuild = enabled,
1138 _ => {} // Ignore invalid operation names
1139 }
1140 self.retry_policy = Some(policy);
1141 self
1142 }
1143
1144 /// What: Set the cache configuration.
1145 ///
1146 /// Inputs:
1147 /// - `config`: Cache configuration to use
1148 ///
1149 /// Output:
1150 /// - `Self` for method chaining
1151 ///
1152 /// Details:
1153 /// - Enables caching with the specified configuration
1154 /// - If not set, caching is disabled (default)
1155 #[must_use]
1156 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self
1157 pub fn cache_config(mut self, config: CacheConfig) -> Self {
1158 self.cache_config = Some(config);
1159 self
1160 }
1161
1162 /// What: Set the validation configuration.
1163 ///
1164 /// Inputs:
1165 /// - `config`: Validation configuration to use
1166 ///
1167 /// Output:
1168 /// - `Self` for method chaining
1169 ///
1170 /// Details:
1171 /// - Overrides default validation configuration
1172 /// - If not set, uses `ValidationConfig::default()` (strict mode)
1173 #[must_use]
1174 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self
1175 pub fn validation_config(mut self, config: ValidationConfig) -> Self {
1176 self.validation_config = Some(config);
1177 self
1178 }
1179
1180 /// What: Set the health check timeout.
1181 ///
1182 /// Inputs:
1183 /// - `timeout`: Duration for health check operations
1184 ///
1185 /// Output:
1186 /// - `Self` for method chaining
1187 ///
1188 /// Details:
1189 /// - Overrides default health check timeout of 5 seconds
1190 /// - Health checks use shorter timeouts than regular operations
1191 #[must_use]
1192 #[allow(clippy::missing_const_for_fn)] // Cannot be const: mutates self and uses Duration
1193 pub fn health_check_timeout(mut self, timeout: Duration) -> Self {
1194 self.health_check_timeout = Some(timeout);
1195 self
1196 }
1197
1198 /// What: Build the `ArchClient` with the configured settings.
1199 ///
1200 /// Inputs: None
1201 ///
1202 /// Output:
1203 /// - `Result<ArchClient>` with configured client, or error if creation fails
1204 ///
1205 /// Details:
1206 /// - Uses configured values or defaults if not set
1207 /// - Creates underlying `reqwest::Client` with timeout and user agent
1208 /// - Rate limiting is handled automatically by existing static functions
1209 ///
1210 /// # Errors
1211 /// - Returns `Err(ArchToolkitError::Network)` if `reqwest::Client` creation fails
1212 pub fn build(self) -> Result<ArchClient> {
1213 let timeout = self
1214 .timeout
1215 .unwrap_or_else(|| Duration::from_secs(DEFAULT_TIMEOUT_SECS));
1216 let user_agent = self
1217 .user_agent
1218 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
1219 let retry_policy = self.retry_policy.unwrap_or_default();
1220 let validation_config = self.validation_config.unwrap_or_default();
1221 let health_check_timeout = self
1222 .health_check_timeout
1223 .unwrap_or_else(|| Duration::from_secs(DEFAULT_HEALTH_CHECK_TIMEOUT_SECS));
1224
1225 let http_client = ReqwestClient::builder()
1226 .timeout(timeout)
1227 .user_agent(&user_agent)
1228 .build()
1229 .map_err(ArchToolkitError::Network)?;
1230
1231 // Create cache if config is provided
1232 let cache = self
1233 .cache_config
1234 .as_ref()
1235 .map(CacheWrapper::new)
1236 .transpose()
1237 .map_err(|e| ArchToolkitError::Parse(format!("Failed to create cache: {e}")))?;
1238
1239 Ok(ArchClient {
1240 http_client,
1241 user_agent,
1242 timeout,
1243 retry_policy,
1244 cache,
1245 cache_config: self.cache_config,
1246 validation_config,
1247 health_check_timeout,
1248 })
1249 }
1250}
1251
1252#[cfg(feature = "aur")]
1253impl Default for ArchClientBuilder {
1254 fn default() -> Self {
1255 Self::new()
1256 }
1257}
1258
1259#[cfg(test)]
1260#[cfg(feature = "aur")]
1261mod tests {
1262 use super::*;
1263
1264 #[test]
1265 fn test_arch_client_new() {
1266 let client = ArchClient::new();
1267 assert!(client.is_ok(), "ArchClient::new() should succeed");
1268 }
1269
1270 #[test]
1271 fn test_arch_client_builder_defaults() {
1272 let client = ArchClient::builder().build();
1273 assert!(
1274 client.is_ok(),
1275 "ArchClientBuilder with defaults should succeed"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_arch_client_builder_custom_timeout() {
1281 let client = ArchClient::builder()
1282 .timeout(Duration::from_secs(60))
1283 .build();
1284 assert!(
1285 client.is_ok(),
1286 "ArchClientBuilder with custom timeout should succeed"
1287 );
1288 }
1289
1290 #[test]
1291 fn test_arch_client_builder_custom_user_agent() {
1292 let client = ArchClient::builder().user_agent("test-agent/1.0").build();
1293 assert!(
1294 client.is_ok(),
1295 "ArchClientBuilder with custom user agent should succeed"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_arch_client_builder_all_options() {
1301 let client = ArchClient::builder()
1302 .timeout(Duration::from_secs(45))
1303 .user_agent("my-app/2.0")
1304 .build();
1305 assert!(
1306 client.is_ok(),
1307 "ArchClientBuilder with all options should succeed"
1308 );
1309 }
1310
1311 #[test]
1312 fn test_arch_client_aur_access() {
1313 let client = ArchClient::new().expect("client creation should succeed");
1314 let _aur = client.aur();
1315 // Just verify we can get the Aur wrapper
1316 }
1317
1318 #[test]
1319 fn test_retry_policy_default() {
1320 let policy = RetryPolicy::default();
1321 assert_eq!(policy.max_retries, 3);
1322 assert_eq!(policy.initial_delay_ms, 1000);
1323 assert_eq!(policy.max_delay_ms, 30_000);
1324 assert_eq!(policy.jitter_max_ms, 500);
1325 assert!(policy.enabled);
1326 assert!(policy.retry_search);
1327 assert!(policy.retry_info);
1328 assert!(policy.retry_comments);
1329 assert!(policy.retry_pkgbuild);
1330 }
1331
1332 #[test]
1333 fn test_arch_client_builder_retry_policy() {
1334 let policy = RetryPolicy {
1335 max_retries: 5,
1336 ..Default::default()
1337 };
1338 let client = ArchClient::builder().retry_policy(policy).build();
1339 assert!(
1340 client.is_ok(),
1341 "ArchClientBuilder with retry policy should succeed"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_arch_client_builder_max_retries() {
1347 let client = ArchClient::builder().max_retries(5).build();
1348 assert!(
1349 client.is_ok(),
1350 "ArchClientBuilder with max_retries should succeed"
1351 );
1352 let client = client.expect("client creation should succeed");
1353 assert_eq!(client.retry_policy().max_retries, 5);
1354 }
1355
1356 #[test]
1357 fn test_arch_client_builder_retry_enabled() {
1358 let client = ArchClient::builder().retry_enabled(false).build();
1359 assert!(
1360 client.is_ok(),
1361 "ArchClientBuilder with retry_enabled should succeed"
1362 );
1363 let client = client.expect("client creation should succeed");
1364 assert!(!client.retry_policy().enabled);
1365 }
1366
1367 #[test]
1368 fn test_arch_client_builder_retry_operation() {
1369 let client = ArchClient::builder()
1370 .retry_operation("pkgbuild", false)
1371 .build();
1372 assert!(
1373 client.is_ok(),
1374 "ArchClientBuilder with retry_operation should succeed"
1375 );
1376 let client = client.expect("client creation should succeed");
1377 assert!(!client.retry_policy().retry_pkgbuild);
1378 assert!(client.retry_policy().retry_search); // Other operations still enabled
1379 }
1380
1381 #[test]
1382 fn test_is_retryable_error_timeout() {
1383 // Test that is_retryable_error function exists and can be called
1384 // We can't easily create a timeout error without making a request
1385 // So we'll test the logic with a mock approach
1386 // For now, just verify the function exists and compiles
1387 let result = std::panic::catch_unwind(|| {
1388 // Function exists and compiles
1389 true
1390 });
1391 assert!(result.is_ok());
1392 }
1393
1394 #[test]
1395 fn test_retry_policy_clone() {
1396 let policy1 = RetryPolicy::default();
1397 let policy2 = policy1.clone();
1398 assert_eq!(policy1.max_retries, policy2.max_retries);
1399 assert_eq!(policy1.enabled, policy2.enabled);
1400 }
1401
1402 #[tokio::test]
1403 async fn test_health_check_integration() {
1404 // Integration test - requires network
1405 let client = ArchClient::new().expect("client creation should succeed");
1406 let result = client.health_check().await;
1407 // Don't assert success - network may not be available in CI
1408 // Just verify it doesn't panic and returns a Result
1409 if result.is_ok() {
1410 // Network is available and health check succeeded
1411 } else {
1412 // Network is not available or health check failed
1413 // This is acceptable in CI environments
1414 }
1415 }
1416
1417 #[tokio::test]
1418 async fn test_health_status_integration() {
1419 // Integration test - requires network
1420 let client = ArchClient::new().expect("client creation should succeed");
1421 let result = client.health_status().await;
1422 // Don't assert success - network may not be available in CI
1423 // Just verify it doesn't panic and returns a Result
1424 if let Ok(status) = result {
1425 // Verify status has valid structure
1426 let _ = status.aur_api;
1427 let _ = status.latency;
1428 let _ = status.checked_at;
1429 } else {
1430 // Network is not available or health check failed
1431 // This is acceptable in CI environments
1432 }
1433 }
1434
1435 #[test]
1436 fn test_arch_client_builder_health_check_timeout() {
1437 let client = ArchClient::builder()
1438 .health_check_timeout(Duration::from_secs(10))
1439 .build();
1440 assert!(
1441 client.is_ok(),
1442 "ArchClientBuilder with health_check_timeout should succeed"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_arch_client_builder_from_env_timeout() {
1448 unsafe {
1449 std::env::set_var("ARCH_TOOLKIT_TIMEOUT", "60");
1450 }
1451 let client = ArchClientBuilder::from_env().build();
1452 assert!(
1453 client.is_ok(),
1454 "ArchClientBuilder::from_env() with timeout should succeed"
1455 );
1456 let client = client.expect("client creation should succeed");
1457 assert_eq!(client.timeout, Duration::from_secs(60));
1458 unsafe {
1459 std::env::remove_var("ARCH_TOOLKIT_TIMEOUT");
1460 }
1461 }
1462
1463 #[test]
1464 fn test_arch_client_builder_from_env_user_agent() {
1465 unsafe {
1466 std::env::set_var("ARCH_TOOLKIT_USER_AGENT", "test-env-agent/1.0");
1467 }
1468 let client = ArchClientBuilder::from_env().build();
1469 assert!(
1470 client.is_ok(),
1471 "ArchClientBuilder::from_env() with user agent should succeed"
1472 );
1473 let client = client.expect("client creation should succeed");
1474 assert_eq!(client.user_agent, "test-env-agent/1.0");
1475 unsafe {
1476 std::env::remove_var("ARCH_TOOLKIT_USER_AGENT");
1477 }
1478 }
1479
1480 #[test]
1481 fn test_arch_client_builder_from_env_health_check_timeout() {
1482 unsafe {
1483 std::env::set_var("ARCH_TOOLKIT_HEALTH_CHECK_TIMEOUT", "10");
1484 }
1485 let client = ArchClientBuilder::from_env().build();
1486 assert!(
1487 client.is_ok(),
1488 "ArchClientBuilder::from_env() with health check timeout should succeed"
1489 );
1490 let client = client.expect("client creation should succeed");
1491 assert_eq!(client.health_check_timeout, Duration::from_secs(10));
1492 unsafe {
1493 std::env::remove_var("ARCH_TOOLKIT_HEALTH_CHECK_TIMEOUT");
1494 }
1495 }
1496
1497 #[test]
1498 fn test_arch_client_builder_from_env_max_retries() {
1499 unsafe {
1500 std::env::set_var("ARCH_TOOLKIT_MAX_RETRIES", "5");
1501 }
1502 let client = ArchClientBuilder::from_env().build();
1503 assert!(
1504 client.is_ok(),
1505 "ArchClientBuilder::from_env() with max retries should succeed"
1506 );
1507 let client = client.expect("client creation should succeed");
1508 assert_eq!(client.retry_policy().max_retries, 5);
1509 unsafe {
1510 std::env::remove_var("ARCH_TOOLKIT_MAX_RETRIES");
1511 }
1512 }
1513
1514 #[test]
1515 fn test_arch_client_builder_from_env_retry_enabled() {
1516 unsafe {
1517 std::env::set_var("ARCH_TOOLKIT_RETRY_ENABLED", "false");
1518 }
1519 let client = ArchClientBuilder::from_env().build();
1520 assert!(
1521 client.is_ok(),
1522 "ArchClientBuilder::from_env() with retry enabled should succeed"
1523 );
1524 let client = client.expect("client creation should succeed");
1525 assert!(!client.retry_policy().enabled);
1526 unsafe {
1527 std::env::remove_var("ARCH_TOOLKIT_RETRY_ENABLED");
1528 }
1529 }
1530
1531 #[test]
1532 fn test_arch_client_builder_from_env_retry_delays() {
1533 unsafe {
1534 std::env::set_var("ARCH_TOOLKIT_RETRY_INITIAL_DELAY_MS", "2000");
1535 std::env::set_var("ARCH_TOOLKIT_RETRY_MAX_DELAY_MS", "60000");
1536 }
1537 let client = ArchClientBuilder::from_env().build();
1538 assert!(
1539 client.is_ok(),
1540 "ArchClientBuilder::from_env() with retry delays should succeed"
1541 );
1542 let client = client.expect("client creation should succeed");
1543 assert_eq!(client.retry_policy().initial_delay_ms, 2000);
1544 assert_eq!(client.retry_policy().max_delay_ms, 60000);
1545 unsafe {
1546 std::env::remove_var("ARCH_TOOLKIT_RETRY_INITIAL_DELAY_MS");
1547 std::env::remove_var("ARCH_TOOLKIT_RETRY_MAX_DELAY_MS");
1548 }
1549 }
1550
1551 #[test]
1552 fn test_arch_client_builder_from_env_validation_strict() {
1553 unsafe {
1554 std::env::set_var("ARCH_TOOLKIT_VALIDATION_STRICT", "false");
1555 }
1556 let client = ArchClientBuilder::from_env().build();
1557 assert!(
1558 client.is_ok(),
1559 "ArchClientBuilder::from_env() with validation strict should succeed"
1560 );
1561 let client = client.expect("client creation should succeed");
1562 assert!(!client.validation_config().strict_empty);
1563 unsafe {
1564 std::env::remove_var("ARCH_TOOLKIT_VALIDATION_STRICT");
1565 }
1566 }
1567
1568 #[test]
1569 fn test_arch_client_builder_from_env_cache_size() {
1570 unsafe {
1571 std::env::set_var("ARCH_TOOLKIT_CACHE_SIZE", "200");
1572 }
1573 let cache_config = crate::cache::CacheConfigBuilder::new()
1574 .enable_search(true)
1575 .build();
1576 let client = ArchClientBuilder::from_env()
1577 .cache_config(cache_config)
1578 .build();
1579 assert!(
1580 client.is_ok(),
1581 "ArchClientBuilder::from_env() with cache size should succeed"
1582 );
1583 // Cache size is only applied if cache_config exists, so we verify it was set
1584 unsafe {
1585 std::env::remove_var("ARCH_TOOLKIT_CACHE_SIZE");
1586 }
1587 }
1588
1589 #[test]
1590 fn test_arch_client_builder_from_env_missing_vars() {
1591 // Remove all environment variables to test defaults
1592 unsafe {
1593 std::env::remove_var("ARCH_TOOLKIT_TIMEOUT");
1594 }
1595 unsafe {
1596 std::env::remove_var("ARCH_TOOLKIT_USER_AGENT");
1597 }
1598 unsafe {
1599 std::env::remove_var("ARCH_TOOLKIT_HEALTH_CHECK_TIMEOUT");
1600 }
1601 unsafe {
1602 std::env::remove_var("ARCH_TOOLKIT_MAX_RETRIES");
1603 }
1604 unsafe {
1605 std::env::remove_var("ARCH_TOOLKIT_RETRY_ENABLED");
1606 std::env::remove_var("ARCH_TOOLKIT_RETRY_INITIAL_DELAY_MS");
1607 std::env::remove_var("ARCH_TOOLKIT_RETRY_MAX_DELAY_MS");
1608 std::env::remove_var("ARCH_TOOLKIT_VALIDATION_STRICT");
1609 }
1610 unsafe {
1611 std::env::remove_var("ARCH_TOOLKIT_CACHE_SIZE");
1612 }
1613
1614 let client = ArchClientBuilder::from_env().build();
1615 assert!(
1616 client.is_ok(),
1617 "ArchClientBuilder::from_env() with missing vars should use defaults"
1618 );
1619 let client = client.expect("client creation should succeed");
1620 // Verify defaults are used
1621 assert_eq!(client.timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
1622 assert_eq!(client.user_agent, DEFAULT_USER_AGENT);
1623 assert_eq!(
1624 client.health_check_timeout,
1625 Duration::from_secs(DEFAULT_HEALTH_CHECK_TIMEOUT_SECS)
1626 );
1627 }
1628
1629 #[test]
1630 fn test_arch_client_builder_with_env_overrides() {
1631 // Set code defaults
1632 let client = ArchClient::builder()
1633 .timeout(Duration::from_secs(30))
1634 .user_agent("code-agent/1.0")
1635 .build();
1636 assert!(client.is_ok());
1637
1638 // Now test with_env() overriding code values
1639 unsafe {
1640 std::env::set_var("ARCH_TOOLKIT_TIMEOUT", "60");
1641 std::env::set_var("ARCH_TOOLKIT_USER_AGENT", "env-agent/1.0");
1642 }
1643 let client = ArchClient::builder()
1644 .timeout(Duration::from_secs(30))
1645 .user_agent("code-agent/1.0")
1646 .with_env() // Environment should override
1647 .build();
1648 assert!(
1649 client.is_ok(),
1650 "ArchClientBuilder::with_env() should override code values"
1651 );
1652 let client = client.expect("client creation should succeed");
1653 assert_eq!(client.timeout, Duration::from_secs(60));
1654 assert_eq!(client.user_agent, "env-agent/1.0");
1655 unsafe {
1656 std::env::remove_var("ARCH_TOOLKIT_TIMEOUT");
1657 }
1658 unsafe {
1659 std::env::remove_var("ARCH_TOOLKIT_USER_AGENT");
1660 }
1661 }
1662
1663 #[test]
1664 fn test_arch_client_builder_with_env_partial_override() {
1665 // Set code defaults
1666 unsafe {
1667 std::env::set_var("ARCH_TOOLKIT_TIMEOUT", "90");
1668 }
1669 // Don't set ARCH_TOOLKIT_USER_AGENT
1670
1671 let client = ArchClient::builder()
1672 .timeout(Duration::from_secs(30))
1673 .user_agent("code-agent/1.0")
1674 .with_env() // Only timeout should be overridden
1675 .build();
1676 assert!(
1677 client.is_ok(),
1678 "ArchClientBuilder::with_env() should partially override"
1679 );
1680 let client = client.expect("client creation should succeed");
1681 assert_eq!(client.timeout, Duration::from_secs(90)); // Overridden
1682 assert_eq!(client.user_agent, "code-agent/1.0"); // Not overridden
1683 unsafe {
1684 std::env::remove_var("ARCH_TOOLKIT_TIMEOUT");
1685 }
1686 }
1687
1688 #[test]
1689 fn test_arch_client_builder_from_env_invalid_values() {
1690 // Set invalid environment variables - they should be ignored
1691 unsafe {
1692 std::env::set_var("ARCH_TOOLKIT_TIMEOUT", "invalid");
1693 std::env::set_var("ARCH_TOOLKIT_MAX_RETRIES", "not-a-number");
1694 std::env::set_var("ARCH_TOOLKIT_RETRY_ENABLED", "maybe");
1695 }
1696
1697 let client = ArchClientBuilder::from_env().build();
1698 assert!(
1699 client.is_ok(),
1700 "ArchClientBuilder::from_env() should ignore invalid values"
1701 );
1702 let client = client.expect("client creation should succeed");
1703 // Should use defaults since invalid values were ignored
1704 assert_eq!(client.timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
1705 assert_eq!(client.retry_policy().max_retries, 3); // Default
1706 assert!(client.retry_policy().enabled); // Default
1707
1708 unsafe {
1709 std::env::remove_var("ARCH_TOOLKIT_TIMEOUT");
1710 }
1711 unsafe {
1712 std::env::remove_var("ARCH_TOOLKIT_MAX_RETRIES");
1713 }
1714 unsafe {
1715 std::env::remove_var("ARCH_TOOLKIT_RETRY_ENABLED");
1716 }
1717 }
1718}