1use std::fs;
7
8use anyhow::{Context, Result};
9use serde::Serialize;
10
11use crate::datadog::error::DatadogError;
12use crate::utils::settings::Settings;
13
14pub const DATADOG_API_KEY: &str = "DATADOG_API_KEY";
16
17pub const DATADOG_APP_KEY: &str = "DATADOG_APP_KEY";
19
20pub const DATADOG_SITE: &str = "DATADOG_SITE";
22
23pub const DATADOG_API_URL: &str = "DATADOG_API_URL";
30
31pub const DEFAULT_SITE: &str = "datadoghq.com";
33
34pub const KNOWN_SITES: &[&str] = &[
40 "datadoghq.com",
41 "us3.datadoghq.com",
42 "us5.datadoghq.com",
43 "datadoghq.eu",
44 "ap1.datadoghq.com",
45 "ddog-gov.com",
46];
47
48#[derive(Debug, Clone)]
50pub struct DatadogCredentials {
51 pub api_key: String,
53
54 pub app_key: String,
56
57 pub site: String,
59}
60
61pub fn normalize_site(raw: &str) -> String {
66 let trimmed = raw.trim();
67 let no_scheme = trimmed
68 .strip_prefix("https://")
69 .or_else(|| trimmed.strip_prefix("http://"))
70 .unwrap_or(trimmed);
71 let no_api = no_scheme.strip_prefix("api.").unwrap_or(no_scheme);
72 no_api.trim_end_matches('/').to_string()
73}
74
75pub fn base_url_for_site(site: &str) -> String {
77 format!("https://api.{}", normalize_site(site))
78}
79
80pub fn load_credentials() -> Result<DatadogCredentials> {
85 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
86}
87
88pub(crate) fn load_credentials_with(
95 env: &impl crate::utils::env::EnvSource,
96) -> Result<DatadogCredentials> {
97 let api_key = env
98 .var(DATADOG_API_KEY)
99 .ok_or(DatadogError::CredentialsNotFound)?;
100 let app_key = env
101 .var(DATADOG_APP_KEY)
102 .ok_or(DatadogError::CredentialsNotFound)?;
103 let site = env
104 .var(DATADOG_SITE)
105 .map(|s| normalize_site(&s))
106 .filter(|s| !s.is_empty())
107 .unwrap_or_else(|| DEFAULT_SITE.to_string());
108
109 if !KNOWN_SITES.iter().any(|k| *k == site) {
110 eprintln!("warning: Datadog site '{site}' is not a known region; proceeding anyway");
111 }
112
113 Ok(DatadogCredentials {
114 api_key,
115 app_key,
116 site,
117 })
118}
119
120#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
125pub struct DatadogScopeStatus {
126 pub name: String,
129 pub has_api_key: bool,
131 pub has_app_key: bool,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub site: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
140pub struct AuthStatus {
141 pub scopes: Vec<DatadogScopeStatus>,
144}
145
146pub fn status() -> AuthStatus {
151 status_with(&crate::utils::settings::SettingsEnv::load())
152}
153
154pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> AuthStatus {
159 let has_api_key = env.var(DATADOG_API_KEY).is_some();
160 let has_app_key = env.var(DATADOG_APP_KEY).is_some();
161 let site = env
162 .var(DATADOG_SITE)
163 .map(|s| normalize_site(&s))
164 .filter(|s| !s.is_empty());
165
166 AuthStatus {
167 scopes: vec![DatadogScopeStatus {
168 name: "default".to_string(),
169 has_api_key,
170 has_app_key,
171 site,
172 }],
173 }
174}
175
176pub fn save_credentials(credentials: &DatadogCredentials) -> Result<()> {
181 save_credentials_to(&Settings::get_settings_path()?, credentials)
182}
183
184pub(crate) fn save_credentials_to(
188 settings_path: &std::path::Path,
189 credentials: &DatadogCredentials,
190) -> Result<()> {
191 let mut settings_value = read_or_default_settings(settings_path)?;
192 ensure_env_object(&mut settings_value);
193
194 let Some(env) = settings_value["env"].as_object_mut() else {
195 anyhow::bail!("Internal error: env key is not an object after initialization");
196 };
197 env.insert(
198 DATADOG_API_KEY.to_string(),
199 serde_json::Value::String(credentials.api_key.clone()),
200 );
201 env.insert(
202 DATADOG_APP_KEY.to_string(),
203 serde_json::Value::String(credentials.app_key.clone()),
204 );
205 env.insert(
206 DATADOG_SITE.to_string(),
207 serde_json::Value::String(credentials.site.clone()),
208 );
209
210 write_settings(settings_path, &settings_value)
211}
212
213pub fn remove_credentials() -> Result<bool> {
219 remove_credentials_at(&Settings::get_settings_path()?)
220}
221
222pub(crate) fn remove_credentials_at(settings_path: &std::path::Path) -> Result<bool> {
226 if !settings_path.exists() {
227 return Ok(false);
228 }
229 let mut settings_value = read_or_default_settings(settings_path)?;
230
231 let mut removed = false;
232 if let Some(env) = settings_value
233 .get_mut("env")
234 .and_then(serde_json::Value::as_object_mut)
235 {
236 for key in [DATADOG_API_KEY, DATADOG_APP_KEY, DATADOG_SITE] {
237 if env.remove(key).is_some() {
238 removed = true;
239 }
240 }
241 }
242
243 if removed {
244 write_settings(settings_path, &settings_value)?;
245 }
246 Ok(removed)
247}
248
249fn read_or_default_settings(path: &std::path::Path) -> Result<serde_json::Value> {
250 if path.exists() {
251 let content = fs::read_to_string(path)
252 .with_context(|| format!("Failed to read {}", path.display()))?;
253 serde_json::from_str(&content)
254 .with_context(|| format!("Failed to parse {}", path.display()))
255 } else {
256 Ok(serde_json::json!({}))
257 }
258}
259
260fn ensure_env_object(value: &mut serde_json::Value) {
261 if !value.get("env").is_some_and(serde_json::Value::is_object) {
262 value["env"] = serde_json::json!({});
263 }
264}
265
266fn write_settings(path: &std::path::Path, value: &serde_json::Value) -> Result<()> {
267 if let Some(parent) = path.parent() {
268 fs::create_dir_all(parent)
269 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
270 }
271 let formatted =
272 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
273 fs::write(path, formatted).with_context(|| format!("Failed to write {}", path.display()))?;
274 Ok(())
275}
276
277#[cfg(test)]
278#[allow(clippy::unwrap_used, clippy::expect_used)]
279mod tests {
280 use super::*;
281
282 #[test]
285 fn normalize_site_strips_scheme_and_api_prefix() {
286 assert_eq!(normalize_site("datadoghq.com"), "datadoghq.com");
287 assert_eq!(normalize_site("https://datadoghq.com"), "datadoghq.com");
288 assert_eq!(normalize_site("http://datadoghq.com"), "datadoghq.com");
289 assert_eq!(normalize_site("api.datadoghq.com"), "datadoghq.com");
290 assert_eq!(normalize_site("https://api.datadoghq.com"), "datadoghq.com");
291 assert_eq!(
292 normalize_site("https://api.us3.datadoghq.com/"),
293 "us3.datadoghq.com"
294 );
295 }
296
297 #[test]
298 fn normalize_site_trims_whitespace() {
299 assert_eq!(normalize_site(" datadoghq.com "), "datadoghq.com");
300 }
301
302 #[test]
303 fn base_url_for_site_builds_api_host() {
304 assert_eq!(
305 base_url_for_site("datadoghq.com"),
306 "https://api.datadoghq.com"
307 );
308 assert_eq!(
309 base_url_for_site("us5.datadoghq.com"),
310 "https://api.us5.datadoghq.com"
311 );
312 assert_eq!(
313 base_url_for_site("datadoghq.eu"),
314 "https://api.datadoghq.eu"
315 );
316 }
317
318 #[test]
319 fn base_url_normalises_input() {
320 assert_eq!(
321 base_url_for_site("https://api.datadoghq.com/"),
322 "https://api.datadoghq.com"
323 );
324 }
325
326 #[test]
327 fn credentials_struct_clone_and_debug() {
328 let creds = DatadogCredentials {
329 api_key: "a".to_string(),
330 app_key: "b".to_string(),
331 site: "datadoghq.com".to_string(),
332 };
333 let cloned = creds.clone();
334 assert_eq!(cloned.api_key, creds.api_key);
335 assert!(format!("{creds:?}").contains("DatadogCredentials"));
336 }
337
338 #[test]
339 fn constant_key_names() {
340 assert_eq!(DATADOG_API_KEY, "DATADOG_API_KEY");
341 assert_eq!(DATADOG_APP_KEY, "DATADOG_APP_KEY");
342 assert_eq!(DATADOG_SITE, "DATADOG_SITE");
343 assert_eq!(DEFAULT_SITE, "datadoghq.com");
344 }
345
346 #[test]
347 fn known_sites_contains_common_regions() {
348 assert!(KNOWN_SITES.contains(&"datadoghq.com"));
349 assert!(KNOWN_SITES.contains(&"datadoghq.eu"));
350 assert!(KNOWN_SITES.contains(&"us5.datadoghq.com"));
351 }
352
353 use crate::test_support::env::MapEnv;
356
357 #[test]
358 fn status_reports_all_false_when_nothing_configured() {
359 let status = status_with(&MapEnv::new());
360 assert_eq!(status.scopes.len(), 1);
361 let scope = &status.scopes[0];
362 assert_eq!(scope.name, "default");
363 assert!(!scope.has_api_key);
364 assert!(!scope.has_app_key);
365 assert_eq!(scope.site, None);
366 }
367
368 #[test]
369 fn status_reports_presence_flags_without_leaking_secrets() {
370 let env = MapEnv::new()
371 .with(DATADOG_API_KEY, "sekret-api-do-not-leak")
372 .with(DATADOG_APP_KEY, "sekret-app-do-not-leak")
373 .with(DATADOG_SITE, "datadoghq.com");
374
375 let status = status_with(&env);
376 let scope = &status.scopes[0];
377 assert!(scope.has_api_key);
378 assert!(scope.has_app_key);
379 assert_eq!(scope.site.as_deref(), Some("datadoghq.com"));
380
381 let yaml = serde_yaml::to_string(&status).unwrap();
382 assert!(!yaml.contains("sekret-api-do-not-leak"));
383 assert!(!yaml.contains("sekret-app-do-not-leak"));
384 }
385
386 #[test]
387 fn status_normalises_site_value() {
388 let env = MapEnv::new().with(DATADOG_SITE, "https://api.us3.datadoghq.com/");
389 let status = status_with(&env);
390 assert_eq!(status.scopes[0].site.as_deref(), Some("us3.datadoghq.com"));
391 }
392
393 #[test]
394 fn load_credentials_errors_when_api_key_missing() {
395 let env = MapEnv::new().with(DATADOG_APP_KEY, "app");
396 let err = load_credentials_with(&env).unwrap_err();
397 assert!(err.to_string().contains("not configured"));
398 }
399
400 #[test]
401 fn load_credentials_defaults_site_when_unset() {
402 let env = MapEnv::new()
403 .with(DATADOG_API_KEY, "api")
404 .with(DATADOG_APP_KEY, "app");
405 let creds = load_credentials_with(&env).unwrap();
406 assert_eq!(creds.site, DEFAULT_SITE);
407 }
408
409 #[test]
410 fn load_credentials_warns_on_unknown_site_but_succeeds() {
411 let env = MapEnv::new()
412 .with(DATADOG_API_KEY, "api")
413 .with(DATADOG_APP_KEY, "app")
414 .with(DATADOG_SITE, "custom.example");
415 let creds = load_credentials_with(&env).unwrap();
416 assert_eq!(creds.site, "custom.example");
417 }
418
419 #[test]
423 fn save_then_remove_round_trip() {
424 {
426 let temp_dir = {
427 std::fs::create_dir_all("tmp").ok();
428 tempfile::TempDir::new_in("tmp").unwrap()
429 };
430 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
431
432 let creds = DatadogCredentials {
433 api_key: "api-1".to_string(),
434 app_key: "app-1".to_string(),
435 site: "datadoghq.com".to_string(),
436 };
437 save_credentials_to(&settings_path, &creds).unwrap();
438
439 assert!(settings_path.exists());
440 let content = fs::read_to_string(&settings_path).unwrap();
441 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
442 assert_eq!(val["env"]["DATADOG_API_KEY"], "api-1");
443 assert_eq!(val["env"]["DATADOG_APP_KEY"], "app-1");
444 assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.com");
445 }
446
447 {
449 let temp_dir = {
450 std::fs::create_dir_all("tmp").ok();
451 tempfile::TempDir::new_in("tmp").unwrap()
452 };
453 let omni_dir = temp_dir.path().join(".omni-dev");
454 fs::create_dir_all(&omni_dir).unwrap();
455 let settings_path = omni_dir.join("settings.json");
456 fs::write(
457 &settings_path,
458 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
459 )
460 .unwrap();
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_to(&settings_path, &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
496 let removed = remove_credentials_at(&settings_path).unwrap();
497 assert!(removed);
498
499 let val: serde_json::Value =
500 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
501 assert!(val["env"].get("DATADOG_API_KEY").is_none());
502 assert!(val["env"].get("DATADOG_APP_KEY").is_none());
503 assert!(val["env"].get("DATADOG_SITE").is_none());
504 assert_eq!(val["env"]["OTHER_KEY"], "keep");
505 }
506
507 {
509 let temp_dir = {
510 std::fs::create_dir_all("tmp").ok();
511 tempfile::TempDir::new_in("tmp").unwrap()
512 };
513 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
514 let removed = remove_credentials_at(&settings_path).unwrap();
515 assert!(!removed);
516 }
517 }
518}