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 anyhow::Context;
7use perfgate_types::BaselineServerConfig;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use crate::client::BaselineClient;
12use crate::fallback::FallbackClient;
13
14/// Authentication method for the client.
15#[derive(Debug, Clone, Default)]
16pub enum AuthMethod {
17    /// No authentication.
18    #[default]
19    None,
20    /// API key authentication (Bearer token).
21    ApiKey(String),
22    /// JWT token authentication (Token header).
23    Token(String),
24}
25
26impl AuthMethod {
27    /// Returns the Authorization header value for this auth method.
28    pub fn header_value(&self) -> Option<String> {
29        match self {
30            AuthMethod::None => None,
31            AuthMethod::ApiKey(key) => Some(format!("Bearer {}", key)),
32            AuthMethod::Token(token) => Some(format!("Token {}", token)),
33        }
34    }
35}
36
37/// Retry configuration for transient failures.
38#[derive(Debug, Clone)]
39pub struct RetryConfig {
40    /// Maximum number of retry attempts.
41    pub max_retries: u32,
42    /// Base delay between retries (exponential backoff).
43    pub base_delay: Duration,
44    /// Maximum delay between retries.
45    pub max_delay: Duration,
46    /// HTTP status codes that should trigger a retry.
47    pub retry_status_codes: Vec<u16>,
48}
49
50impl Default for RetryConfig {
51    fn default() -> Self {
52        Self {
53            max_retries: 3,
54            base_delay: Duration::from_millis(100),
55            max_delay: Duration::from_secs(5),
56            retry_status_codes: vec![429, 500, 502, 503, 504],
57        }
58    }
59}
60
61impl RetryConfig {
62    /// Creates a new retry configuration with default values.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Sets the maximum number of retries.
68    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
69        self.max_retries = max_retries;
70        self
71    }
72
73    /// Sets the base delay for exponential backoff.
74    pub fn with_base_delay(mut self, base_delay: Duration) -> Self {
75        self.base_delay = base_delay;
76        self
77    }
78
79    /// Sets the maximum delay between retries.
80    pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
81        self.max_delay = max_delay;
82        self
83    }
84
85    /// Calculates the delay for a given retry attempt using exponential backoff.
86    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
87        let multiplier = 2u32.pow(attempt);
88        let delay = self.base_delay.saturating_mul(multiplier);
89        delay.min(self.max_delay)
90    }
91}
92
93/// Fallback storage configuration.
94#[derive(Debug, Clone)]
95pub enum FallbackStorage {
96    /// Local filesystem storage.
97    Local {
98        /// Directory for storing baseline files.
99        dir: PathBuf,
100    },
101}
102
103impl FallbackStorage {
104    /// Creates a local fallback storage.
105    pub fn local(dir: impl Into<PathBuf>) -> Self {
106        FallbackStorage::Local { dir: dir.into() }
107    }
108}
109
110/// Client configuration.
111#[derive(Debug, Clone)]
112pub struct ClientConfig {
113    /// Base URL of the server (e.g., "https://perfgate.example.com/api/v1").
114    pub server_url: String,
115    /// Authentication method.
116    pub auth: AuthMethod,
117    /// Request timeout.
118    pub timeout: Duration,
119    /// Retry configuration.
120    pub retry: RetryConfig,
121    /// Fallback storage when server is unavailable.
122    pub fallback: Option<FallbackStorage>,
123}
124
125impl Default for ClientConfig {
126    fn default() -> Self {
127        Self {
128            server_url: String::new(),
129            auth: AuthMethod::None,
130            timeout: Duration::from_secs(30),
131            retry: RetryConfig::default(),
132            fallback: None,
133        }
134    }
135}
136
137impl ClientConfig {
138    /// Creates a new client configuration with the specified server URL.
139    pub fn new(server_url: impl Into<String>) -> Self {
140        Self {
141            server_url: server_url.into(),
142            ..Self::default()
143        }
144    }
145
146    /// Sets the API key for authentication.
147    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
148        self.auth = AuthMethod::ApiKey(api_key.into());
149        self
150    }
151
152    /// Sets the JWT token for authentication.
153    pub fn with_token(mut self, token: impl Into<String>) -> Self {
154        self.auth = AuthMethod::Token(token.into());
155        self
156    }
157
158    /// Sets the request timeout.
159    pub fn with_timeout(mut self, timeout: Duration) -> Self {
160        self.timeout = timeout;
161        self
162    }
163
164    /// Sets the retry configuration.
165    pub fn with_retry(mut self, retry: RetryConfig) -> Self {
166        self.retry = retry;
167        self
168    }
169
170    /// Sets the fallback storage.
171    pub fn with_fallback(mut self, fallback: FallbackStorage) -> Self {
172        self.fallback = Some(fallback);
173        self
174    }
175
176    /// Validates the configuration.
177    pub fn validate(&self) -> Result<(), String> {
178        if self.server_url.is_empty() {
179            return Err("server_url is required".to_string());
180        }
181
182        // Validate URL format
183        if let Err(e) = url::Url::parse(&self.server_url) {
184            return Err(format!("Invalid server_url: {}", e));
185        }
186
187        if self.timeout.is_zero() {
188            return Err("timeout must be greater than zero".to_string());
189        }
190
191        Ok(())
192    }
193}
194
195/// Resolved baseline server configuration with all sources merged.
196#[derive(Debug, Clone, Default)]
197pub struct ResolvedServerConfig {
198    pub url: Option<String>,
199    pub api_key: Option<String>,
200    pub project: Option<String>,
201    pub fallback_to_local: bool,
202}
203
204impl ResolvedServerConfig {
205    /// Returns true if server is configured (has a URL).
206    pub fn is_configured(&self) -> bool {
207        self.url.as_ref().is_some_and(|u| !u.is_empty())
208    }
209
210    /// Creates a [`BaselineClient`] from this configuration.
211    pub fn create_client(&self) -> anyhow::Result<Option<BaselineClient>> {
212        if !self.is_configured() {
213            return Ok(None);
214        }
215
216        let url = self.url.as_ref().expect("checked by is_configured");
217        let mut config = ClientConfig::new(url);
218
219        if let Some(api_key) = &self.api_key {
220            config = config.with_api_key(api_key);
221        }
222
223        let client = BaselineClient::new(config)
224            .with_context(|| format!("Failed to create baseline client for {}", url))?;
225
226        Ok(Some(client))
227    }
228
229    /// Creates a [`FallbackClient`] if fallback is enabled and server is configured.
230    pub fn create_fallback_client(
231        &self,
232        fallback_dir: Option<&Path>,
233    ) -> anyhow::Result<Option<FallbackClient>> {
234        let client = match self.create_client()? {
235            Some(c) => c,
236            None => return Ok(None),
237        };
238
239        let fallback = if self.fallback_to_local {
240            fallback_dir.map(|dir| FallbackStorage::local(dir.to_path_buf()))
241        } else {
242            None
243        };
244
245        Ok(Some(FallbackClient::new(client, fallback)))
246    }
247
248    /// Returns a baseline client for explicit server operations, or an error
249    /// if the server is not configured.
250    pub fn require_client(&self, error_msg: &str) -> anyhow::Result<BaselineClient> {
251        self.create_client()?
252            .ok_or_else(|| anyhow::anyhow!(error_msg.to_string()))
253    }
254
255    /// Returns a baseline client for server operations, or an error if not configured.
256    pub fn require_fallback_client(
257        &self,
258        fallback_dir: Option<&Path>,
259        error_msg: &str,
260    ) -> anyhow::Result<FallbackClient> {
261        self.create_fallback_client(fallback_dir)?
262            .ok_or_else(|| anyhow::anyhow!(error_msg.to_string()))
263    }
264
265    /// Resolve a project for server operations.
266    pub fn resolve_project(&self, project: Option<String>) -> anyhow::Result<String> {
267        project.or_else(|| self.project.clone()).ok_or_else(|| {
268            anyhow::anyhow!(
269                "--project is required (or set --project flag, PERFGATE_PROJECT, or [baseline_server].project in perfgate.toml)"
270            )
271        })
272    }
273}
274
275/// Resolves server configuration from CLI flags, environment variables, and config file values.
276pub fn resolve_server_config(
277    flag_url: Option<String>,
278    flag_key: Option<String>,
279    flag_project: Option<String>,
280    file_config: &BaselineServerConfig,
281) -> ResolvedServerConfig {
282    ResolvedServerConfig {
283        url: flag_url.or_else(|| file_config.resolved_url()),
284        api_key: flag_key.or_else(|| file_config.resolved_api_key()),
285        project: flag_project.or_else(|| file_config.resolved_project()),
286        fallback_to_local: file_config.fallback_to_local,
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_auth_method_header_value() {
296        assert_eq!(AuthMethod::None.header_value(), None);
297        assert_eq!(
298            AuthMethod::ApiKey("secret".to_string()).header_value(),
299            Some("Bearer secret".to_string())
300        );
301        assert_eq!(
302            AuthMethod::Token("jwt-token".to_string()).header_value(),
303            Some("Token jwt-token".to_string())
304        );
305    }
306
307    #[test]
308    fn test_retry_config_delay() {
309        let config = RetryConfig {
310            max_retries: 3,
311            base_delay: Duration::from_millis(100),
312            max_delay: Duration::from_secs(5),
313            retry_status_codes: vec![],
314        };
315
316        // Exponential backoff: 100ms, 200ms, 400ms
317        assert_eq!(config.delay_for_attempt(0), Duration::from_millis(100));
318        assert_eq!(config.delay_for_attempt(1), Duration::from_millis(200));
319        assert_eq!(config.delay_for_attempt(2), Duration::from_millis(400));
320    }
321
322    #[test]
323    fn test_retry_config_delay_capped() {
324        let config = RetryConfig {
325            max_retries: 10,
326            base_delay: Duration::from_secs(1),
327            max_delay: Duration::from_secs(5),
328            retry_status_codes: vec![],
329        };
330
331        // Should cap at max_delay
332        assert_eq!(config.delay_for_attempt(10), Duration::from_secs(5));
333    }
334
335    #[test]
336    fn test_client_config_validation() {
337        let config = ClientConfig::new("https://example.com/api/v1");
338        assert!(config.validate().is_ok());
339
340        let empty_config = ClientConfig {
341            server_url: String::new(),
342            ..Default::default()
343        };
344        assert!(empty_config.validate().is_err());
345
346        let invalid_url = ClientConfig::new("not a url");
347        assert!(invalid_url.validate().is_err());
348
349        let zero_timeout = ClientConfig {
350            server_url: "https://example.com".to_string(),
351            timeout: Duration::ZERO,
352            ..Default::default()
353        };
354        assert!(zero_timeout.validate().is_err());
355    }
356
357    #[test]
358    fn test_client_config_builder() {
359        let config = ClientConfig::new("https://example.com/api/v1")
360            .with_api_key("my-key")
361            .with_timeout(Duration::from_secs(60))
362            .with_fallback(FallbackStorage::local("/tmp/baselines"));
363
364        assert_eq!(config.server_url, "https://example.com/api/v1");
365        assert!(matches!(config.auth, AuthMethod::ApiKey(_)));
366        assert_eq!(config.timeout, Duration::from_secs(60));
367        assert!(config.fallback.is_some());
368    }
369
370    #[test]
371    fn test_resolve_server_config_prefers_flags() {
372        let file_config = BaselineServerConfig {
373            url: Some("https://file.example.com".to_string()),
374            api_key: Some("file-key".to_string()),
375            project: Some("file-project".to_string()),
376            fallback_to_local: false,
377        };
378
379        let resolved = resolve_server_config(
380            Some("https://flag.example.com".to_string()),
381            Some("flag-key".to_string()),
382            Some("flag-project".to_string()),
383            &file_config,
384        );
385
386        assert_eq!(resolved.url.as_deref(), Some("https://flag.example.com"));
387        assert_eq!(resolved.api_key.as_deref(), Some("flag-key"));
388        assert_eq!(resolved.project.as_deref(), Some("flag-project"));
389        assert!(!resolved.fallback_to_local);
390    }
391
392    #[test]
393    fn test_resolved_server_config_reports_unconfigured_without_url() {
394        let resolved = ResolvedServerConfig::default();
395
396        assert!(!resolved.is_configured());
397        assert!(resolved.create_client().unwrap().is_none());
398    }
399}