1#[cfg(feature = "keyring")]
2use keyring::Entry;
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::PathBuf;
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct RetryConfig {
11 pub max_retries: u32,
13 pub initial_backoff_ms: u64,
15 pub max_backoff_ms: u64,
17 pub backoff_multiplier: f64,
19}
20
21impl Default for RetryConfig {
22 fn default() -> Self {
23 Self {
24 max_retries: 3,
25 initial_backoff_ms: 100,
26 max_backoff_ms: 10000,
27 backoff_multiplier: 2.0,
28 }
29 }
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct HttpConfig {
35 pub timeout_secs: u64,
37 pub pool_max_idle_per_host: usize,
39 pub pool_idle_timeout_secs: u64,
41 pub tcp_keepalive_secs: Option<u64>,
43}
44
45impl Default for HttpConfig {
46 fn default() -> Self {
47 Self {
48 timeout_secs: 30,
49 pool_max_idle_per_host: 10,
50 pool_idle_timeout_secs: 90,
51 tcp_keepalive_secs: Some(60),
52 }
53 }
54}
55
56#[derive(Clone, Deserialize, Serialize)]
58pub struct DatadogConfig {
59 pub api_key: SecretString,
61 pub app_key: SecretString,
63 #[serde(default = "default_site")]
65 pub site: String,
66 #[serde(default)]
68 pub retry_config: RetryConfig,
69 #[serde(default)]
71 pub http_config: HttpConfig,
72 #[serde(default = "default_unstable_operations")]
74 pub unstable_operations: Vec<String>,
75 #[serde(skip)]
77 base_url_override: Option<String>,
78}
79
80impl fmt::Debug for DatadogConfig {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.debug_struct("DatadogConfig")
83 .field("api_key", &"[REDACTED]")
84 .field("app_key", &"[REDACTED]")
85 .field("site", &self.site)
86 .field("retry_config", &self.retry_config)
87 .field("http_config", &self.http_config)
88 .field("unstable_operations", &self.unstable_operations)
89 .field(
90 "base_url_override",
91 &self.base_url_override.as_ref().map(|_| "[SET]"),
92 )
93 .finish()
94 }
95}
96
97const fn default_site_const() -> &'static str {
98 "datadoghq.com"
99}
100
101fn default_site() -> String {
102 default_site_const().to_string()
103}
104
105fn default_unstable_operations() -> Vec<String> {
106 vec!["incidents".to_string()]
107}
108
109impl DatadogConfig {
110 #[must_use]
114 pub fn new(api_key: String, application_key: String) -> Self {
115 Self {
116 api_key: SecretString::new(api_key),
117 app_key: SecretString::new(application_key),
118 site: default_site(),
119 retry_config: RetryConfig::default(),
120 http_config: HttpConfig::default(),
121 unstable_operations: default_unstable_operations(),
122 base_url_override: None,
123 }
124 }
125
126 #[must_use]
135 pub fn with_site(mut self, site: String) -> Self {
136 self.site = site;
137 self
138 }
139
140 #[must_use]
142 pub fn with_base_url(mut self, base_url: String) -> Self {
143 self.base_url_override = Some(base_url);
144 self
145 }
146
147 #[must_use]
149 pub fn base_url(&self) -> String {
150 self.base_url_override
151 .clone()
152 .unwrap_or_else(|| format!("https://api.{}", self.site))
153 }
154
155 pub fn from_env() -> crate::Result<Self> {
167 let api_key = std::env::var("DD_API_KEY")
168 .map_err(|_| crate::Error::ConfigError("DD_API_KEY not set".to_string()))?;
169
170 let application_key = std::env::var("DD_APP_KEY")
171 .map_err(|_| crate::Error::ConfigError("DD_APP_KEY not set".to_string()))?;
172
173 let site = std::env::var("DD_SITE").unwrap_or_else(|_| default_site());
174
175 Ok(Self {
176 api_key: SecretString::new(api_key),
177 app_key: SecretString::new(application_key),
178 site,
179 retry_config: RetryConfig::default(),
180 http_config: HttpConfig::default(),
181 unstable_operations: default_unstable_operations(),
182 base_url_override: None,
183 })
184 }
185
186 pub fn from_env_or_file() -> crate::Result<Self> {
188 if let Ok(file_cfg) = Self::from_credentials_file() {
189 return Ok(file_cfg);
190 }
191 #[cfg(feature = "keyring")]
192 if let Ok(keyring_cfg) = Self::from_keyring() {
193 return Ok(keyring_cfg);
194 }
195 Self::from_env()
196 }
197
198 fn from_credentials_file() -> crate::Result<Self> {
199 let home = std::env::var("HOME").map_err(|_| {
200 crate::Error::ConfigError("HOME not set; cannot read credentials file".to_string())
201 })?;
202 let path = PathBuf::from(home)
203 .join(".datadog-mcp")
204 .join("credentials.json");
205 let content = std::fs::read_to_string(&path).map_err(|e| {
206 crate::Error::ConfigError(format!("Failed to read {}: {}", path.display(), e))
207 })?;
208 let file_cfg: FileCredentials = serde_json::from_str(&content).map_err(|e| {
209 crate::Error::ConfigError(format!(
210 "Invalid credentials file {}: {}",
211 path.display(),
212 e
213 ))
214 })?;
215 Ok(Self::new(file_cfg.api_key, file_cfg.app_key)
216 .with_site(file_cfg.site.unwrap_or_else(default_site)))
217 }
218
219 #[cfg(feature = "keyring")]
223 pub fn from_keyring() -> crate::Result<Self> {
224 let profile = std::env::var("DD_PROFILE").unwrap_or_else(|_| "default".to_string());
225 let entry = Entry::new(KEYRING_SERVICE, &profile)
226 .map_err(|e| crate::Error::ConfigError(format!("Failed to access keyring: {e}")))?;
227 let secret = entry
228 .get_password()
229 .map_err(|e| crate::Error::ConfigError(format!("Failed to read keyring entry: {e}")))?;
230 let creds: FileCredentials = serde_json::from_str(&secret).map_err(|e| {
231 crate::Error::ConfigError(format!("Invalid keyring credentials format: {e}"))
232 })?;
233 Ok(Self::new(creds.api_key, creds.app_key)
234 .with_site(creds.site.unwrap_or_else(default_site)))
235 }
236
237 #[cfg(feature = "keyring")]
241 pub fn store_in_keyring(&self) -> crate::Result<()> {
242 let profile = std::env::var("DD_PROFILE").unwrap_or_else(|_| "default".to_string());
243 let entry = Entry::new(KEYRING_SERVICE, &profile)
244 .map_err(|e| crate::Error::ConfigError(format!("Failed to access keyring: {e}")))?;
245 let payload = serde_json::to_string(&FileCredentials {
246 api_key: self.api_key.expose().to_string(),
247 app_key: self.app_key.expose().to_string(),
248 site: Some(self.site.clone()),
249 })
250 .map_err(|e| crate::Error::ConfigError(format!("Failed to serialize credentials: {e}")))?;
251 entry.set_password(&payload).map_err(|e| {
252 crate::Error::ConfigError(format!("Failed to store keyring entry: {e}"))
253 })?;
254 Ok(())
255 }
256}
257
258#[derive(Clone, Deserialize, Serialize, Zeroize, ZeroizeOnDrop, PartialEq, Eq)]
260#[serde(transparent)]
261pub struct SecretString(String);
262
263impl SecretString {
264 pub fn new(value: impl Into<String>) -> Self {
265 Self(value.into())
266 }
267
268 pub fn expose(&self) -> &str {
269 &self.0
270 }
271}
272
273impl fmt::Debug for SecretString {
274 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275 f.write_str("[REDACTED]")
276 }
277}
278
279impl fmt::Display for SecretString {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 f.write_str("[REDACTED]")
282 }
283}
284
285impl PartialEq<str> for SecretString {
286 fn eq(&self, other: &str) -> bool {
287 self.0 == other
288 }
289}
290
291impl PartialEq<String> for SecretString {
292 fn eq(&self, other: &String) -> bool {
293 &self.0 == other
294 }
295}
296
297impl PartialEq<&str> for SecretString {
298 fn eq(&self, other: &&str) -> bool {
299 self.0 == *other
300 }
301}
302
303#[derive(Debug, Clone, Deserialize, Serialize)]
304struct FileCredentials {
305 api_key: String,
306 app_key: String,
307 #[serde(default)]
308 site: Option<String>,
309}
310
311#[cfg(feature = "keyring")]
312const KEYRING_SERVICE: &str = "datadog-mcp";
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::Error;
318 use serial_test::serial;
319 use std::env;
320
321 #[test]
322 fn test_config_new() {
323 let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string());
324
325 assert_eq!(config.api_key, "test_api_key");
326 assert_eq!(config.app_key, "test_app_key");
327 assert_eq!(config.site, "datadoghq.com");
328 }
329
330 #[test]
331 fn test_config_with_site() {
332 let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string())
333 .with_site("datadoghq.eu".to_string());
334
335 assert_eq!(config.site, "datadoghq.eu");
336 }
337
338 #[test]
339 fn test_base_url_us1() {
340 let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string());
341
342 assert_eq!(config.base_url(), "https://api.datadoghq.com");
343 }
344
345 #[test]
346 fn test_base_url_eu() {
347 let config = DatadogConfig::new("test_api_key".to_string(), "test_app_key".to_string())
348 .with_site("datadoghq.eu".to_string());
349
350 assert_eq!(config.base_url(), "https://api.datadoghq.eu");
351 }
352
353 #[test]
354 #[serial]
355 fn test_from_env_success() {
356 env::set_var("DD_API_KEY", "env_api_key");
357 env::set_var("DD_APP_KEY", "env_app_key");
358 env::set_var("DD_SITE", "us3.datadoghq.com");
359
360 let config = DatadogConfig::from_env().expect("Failed to create config from env");
361
362 assert_eq!(config.api_key, "env_api_key");
363 assert_eq!(config.app_key, "env_app_key");
364 assert_eq!(config.site, "us3.datadoghq.com");
365
366 env::remove_var("DD_API_KEY");
367 env::remove_var("DD_APP_KEY");
368 env::remove_var("DD_SITE");
369 }
370
371 #[test]
372 #[serial]
373 fn test_from_env_default_site() {
374 env::set_var("DD_API_KEY", "env_api_key");
375 env::set_var("DD_APP_KEY", "env_app_key");
376 env::remove_var("DD_SITE");
377
378 let config = DatadogConfig::from_env().expect("Failed to create config from env");
379
380 assert_eq!(config.site, "datadoghq.com");
381
382 env::remove_var("DD_API_KEY");
383 env::remove_var("DD_APP_KEY");
384 }
385
386 #[test]
387 #[serial]
388 fn test_from_env_missing_api_key() {
389 env::remove_var("DD_API_KEY");
390 env::set_var("DD_APP_KEY", "env_app_key");
391
392 let result = DatadogConfig::from_env();
393
394 assert!(result.is_err());
395 if let Err(Error::ConfigError(msg)) = result {
396 assert!(msg.contains("DD_API_KEY"));
397 } else {
398 panic!("Expected ConfigError");
399 }
400
401 env::remove_var("DD_APP_KEY");
402 }
403
404 #[test]
405 #[serial]
406 fn test_from_env_missing_app_key() {
407 env::set_var("DD_API_KEY", "env_api_key");
408 env::remove_var("DD_APP_KEY");
409
410 let result = DatadogConfig::from_env();
411
412 assert!(result.is_err());
413 if let Err(Error::ConfigError(msg)) = result {
414 assert!(msg.contains("DD_APP_KEY"));
415 } else {
416 panic!("Expected ConfigError");
417 }
418
419 env::remove_var("DD_API_KEY");
420 }
421
422 #[test]
423 fn test_config_serialization() {
424 let config = DatadogConfig::new("api_key".to_string(), "app_key".to_string())
425 .with_site("datadoghq.eu".to_string());
426
427 let json = serde_json::to_string(&config).expect("Failed to serialize");
428 let deserialized: DatadogConfig =
429 serde_json::from_str(&json).expect("Failed to deserialize");
430
431 assert_eq!(config.api_key, deserialized.api_key);
432 assert_eq!(config.app_key, deserialized.app_key);
433 assert_eq!(config.site, deserialized.site);
434 }
435
436 #[test]
437 fn test_http_config_default() {
438 let config = HttpConfig::default();
439 assert_eq!(config.timeout_secs, 30);
440 assert_eq!(config.pool_max_idle_per_host, 10);
441 assert_eq!(config.pool_idle_timeout_secs, 90);
442 assert_eq!(config.tcp_keepalive_secs, Some(60));
443 }
444
445 #[test]
446 fn test_http_config_serialization() {
447 let config = HttpConfig {
448 timeout_secs: 60,
449 pool_max_idle_per_host: 20,
450 pool_idle_timeout_secs: 120,
451 tcp_keepalive_secs: None,
452 };
453
454 let json = serde_json::to_string(&config).expect("Failed to serialize");
455 let deserialized: HttpConfig =
456 serde_json::from_str(&json).expect("Failed to deserialize");
457
458 assert_eq!(config.timeout_secs, deserialized.timeout_secs);
459 assert_eq!(
460 config.pool_max_idle_per_host,
461 deserialized.pool_max_idle_per_host
462 );
463 assert_eq!(config.tcp_keepalive_secs, deserialized.tcp_keepalive_secs);
464 }
465}