Skip to main content

dd_config/
lib.rs

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}