1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{bail, Context, Result};
5use serde::Deserialize;
6
7pub const DEFAULT_SITE: &str = "datadoghq.com";
8
9#[derive(Debug, Clone)]
10pub struct ResolvedConfig {
11 pub api_key: String,
12 pub app_key: String,
13 pub site: String,
14 pub profile: Option<String>,
15 pub source: ConfigSource,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ConfigSource {
20 EnvOnly,
21 File,
22 FileAndEnv,
23}
24
25#[derive(Debug, Default, Clone, Deserialize)]
26pub struct FileConfig {
27 pub default_site: Option<String>,
28 pub default_profile: Option<String>,
29 #[serde(default)]
30 pub profiles: BTreeMap<String, Profile>,
31}
32
33#[derive(Debug, Default, Clone, Deserialize)]
34pub struct Profile {
35 pub api_key: Option<String>,
36 pub app_key: Option<String>,
37 pub site: Option<String>,
38}
39
40#[derive(Debug, Default, Clone)]
41pub struct Overrides {
42 pub api_key: Option<String>,
43 pub app_key: Option<String>,
44 pub site: Option<String>,
45 pub profile: Option<String>,
46 pub config_path: Option<PathBuf>,
47}
48
49pub fn default_config_path() -> Option<PathBuf> {
50 directories::ProjectDirs::from("com", "ddog", "ddog")
51 .map(|dirs| dirs.config_dir().join("config.toml"))
52}
53
54pub fn resolve(overrides: Overrides) -> Result<ResolvedConfig> {
55 let env_api_key = std::env::var("DD_API_KEY").ok();
56 let env_app_key = std::env::var("DD_APP_KEY").ok();
57 let env_site = std::env::var("DD_SITE").ok();
58 let env_profile = std::env::var("DD_PROFILE").ok();
59
60 let config_path = overrides
61 .config_path
62 .clone()
63 .or_else(|| std::env::var("DD_CONFIG").ok().map(PathBuf::from))
64 .or_else(default_config_path);
65
66 let (file_config, file_loaded) = match config_path.as_deref() {
67 Some(path) if path.exists() => (load_file(path)?, true),
68 _ => (FileConfig::default(), false),
69 };
70
71 let profile_name = overrides
72 .profile
73 .clone()
74 .or(env_profile)
75 .or_else(|| file_config.default_profile.clone());
76
77 let profile = match profile_name.as_deref() {
78 Some(name) => Some(
79 file_config
80 .profiles
81 .get(name)
82 .with_context(|| {
83 format!("profile '{name}' not found in config file")
84 })?
85 .clone(),
86 ),
87 None => None,
88 };
89
90 let api_key = overrides
91 .api_key
92 .or(env_api_key)
93 .or_else(|| profile.as_ref().and_then(|p| p.api_key.clone()));
94 let app_key = overrides
95 .app_key
96 .or(env_app_key)
97 .or_else(|| profile.as_ref().and_then(|p| p.app_key.clone()));
98 let site = overrides
99 .site
100 .or(env_site)
101 .or_else(|| profile.as_ref().and_then(|p| p.site.clone()))
102 .or(file_config.default_site)
103 .unwrap_or_else(|| DEFAULT_SITE.to_string());
104
105 let api_key = api_key
106 .filter(|s| !s.is_empty())
107 .context("DD_API_KEY is required (set --api-key, DD_API_KEY env, or profile)")?;
108 let app_key = app_key
109 .filter(|s| !s.is_empty())
110 .context("DD_APP_KEY is required (set --app-key, DD_APP_KEY env, or profile)")?;
111
112 let source = match (file_loaded, std::env::var("DD_API_KEY").is_ok()) {
113 (true, true) => ConfigSource::FileAndEnv,
114 (true, false) => ConfigSource::File,
115 _ => ConfigSource::EnvOnly,
116 };
117
118 Ok(ResolvedConfig {
119 api_key,
120 app_key,
121 site,
122 profile: profile_name,
123 source,
124 })
125}
126
127fn load_file(path: &Path) -> Result<FileConfig> {
128 let raw = std::fs::read_to_string(path)
129 .with_context(|| format!("reading config file {}", path.display()))?;
130 let parsed: FileConfig = toml::from_str(&raw)
131 .with_context(|| format!("parsing config file {}", path.display()))?;
132 for (name, _) in &parsed.profiles {
133 if name.trim().is_empty() {
134 bail!("profile names cannot be empty");
135 }
136 }
137 Ok(parsed)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn parses_profiles_toml() {
146 let toml = r#"
147 default_site = "datadoghq.eu"
148 default_profile = "prod"
149
150 [profiles.prod]
151 api_key = "a"
152 app_key = "b"
153
154 [profiles.staging]
155 api_key = "c"
156 app_key = "d"
157 site = "us5.datadoghq.com"
158 "#;
159 let parsed: FileConfig = toml::from_str(toml).unwrap();
160 assert_eq!(parsed.default_site.as_deref(), Some("datadoghq.eu"));
161 assert_eq!(parsed.default_profile.as_deref(), Some("prod"));
162 assert_eq!(parsed.profiles.len(), 2);
163 assert_eq!(
164 parsed.profiles["staging"].site.as_deref(),
165 Some("us5.datadoghq.com")
166 );
167 }
168}