Skip to main content

perfgate_client/
config.rs

1//! Client configuration types.
2//!
3//! This module defines configuration options for the baseline client,
4//! including authentication, timeouts, and retry behavior.
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9/// Authentication method for the client.
10#[derive(Debug, Clone, Default)]
11pub enum AuthMethod {
12    /// No authentication.
13    #[default]
14    None,
15    /// API key authentication (Bearer token).
16    ApiKey(String),
17    /// JWT token authentication (Token header).
18    Token(String),
19}
20
21impl AuthMethod {
22    /// Returns the Authorization header value for this auth method.
23    pub fn header_value(&self) -> Option<String> {
24        match self {
25            AuthMethod::None => None,
26            AuthMethod::ApiKey(key) => Some(format!("Bearer {}", key)),
27            AuthMethod::Token(token) => Some(format!("Token {}", token)),
28        }
29    }
30}
31
32/// Retry configuration for transient failures.
33#[derive(Debug, Clone)]
34pub struct RetryConfig {
35    /// Maximum number of retry attempts.
36    pub max_retries: u32,
37    /// Base delay between retries (exponential backoff).
38    pub base_delay: Duration,
39    /// Maximum delay between retries.
40    pub max_delay: Duration,
41    /// HTTP status codes that should trigger a retry.
42    pub retry_status_codes: Vec<u16>,
43}
44
45impl Default for RetryConfig {
46    fn default() -> Self {
47        Self {
48            max_retries: 3,
49            base_delay: Duration::from_millis(100),
50            max_delay: Duration::from_secs(5),
51            retry_status_codes: vec![429, 500, 502, 503, 504],
52        }
53    }
54}
55
56impl RetryConfig {
57    /// Creates a new retry configuration with default values.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Sets the maximum number of retries.
63    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
64        self.max_retries = max_retries;
65        self
66    }
67
68    /// Sets the base delay for exponential backoff.
69    pub fn with_base_delay(mut self, base_delay: Duration) -> Self {
70        self.base_delay = base_delay;
71        self
72    }
73
74    /// Sets the maximum delay between retries.
75    pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
76        self.max_delay = max_delay;
77        self
78    }
79
80    /// Calculates the delay for a given retry attempt using exponential backoff.
81    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
82        let multiplier = 2u32.pow(attempt);
83        let delay = self.base_delay.saturating_mul(multiplier);
84        delay.min(self.max_delay)
85    }
86}
87
88/// Fallback storage configuration.
89#[derive(Debug, Clone)]
90pub enum FallbackStorage {
91    /// Local filesystem storage.
92    Local {
93        /// Directory for storing baseline files.
94        dir: PathBuf,
95    },
96}
97
98impl FallbackStorage {
99    /// Creates a local fallback storage.
100    pub fn local(dir: impl Into<PathBuf>) -> Self {
101        FallbackStorage::Local { dir: dir.into() }
102    }
103}
104
105/// Client configuration.
106#[derive(Debug, Clone)]
107pub struct ClientConfig {
108    /// Base URL of the server (e.g., "https://perfgate.example.com/api/v1").
109    pub server_url: String,
110    /// Authentication method.
111    pub auth: AuthMethod,
112    /// Request timeout.
113    pub timeout: Duration,
114    /// Retry configuration.
115    pub retry: RetryConfig,
116    /// Fallback storage when server is unavailable.
117    pub fallback: Option<FallbackStorage>,
118}
119
120impl Default for ClientConfig {
121    fn default() -> Self {
122        Self {
123            server_url: String::new(),
124            auth: AuthMethod::None,
125            timeout: Duration::from_secs(30),
126            retry: RetryConfig::default(),
127            fallback: None,
128        }
129    }
130}
131
132impl ClientConfig {
133    /// Creates a new client configuration with the specified server URL.
134    pub fn new(server_url: impl Into<String>) -> Self {
135        Self {
136            server_url: server_url.into(),
137            ..Self::default()
138        }
139    }
140
141    /// Sets the API key for authentication.
142    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
143        self.auth = AuthMethod::ApiKey(api_key.into());
144        self
145    }
146
147    /// Sets the JWT token for authentication.
148    pub fn with_token(mut self, token: impl Into<String>) -> Self {
149        self.auth = AuthMethod::Token(token.into());
150        self
151    }
152
153    /// Sets the request timeout.
154    pub fn with_timeout(mut self, timeout: Duration) -> Self {
155        self.timeout = timeout;
156        self
157    }
158
159    /// Sets the retry configuration.
160    pub fn with_retry(mut self, retry: RetryConfig) -> Self {
161        self.retry = retry;
162        self
163    }
164
165    /// Sets the fallback storage.
166    pub fn with_fallback(mut self, fallback: FallbackStorage) -> Self {
167        self.fallback = Some(fallback);
168        self
169    }
170
171    /// Validates the configuration.
172    pub fn validate(&self) -> Result<(), String> {
173        if self.server_url.is_empty() {
174            return Err("server_url is required".to_string());
175        }
176
177        // Validate URL format
178        if let Err(e) = url::Url::parse(&self.server_url) {
179            return Err(format!("Invalid server_url: {}", e));
180        }
181
182        if self.timeout.is_zero() {
183            return Err("timeout must be greater than zero".to_string());
184        }
185
186        Ok(())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_auth_method_header_value() {
196        assert_eq!(AuthMethod::None.header_value(), None);
197        assert_eq!(
198            AuthMethod::ApiKey("secret".to_string()).header_value(),
199            Some("Bearer secret".to_string())
200        );
201        assert_eq!(
202            AuthMethod::Token("jwt-token".to_string()).header_value(),
203            Some("Token jwt-token".to_string())
204        );
205    }
206
207    #[test]
208    fn test_retry_config_delay() {
209        let config = RetryConfig {
210            max_retries: 3,
211            base_delay: Duration::from_millis(100),
212            max_delay: Duration::from_secs(5),
213            retry_status_codes: vec![],
214        };
215
216        // Exponential backoff: 100ms, 200ms, 400ms
217        assert_eq!(config.delay_for_attempt(0), Duration::from_millis(100));
218        assert_eq!(config.delay_for_attempt(1), Duration::from_millis(200));
219        assert_eq!(config.delay_for_attempt(2), Duration::from_millis(400));
220    }
221
222    #[test]
223    fn test_retry_config_delay_capped() {
224        let config = RetryConfig {
225            max_retries: 10,
226            base_delay: Duration::from_secs(1),
227            max_delay: Duration::from_secs(5),
228            retry_status_codes: vec![],
229        };
230
231        // Should cap at max_delay
232        assert_eq!(config.delay_for_attempt(10), Duration::from_secs(5));
233    }
234
235    #[test]
236    fn test_client_config_validation() {
237        let config = ClientConfig::new("https://example.com/api/v1");
238        assert!(config.validate().is_ok());
239
240        let empty_config = ClientConfig {
241            server_url: String::new(),
242            ..Default::default()
243        };
244        assert!(empty_config.validate().is_err());
245
246        let invalid_url = ClientConfig::new("not a url");
247        assert!(invalid_url.validate().is_err());
248
249        let zero_timeout = ClientConfig {
250            server_url: "https://example.com".to_string(),
251            timeout: Duration::ZERO,
252            ..Default::default()
253        };
254        assert!(zero_timeout.validate().is_err());
255    }
256
257    #[test]
258    fn test_client_config_builder() {
259        let config = ClientConfig::new("https://example.com/api/v1")
260            .with_api_key("my-key")
261            .with_timeout(Duration::from_secs(60))
262            .with_fallback(FallbackStorage::local("/tmp/baselines"));
263
264        assert_eq!(config.server_url, "https://example.com/api/v1");
265        assert!(matches!(config.auth, AuthMethod::ApiKey(_)));
266        assert_eq!(config.timeout, Duration::from_secs(60));
267        assert!(config.fallback.is_some());
268    }
269}