use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtupaConfig {
pub rpc_url: String,
pub etherscan_key: Option<String>,
pub output_dir: String,
pub studio_dir: Option<PathBuf>,
pub studio_port: u16,
}
impl Default for AtupaConfig {
fn default() -> Self {
Self {
rpc_url: "http://localhost:8547".to_string(),
etherscan_key: None,
output_dir: ".".to_string(),
studio_dir: None,
studio_port: 5173,
}
}
}
impl AtupaConfig {
pub fn load() -> Self {
match Self::build_figment().extract::<Self>() {
Ok(config) => config,
Err(e) => {
log::warn!(
"Failed to parse Atupa configuration — falling back to defaults. \
Check your atupa.toml or ~/.atupa/config.toml. Error: {e}"
);
Self::default()
}
}
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.rpc_url.trim().is_empty() {
anyhow::bail!(
"rpc_url must not be empty. \
Set it via the ATUPA_RPC_URL environment variable, atupa.toml, or the --rpc flag."
);
}
Ok(())
}
fn build_figment() -> Figment {
let mut figment = Figment::from(Serialized::defaults(Self::default()));
if let Some(home) = dirs::home_dir() {
figment = figment.merge(Toml::file(home.join(".atupa").join("config.toml")));
}
figment = figment.merge(Toml::file("atupa.toml"));
figment = figment.merge(Env::prefixed("ATUPA_"));
figment
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn default_values_are_sane() {
let cfg = AtupaConfig::default();
assert_eq!(cfg.rpc_url, "http://localhost:8547");
assert_eq!(cfg.studio_port, 5173);
assert_eq!(cfg.output_dir, ".");
assert!(cfg.etherscan_key.is_none());
assert!(cfg.studio_dir.is_none());
}
#[test]
fn validate_rejects_empty_rpc_url() {
let cfg = AtupaConfig { rpc_url: String::new(), ..Default::default() };
assert!(cfg.validate().is_err(), "empty rpc_url should fail validation");
}
#[test]
fn validate_rejects_whitespace_only_rpc_url() {
let cfg = AtupaConfig { rpc_url: " ".to_string(), ..Default::default() };
assert!(cfg.validate().is_err(), "whitespace-only rpc_url should fail validation");
}
#[test]
fn validate_accepts_default_config() {
assert!(
AtupaConfig::default().validate().is_ok(),
"default config should pass validation"
);
}
#[test]
fn env_vars_override_rpc_url_and_key() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("ATUPA_RPC_URL", "http://test-rpc.local");
std::env::set_var("ATUPA_ETHERSCAN_KEY", "test-key-123");
}
let cfg = AtupaConfig::load();
unsafe {
std::env::remove_var("ATUPA_RPC_URL");
std::env::remove_var("ATUPA_ETHERSCAN_KEY");
}
assert_eq!(cfg.rpc_url, "http://test-rpc.local");
assert_eq!(cfg.etherscan_key, Some("test-key-123".to_string()));
}
}