1use 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#[derive(Debug, Clone, Default)]
16pub enum AuthMethod {
17 #[default]
19 None,
20 ApiKey(String),
22 Token(String),
24}
25
26impl AuthMethod {
27 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#[derive(Debug, Clone)]
39pub struct RetryConfig {
40 pub max_retries: u32,
42 pub base_delay: Duration,
44 pub max_delay: Duration,
46 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 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
69 self.max_retries = max_retries;
70 self
71 }
72
73 pub fn with_base_delay(mut self, base_delay: Duration) -> Self {
75 self.base_delay = base_delay;
76 self
77 }
78
79 pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
81 self.max_delay = max_delay;
82 self
83 }
84
85 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#[derive(Debug, Clone)]
95pub enum FallbackStorage {
96 Local {
98 dir: PathBuf,
100 },
101}
102
103impl FallbackStorage {
104 pub fn local(dir: impl Into<PathBuf>) -> Self {
106 FallbackStorage::Local { dir: dir.into() }
107 }
108}
109
110#[derive(Debug, Clone)]
112pub struct ClientConfig {
113 pub server_url: String,
115 pub auth: AuthMethod,
117 pub timeout: Duration,
119 pub retry: RetryConfig,
121 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 pub fn new(server_url: impl Into<String>) -> Self {
140 Self {
141 server_url: server_url.into(),
142 ..Self::default()
143 }
144 }
145
146 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 pub fn with_token(mut self, token: impl Into<String>) -> Self {
154 self.auth = AuthMethod::Token(token.into());
155 self
156 }
157
158 pub fn with_timeout(mut self, timeout: Duration) -> Self {
160 self.timeout = timeout;
161 self
162 }
163
164 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
166 self.retry = retry;
167 self
168 }
169
170 pub fn with_fallback(mut self, fallback: FallbackStorage) -> Self {
172 self.fallback = Some(fallback);
173 self
174 }
175
176 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 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#[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 pub fn is_configured(&self) -> bool {
207 self.url.as_ref().is_some_and(|u| !u.is_empty())
208 }
209
210 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 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 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 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 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
275pub 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 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 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}