exodata 0.1.1

Terminal client for Exoplanets Catalog data, SQL queries, schemas, downloads, and insights
use std::env;
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result, anyhow};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
    Auto,
    Api,
    Local,
}

impl std::fmt::Display for Backend {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Backend::Auto => write!(f, "auto"),
            Backend::Api => write!(f, "api"),
            Backend::Local => write!(f, "local"),
        }
    }
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Config {
    #[serde(default = "default_backend")]
    pub default_backend: Backend,
    #[serde(default)]
    pub api: ApiConfig,
    #[serde(default)]
    pub local: LocalConfig,
    #[serde(default)]
    pub downloads: DownloadConfig,
    #[serde(default)]
    pub output: OutputConfig,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ApiConfig {
    #[serde(default = "default_base_url")]
    pub base_url: String,
    #[serde(default = "default_timeout_seconds")]
    pub timeout_seconds: u64,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LocalConfig {
    #[serde(default = "default_data_dir_string")]
    pub data_dir: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DownloadConfig {
    #[serde(default = "default_data_dir_string")]
    pub directory: String,
    #[serde(default)]
    pub overwrite: bool,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OutputConfig {
    #[serde(default = "default_output_format")]
    pub format: String,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            default_backend: default_backend(),
            api: ApiConfig::default(),
            local: LocalConfig::default(),
            downloads: DownloadConfig::default(),
            output: OutputConfig::default(),
        }
    }
}

impl Default for ApiConfig {
    fn default() -> Self {
        Self {
            base_url: default_base_url(),
            timeout_seconds: default_timeout_seconds(),
        }
    }
}

impl Default for LocalConfig {
    fn default() -> Self {
        Self {
            data_dir: default_data_dir_string(),
        }
    }
}

impl Default for DownloadConfig {
    fn default() -> Self {
        Self {
            directory: default_data_dir_string(),
            overwrite: false,
        }
    }
}

impl Default for OutputConfig {
    fn default() -> Self {
        Self {
            format: default_output_format(),
        }
    }
}

impl Config {
    pub fn load() -> Result<Self> {
        let path = config_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }

        let text = fs::read_to_string(&path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        toml::from_str(&text)
            .with_context(|| format!("failed to parse {}", path.display()))
    }

    pub fn save(&self) -> Result<()> {
        let path = config_path()?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!("failed to create {}", parent.display())
            })?;
        }
        let text = toml::to_string_pretty(self)?;
        fs::write(&path, text)
            .with_context(|| format!("failed to write {}", path.display()))
    }

    pub fn backend(&self, flag: Option<Backend>) -> Backend {
        if let Some(backend) = flag {
            return backend;
        }
        if let Ok(value) = env::var("EXO_BACKEND") {
            match value.trim().to_ascii_lowercase().as_str() {
                "auto" => return Backend::Auto,
                "api" => return Backend::Api,
                "local" => return Backend::Local,
                _ => {}
            }
        }
        self.default_backend
    }

    pub fn api_base_url(&self, flag: Option<String>) -> String {
        flag.or_else(|| env::var("EXO_API_BASE_URL").ok())
            .unwrap_or_else(|| self.api.base_url.clone())
            .trim_end_matches('/')
            .to_string()
    }

    pub fn data_dir(&self, flag: Option<String>) -> String {
        flag.or_else(|| env::var("EXO_DATA_DIR").ok())
            .unwrap_or_else(|| self.local.data_dir.clone())
    }

    pub fn download_dir(&self, flag: Option<String>) -> String {
        flag.or_else(|| env::var("EXO_DOWNLOAD_DIR").ok())
            .unwrap_or_else(|| self.downloads.directory.clone())
    }
}

pub fn config_path() -> Result<PathBuf> {
    Ok(base_dir()?.join("config.toml"))
}

pub fn base_dir() -> Result<PathBuf> {
    dirs::home_dir()
        .map(|home| home.join(".exodata"))
        .ok_or_else(|| anyhow!("could not determine home directory"))
}

pub fn get_config_value(config: &Config, key: &str) -> Result<String> {
    match key {
        "default_backend" => Ok(config.default_backend.to_string()),
        "api.base_url" => Ok(config.api.base_url.clone()),
        "api.timeout_seconds" => Ok(config.api.timeout_seconds.to_string()),
        "local.data_dir" => Ok(config.local.data_dir.clone()),
        "downloads.directory" => Ok(config.downloads.directory.clone()),
        "downloads.overwrite" => Ok(config.downloads.overwrite.to_string()),
        "output.format" => Ok(config.output.format.clone()),
        _ => Err(anyhow!("unknown config key '{}'", key)),
    }
}

pub fn set_config_value(
    config: &mut Config,
    key: &str,
    value: &str,
) -> Result<()> {
    match key {
        "default_backend" => {
            config.default_backend = parse_backend(value)?;
        }
        "api.base_url" => {
            config.api.base_url = value.trim_end_matches('/').to_string();
        }
        "api.timeout_seconds" => {
            config.api.timeout_seconds = value.parse()?;
        }
        "local.data_dir" => {
            config.local.data_dir = value.to_string();
        }
        "downloads.directory" => {
            config.downloads.directory = value.to_string();
        }
        "downloads.overwrite" => {
            config.downloads.overwrite = value.parse()?;
        }
        "output.format" => {
            config.output.format = value.to_string();
        }
        _ => return Err(anyhow!("unknown config key '{}'", key)),
    }
    Ok(())
}

fn parse_backend(value: &str) -> Result<Backend> {
    match value.trim().to_ascii_lowercase().as_str() {
        "auto" => Ok(Backend::Auto),
        "api" => Ok(Backend::Api),
        "local" => Ok(Backend::Local),
        _ => Err(anyhow!("backend must be 'auto', 'api', or 'local'")),
    }
}

fn default_backend() -> Backend {
    Backend::Auto
}

fn default_base_url() -> String {
    "https://exodata.space".to_string()
}

fn default_timeout_seconds() -> u64 {
    30
}

fn default_data_dir_string() -> String {
    base_dir()
        .map(|path| path.display().to_string())
        .unwrap_or_else(|_| "~/.exodata".to_string())
}

fn default_output_format() -> String {
    "table".to_string()
}

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

    #[test]
    fn backend_display_uses_config_values() {
        assert_eq!(Backend::Auto.to_string(), "auto");
        assert_eq!(Backend::Api.to_string(), "api");
        assert_eq!(Backend::Local.to_string(), "local");
    }

    #[test]
    fn default_config_uses_public_api_and_table_output() {
        let config = Config::default();

        assert_eq!(config.default_backend, Backend::Auto);
        assert_eq!(config.api.base_url, "https://exodata.space");
        assert_eq!(config.api.timeout_seconds, 30);
        assert_eq!(config.output.format, "table");
        assert!(!config.downloads.overwrite);
    }

    #[test]
    fn explicit_backend_flag_overrides_config() {
        let config = Config {
            default_backend: Backend::Local,
            ..Default::default()
        };

        assert_eq!(config.backend(Some(Backend::Api)), Backend::Api);
    }

    #[test]
    fn url_and_path_flags_override_config_values() {
        let config = Config {
            api: ApiConfig {
                base_url: "https://example.test/api/".to_string(),
                timeout_seconds: 10,
            },
            local: LocalConfig {
                data_dir: "local-data".to_string(),
            },
            downloads: DownloadConfig {
                directory: "downloads".to_string(),
                overwrite: true,
            },
            ..Default::default()
        };

        assert_eq!(
            config.api_base_url(Some("https://override.test/".to_string())),
            "https://override.test"
        );
        assert_eq!(config.api_base_url(None), "https://example.test/api");
        assert_eq!(config.data_dir(Some("flag-data".to_string())), "flag-data");
        assert_eq!(config.data_dir(None), "local-data");
        assert_eq!(
            config.download_dir(Some("flag-downloads".to_string())),
            "flag-downloads"
        );
        assert_eq!(config.download_dir(None), "downloads");
    }

    #[test]
    fn get_and_set_config_values_cover_supported_keys() {
        let mut config = Config::default();

        set_config_value(&mut config, "default_backend", "local").unwrap();
        set_config_value(&mut config, "api.base_url", "https://api.test/")
            .unwrap();
        set_config_value(&mut config, "api.timeout_seconds", "15").unwrap();
        set_config_value(&mut config, "local.data_dir", "data").unwrap();
        set_config_value(&mut config, "downloads.directory", "dl").unwrap();
        set_config_value(&mut config, "downloads.overwrite", "true").unwrap();
        set_config_value(&mut config, "output.format", "json").unwrap();

        assert_eq!(
            get_config_value(&config, "default_backend").unwrap(),
            "local"
        );
        assert_eq!(
            get_config_value(&config, "api.base_url").unwrap(),
            "https://api.test"
        );
        assert_eq!(
            get_config_value(&config, "api.timeout_seconds").unwrap(),
            "15"
        );
        assert_eq!(get_config_value(&config, "local.data_dir").unwrap(), "data");
        assert_eq!(
            get_config_value(&config, "downloads.directory").unwrap(),
            "dl"
        );
        assert_eq!(
            get_config_value(&config, "downloads.overwrite").unwrap(),
            "true"
        );
        assert_eq!(get_config_value(&config, "output.format").unwrap(), "json");
    }

    #[test]
    fn unknown_config_key_and_backend_are_errors() {
        let mut config = Config::default();

        assert!(get_config_value(&config, "missing").is_err());
        assert!(set_config_value(&mut config, "missing", "value").is_err());
        assert!(
            set_config_value(&mut config, "default_backend", "remote").is_err()
        );
    }
}