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}