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()
);
}
}