1use std::collections::HashMap;
7use std::fs;
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11
12use crate::datadog::error::DatadogError;
13use crate::utils::settings::Settings;
14
15pub const DATADOG_API_KEY: &str = "DATADOG_API_KEY";
17
18pub const DATADOG_APP_KEY: &str = "DATADOG_APP_KEY";
20
21pub const DATADOG_SITE: &str = "DATADOG_SITE";
23
24pub const DATADOG_API_URL: &str = "DATADOG_API_URL";
31
32pub const DEFAULT_SITE: &str = "datadoghq.com";
34
35pub const KNOWN_SITES: &[&str] = &[
41 "datadoghq.com",
42 "us3.datadoghq.com",
43 "us5.datadoghq.com",
44 "datadoghq.eu",
45 "ap1.datadoghq.com",
46 "ddog-gov.com",
47];
48
49#[derive(Debug, Clone)]
51pub struct DatadogCredentials {
52 pub api_key: String,
54
55 pub app_key: String,
57
58 pub site: String,
60}
61
62pub fn normalize_site(raw: &str) -> String {
67 let trimmed = raw.trim();
68 let no_scheme = trimmed
69 .strip_prefix("https://")
70 .or_else(|| trimmed.strip_prefix("http://"))
71 .unwrap_or(trimmed);
72 let no_api = no_scheme.strip_prefix("api.").unwrap_or(no_scheme);
73 no_api.trim_end_matches('/').to_string()
74}
75
76pub fn base_url_for_site(site: &str) -> String {
78 format!("https://api.{}", normalize_site(site))
79}
80
81pub fn load_credentials() -> Result<DatadogCredentials> {
86 let settings = Settings::load().unwrap_or(Settings {
87 env: HashMap::new(),
88 });
89
90 let api_key = settings
91 .get_env_var(DATADOG_API_KEY)
92 .ok_or(DatadogError::CredentialsNotFound)?;
93 let app_key = settings
94 .get_env_var(DATADOG_APP_KEY)
95 .ok_or(DatadogError::CredentialsNotFound)?;
96 let site = settings
97 .get_env_var(DATADOG_SITE)
98 .map(|s| normalize_site(&s))
99 .filter(|s| !s.is_empty())
100 .unwrap_or_else(|| DEFAULT_SITE.to_string());
101
102 if !KNOWN_SITES.iter().any(|k| *k == site) {
103 eprintln!("warning: Datadog site '{site}' is not a known region; proceeding anyway");
104 }
105
106 Ok(DatadogCredentials {
107 api_key,
108 app_key,
109 site,
110 })
111}
112
113#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
118pub struct DatadogScopeStatus {
119 pub name: String,
122 pub has_api_key: bool,
124 pub has_app_key: bool,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub site: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
133pub struct AuthStatus {
134 pub scopes: Vec<DatadogScopeStatus>,
137}
138
139pub fn status() -> AuthStatus {
144 let settings = Settings::load().unwrap_or(Settings {
145 env: HashMap::new(),
146 });
147
148 let has_api_key = settings.get_env_var(DATADOG_API_KEY).is_some();
149 let has_app_key = settings.get_env_var(DATADOG_APP_KEY).is_some();
150 let site = settings
151 .get_env_var(DATADOG_SITE)
152 .map(|s| normalize_site(&s))
153 .filter(|s| !s.is_empty());
154
155 AuthStatus {
156 scopes: vec![DatadogScopeStatus {
157 name: "default".to_string(),
158 has_api_key,
159 has_app_key,
160 site,
161 }],
162 }
163}
164
165pub fn save_credentials(credentials: &DatadogCredentials) -> Result<()> {
170 let settings_path = Settings::get_settings_path()?;
171 let mut settings_value = read_or_default_settings(&settings_path)?;
172 ensure_env_object(&mut settings_value);
173
174 let Some(env) = settings_value["env"].as_object_mut() else {
175 anyhow::bail!("Internal error: env key is not an object after initialization");
176 };
177 env.insert(
178 DATADOG_API_KEY.to_string(),
179 serde_json::Value::String(credentials.api_key.clone()),
180 );
181 env.insert(
182 DATADOG_APP_KEY.to_string(),
183 serde_json::Value::String(credentials.app_key.clone()),
184 );
185 env.insert(
186 DATADOG_SITE.to_string(),
187 serde_json::Value::String(credentials.site.clone()),
188 );
189
190 write_settings(&settings_path, &settings_value)
191}
192
193pub fn remove_credentials() -> Result<bool> {
199 let settings_path = Settings::get_settings_path()?;
200 if !settings_path.exists() {
201 return Ok(false);
202 }
203 let mut settings_value = read_or_default_settings(&settings_path)?;
204
205 let mut removed = false;
206 if let Some(env) = settings_value
207 .get_mut("env")
208 .and_then(serde_json::Value::as_object_mut)
209 {
210 for key in [DATADOG_API_KEY, DATADOG_APP_KEY, DATADOG_SITE] {
211 if env.remove(key).is_some() {
212 removed = true;
213 }
214 }
215 }
216
217 if removed {
218 write_settings(&settings_path, &settings_value)?;
219 }
220 Ok(removed)
221}
222
223fn read_or_default_settings(path: &std::path::Path) -> Result<serde_json::Value> {
224 if path.exists() {
225 let content = fs::read_to_string(path)
226 .with_context(|| format!("Failed to read {}", path.display()))?;
227 serde_json::from_str(&content)
228 .with_context(|| format!("Failed to parse {}", path.display()))
229 } else {
230 Ok(serde_json::json!({}))
231 }
232}
233
234fn ensure_env_object(value: &mut serde_json::Value) {
235 if !value.get("env").is_some_and(serde_json::Value::is_object) {
236 value["env"] = serde_json::json!({});
237 }
238}
239
240fn write_settings(path: &std::path::Path, value: &serde_json::Value) -> Result<()> {
241 if let Some(parent) = path.parent() {
242 fs::create_dir_all(parent)
243 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
244 }
245 let formatted =
246 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
247 fs::write(path, formatted).with_context(|| format!("Failed to write {}", path.display()))?;
248 Ok(())
249}
250
251#[cfg(test)]
252#[allow(clippy::unwrap_used, clippy::expect_used)]
253mod tests {
254 use super::*;
255
256 #[test]
259 fn normalize_site_strips_scheme_and_api_prefix() {
260 assert_eq!(normalize_site("datadoghq.com"), "datadoghq.com");
261 assert_eq!(normalize_site("https://datadoghq.com"), "datadoghq.com");
262 assert_eq!(normalize_site("http://datadoghq.com"), "datadoghq.com");
263 assert_eq!(normalize_site("api.datadoghq.com"), "datadoghq.com");
264 assert_eq!(normalize_site("https://api.datadoghq.com"), "datadoghq.com");
265 assert_eq!(
266 normalize_site("https://api.us3.datadoghq.com/"),
267 "us3.datadoghq.com"
268 );
269 }
270
271 #[test]
272 fn normalize_site_trims_whitespace() {
273 assert_eq!(normalize_site(" datadoghq.com "), "datadoghq.com");
274 }
275
276 #[test]
277 fn base_url_for_site_builds_api_host() {
278 assert_eq!(
279 base_url_for_site("datadoghq.com"),
280 "https://api.datadoghq.com"
281 );
282 assert_eq!(
283 base_url_for_site("us5.datadoghq.com"),
284 "https://api.us5.datadoghq.com"
285 );
286 assert_eq!(
287 base_url_for_site("datadoghq.eu"),
288 "https://api.datadoghq.eu"
289 );
290 }
291
292 #[test]
293 fn base_url_normalises_input() {
294 assert_eq!(
295 base_url_for_site("https://api.datadoghq.com/"),
296 "https://api.datadoghq.com"
297 );
298 }
299
300 #[test]
301 fn credentials_struct_clone_and_debug() {
302 let creds = DatadogCredentials {
303 api_key: "a".to_string(),
304 app_key: "b".to_string(),
305 site: "datadoghq.com".to_string(),
306 };
307 let cloned = creds.clone();
308 assert_eq!(cloned.api_key, creds.api_key);
309 assert!(format!("{creds:?}").contains("DatadogCredentials"));
310 }
311
312 #[test]
313 fn constant_key_names() {
314 assert_eq!(DATADOG_API_KEY, "DATADOG_API_KEY");
315 assert_eq!(DATADOG_APP_KEY, "DATADOG_APP_KEY");
316 assert_eq!(DATADOG_SITE, "DATADOG_SITE");
317 assert_eq!(DEFAULT_SITE, "datadoghq.com");
318 }
319
320 #[test]
321 fn known_sites_contains_common_regions() {
322 assert!(KNOWN_SITES.contains(&"datadoghq.com"));
323 assert!(KNOWN_SITES.contains(&"datadoghq.eu"));
324 assert!(KNOWN_SITES.contains(&"us5.datadoghq.com"));
325 }
326
327 use crate::datadog::test_support::{with_empty_home, EnvGuard};
330
331 #[test]
332 fn status_reports_all_false_when_nothing_configured() {
333 let guard = EnvGuard::take();
334 let _dir = with_empty_home(&guard);
335
336 let status = status();
337 assert_eq!(status.scopes.len(), 1);
338 let scope = &status.scopes[0];
339 assert_eq!(scope.name, "default");
340 assert!(!scope.has_api_key);
341 assert!(!scope.has_app_key);
342 assert_eq!(scope.site, None);
343 }
344
345 #[test]
346 fn status_reports_presence_flags_without_leaking_secrets() {
347 let guard = EnvGuard::take();
348 let dir = with_empty_home(&guard);
349 let omni_dir = dir.path().join(".omni-dev");
350 fs::create_dir_all(&omni_dir).unwrap();
351 fs::write(
352 omni_dir.join("settings.json"),
353 r#"{"env":{
354 "DATADOG_API_KEY":"sekret-api-do-not-leak",
355 "DATADOG_APP_KEY":"sekret-app-do-not-leak",
356 "DATADOG_SITE":"datadoghq.com"
357 }}"#,
358 )
359 .unwrap();
360
361 let status = status();
362 let scope = &status.scopes[0];
363 assert!(scope.has_api_key);
364 assert!(scope.has_app_key);
365 assert_eq!(scope.site.as_deref(), Some("datadoghq.com"));
366
367 let yaml = serde_yaml::to_string(&status).unwrap();
368 assert!(!yaml.contains("sekret-api-do-not-leak"));
369 assert!(!yaml.contains("sekret-app-do-not-leak"));
370 }
371
372 #[test]
373 fn status_normalises_site_value() {
374 let guard = EnvGuard::take();
375 let _dir = with_empty_home(&guard);
376 std::env::set_var(DATADOG_SITE, "https://api.us3.datadoghq.com/");
377
378 let status = status();
379 assert_eq!(status.scopes[0].site.as_deref(), Some("us3.datadoghq.com"));
380 }
381
382 #[test]
383 fn load_credentials_errors_when_api_key_missing() {
384 let guard = EnvGuard::take();
385 let _dir = with_empty_home(&guard);
386 std::env::set_var(DATADOG_APP_KEY, "app");
387
388 let err = load_credentials().unwrap_err();
389 assert!(err.to_string().contains("not configured"));
390 }
391
392 #[test]
393 fn load_credentials_defaults_site_when_unset() {
394 let guard = EnvGuard::take();
395 let _dir = with_empty_home(&guard);
396 std::env::set_var(DATADOG_API_KEY, "api");
397 std::env::set_var(DATADOG_APP_KEY, "app");
398
399 let creds = load_credentials().unwrap();
400 assert_eq!(creds.site, DEFAULT_SITE);
401 }
402
403 #[test]
404 fn load_credentials_warns_on_unknown_site_but_succeeds() {
405 let guard = EnvGuard::take();
406 let _dir = with_empty_home(&guard);
407 std::env::set_var(DATADOG_API_KEY, "api");
408 std::env::set_var(DATADOG_APP_KEY, "app");
409 std::env::set_var(DATADOG_SITE, "custom.example");
410
411 let creds = load_credentials().unwrap();
412 assert_eq!(creds.site, "custom.example");
413 }
414
415 #[test]
418 fn save_then_remove_round_trip() {
419 let _guard = EnvGuard::take();
420
421 {
423 let temp_dir = {
424 std::fs::create_dir_all("tmp").ok();
425 tempfile::TempDir::new_in("tmp").unwrap()
426 };
427 std::env::set_var("HOME", temp_dir.path());
428
429 let creds = DatadogCredentials {
430 api_key: "api-1".to_string(),
431 app_key: "app-1".to_string(),
432 site: "datadoghq.com".to_string(),
433 };
434 save_credentials(&creds).unwrap();
435
436 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
437 assert!(settings_path.exists());
438 let content = fs::read_to_string(&settings_path).unwrap();
439 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
440 assert_eq!(val["env"]["DATADOG_API_KEY"], "api-1");
441 assert_eq!(val["env"]["DATADOG_APP_KEY"], "app-1");
442 assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.com");
443 }
444
445 {
447 let temp_dir = {
448 std::fs::create_dir_all("tmp").ok();
449 tempfile::TempDir::new_in("tmp").unwrap()
450 };
451 let omni_dir = temp_dir.path().join(".omni-dev");
452 fs::create_dir_all(&omni_dir).unwrap();
453 let settings_path = omni_dir.join("settings.json");
454 fs::write(
455 &settings_path,
456 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
457 )
458 .unwrap();
459
460 std::env::set_var("HOME", temp_dir.path());
461
462 let creds = DatadogCredentials {
463 api_key: "api-2".to_string(),
464 app_key: "app-2".to_string(),
465 site: "datadoghq.eu".to_string(),
466 };
467 save_credentials(&creds).unwrap();
468
469 let val: serde_json::Value =
470 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
471 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
472 assert_eq!(val["extra"], true);
473 assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.eu");
474 }
475
476 {
478 let temp_dir = {
479 std::fs::create_dir_all("tmp").ok();
480 tempfile::TempDir::new_in("tmp").unwrap()
481 };
482 let omni_dir = temp_dir.path().join(".omni-dev");
483 fs::create_dir_all(&omni_dir).unwrap();
484 let settings_path = omni_dir.join("settings.json");
485 fs::write(
486 &settings_path,
487 r#"{"env": {
488 "DATADOG_API_KEY": "a",
489 "DATADOG_APP_KEY": "b",
490 "DATADOG_SITE": "datadoghq.com",
491 "OTHER_KEY": "keep"
492 }}"#,
493 )
494 .unwrap();
495 std::env::set_var("HOME", temp_dir.path());
496
497 let removed = remove_credentials().unwrap();
498 assert!(removed);
499
500 let val: serde_json::Value =
501 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
502 assert!(val["env"].get("DATADOG_API_KEY").is_none());
503 assert!(val["env"].get("DATADOG_APP_KEY").is_none());
504 assert!(val["env"].get("DATADOG_SITE").is_none());
505 assert_eq!(val["env"]["OTHER_KEY"], "keep");
506 }
507
508 {
510 let temp_dir = {
511 std::fs::create_dir_all("tmp").ok();
512 tempfile::TempDir::new_in("tmp").unwrap()
513 };
514 std::env::set_var("HOME", temp_dir.path());
515 let removed = remove_credentials().unwrap();
516 assert!(!removed);
517 }
518 }
519}