dd-config 0.3.0

Configuration loader for the ddog Datadog CLI.
Documentation
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::Deserialize;

pub const DEFAULT_SITE: &str = "datadoghq.com";

#[derive(Debug, Clone)]
pub struct ResolvedConfig {
    pub api_key: String,
    pub app_key: String,
    pub site: String,
    pub profile: Option<String>,
    pub source: ConfigSource,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSource {
    EnvOnly,
    File,
    FileAndEnv,
}

#[derive(Debug, Default, Clone, Deserialize)]
pub struct FileConfig {
    pub default_site: Option<String>,
    pub default_profile: Option<String>,
    #[serde(default)]
    pub profiles: BTreeMap<String, Profile>,
}

#[derive(Debug, Default, Clone, Deserialize)]
pub struct Profile {
    pub api_key: Option<String>,
    pub app_key: Option<String>,
    pub site: Option<String>,
}

#[derive(Debug, Default, Clone)]
pub struct Overrides {
    pub api_key: Option<String>,
    pub app_key: Option<String>,
    pub site: Option<String>,
    pub profile: Option<String>,
    pub config_path: Option<PathBuf>,
}

pub fn default_config_path() -> Option<PathBuf> {
    directories::ProjectDirs::from("com", "ddog", "ddog")
        .map(|dirs| dirs.config_dir().join("config.toml"))
}

pub fn resolve(overrides: Overrides) -> Result<ResolvedConfig> {
    let env_api_key = std::env::var("DD_API_KEY").ok();
    let env_app_key = std::env::var("DD_APP_KEY").ok();
    let env_site = std::env::var("DD_SITE").ok();
    let env_profile = std::env::var("DD_PROFILE").ok();

    let config_path = overrides
        .config_path
        .clone()
        .or_else(|| std::env::var("DD_CONFIG").ok().map(PathBuf::from))
        .or_else(default_config_path);

    let (file_config, file_loaded) = match config_path.as_deref() {
        Some(path) if path.exists() => (load_file(path)?, true),
        _ => (FileConfig::default(), false),
    };

    let profile_name = overrides
        .profile
        .clone()
        .or(env_profile)
        .or_else(|| file_config.default_profile.clone());

    let profile = match profile_name.as_deref() {
        Some(name) => Some(
            file_config
                .profiles
                .get(name)
                .with_context(|| {
                    format!("profile '{name}' not found in config file")
                })?
                .clone(),
        ),
        None => None,
    };

    let api_key = overrides
        .api_key
        .or(env_api_key)
        .or_else(|| profile.as_ref().and_then(|p| p.api_key.clone()));
    let app_key = overrides
        .app_key
        .or(env_app_key)
        .or_else(|| profile.as_ref().and_then(|p| p.app_key.clone()));
    let site = overrides
        .site
        .or(env_site)
        .or_else(|| profile.as_ref().and_then(|p| p.site.clone()))
        .or(file_config.default_site)
        .unwrap_or_else(|| DEFAULT_SITE.to_string());

    let api_key = api_key
        .filter(|s| !s.is_empty())
        .context("DD_API_KEY is required (set --api-key, DD_API_KEY env, or profile)")?;
    let app_key = app_key
        .filter(|s| !s.is_empty())
        .context("DD_APP_KEY is required (set --app-key, DD_APP_KEY env, or profile)")?;

    let source = match (file_loaded, std::env::var("DD_API_KEY").is_ok()) {
        (true, true) => ConfigSource::FileAndEnv,
        (true, false) => ConfigSource::File,
        _ => ConfigSource::EnvOnly,
    };

    Ok(ResolvedConfig {
        api_key,
        app_key,
        site,
        profile: profile_name,
        source,
    })
}

fn load_file(path: &Path) -> Result<FileConfig> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("reading config file {}", path.display()))?;
    let parsed: FileConfig = toml::from_str(&raw)
        .with_context(|| format!("parsing config file {}", path.display()))?;
    for (name, _) in &parsed.profiles {
        if name.trim().is_empty() {
            bail!("profile names cannot be empty");
        }
    }
    Ok(parsed)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_profiles_toml() {
        let toml = r#"
            default_site = "datadoghq.eu"
            default_profile = "prod"

            [profiles.prod]
            api_key = "a"
            app_key = "b"

            [profiles.staging]
            api_key = "c"
            app_key = "d"
            site = "us5.datadoghq.com"
        "#;
        let parsed: FileConfig = toml::from_str(toml).unwrap();
        assert_eq!(parsed.default_site.as_deref(), Some("datadoghq.eu"));
        assert_eq!(parsed.default_profile.as_deref(), Some("prod"));
        assert_eq!(parsed.profiles.len(), 2);
        assert_eq!(
            parsed.profiles["staging"].site.as_deref(),
            Some("us5.datadoghq.com")
        );
    }
}