1use figment::{
4 providers::{Env, Format, Serialized, Toml},
5 Figment,
6};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AtupaConfig {
22 pub rpc_url: String,
24 pub etherscan_key: Option<String>,
26 pub output_dir: String,
28 pub studio_dir: Option<PathBuf>,
30 pub studio_port: u16,
32}
33
34impl Default for AtupaConfig {
35 fn default() -> Self {
36 Self {
37 rpc_url: "http://localhost:8547".to_string(),
38 etherscan_key: None,
39 output_dir: ".".to_string(),
40 studio_dir: None,
41 studio_port: 5173,
42 }
43 }
44}
45
46impl AtupaConfig {
47 pub fn load() -> Self {
53 match Self::build_figment().extract::<Self>() {
54 Ok(config) => config,
55 Err(e) => {
56 log::warn!(
57 "Failed to parse Atupa configuration — falling back to defaults. \
58 Check your atupa.toml or ~/.atupa/config.toml. Error: {e}"
59 );
60 Self::default()
61 }
62 }
63 }
64
65 pub fn validate(&self) -> anyhow::Result<()> {
73 if self.rpc_url.trim().is_empty() {
74 anyhow::bail!(
75 "rpc_url must not be empty. \
76 Set it via the ATUPA_RPC_URL environment variable, atupa.toml, or the --rpc flag."
77 );
78 }
79 Ok(())
80 }
81
82 fn build_figment() -> Figment {
85 let mut figment = Figment::from(Serialized::defaults(Self::default()));
86
87 if let Some(home) = dirs::home_dir() {
89 figment = figment.merge(Toml::file(home.join(".atupa").join("config.toml")));
90 }
91
92 figment = figment.merge(Toml::file("atupa.toml"));
94
95 figment = figment.merge(Env::prefixed("ATUPA_"));
97
98 figment
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use std::sync::Mutex;
106
107 static ENV_LOCK: Mutex<()> = Mutex::new(());
113
114 #[test]
115 fn default_values_are_sane() {
116 let cfg = AtupaConfig::default();
117 assert_eq!(cfg.rpc_url, "http://localhost:8547");
118 assert_eq!(cfg.studio_port, 5173);
119 assert_eq!(cfg.output_dir, ".");
120 assert!(cfg.etherscan_key.is_none());
121 assert!(cfg.studio_dir.is_none());
122 }
123
124 #[test]
125 fn validate_rejects_empty_rpc_url() {
126 let cfg = AtupaConfig { rpc_url: String::new(), ..Default::default() };
127 assert!(cfg.validate().is_err(), "empty rpc_url should fail validation");
128 }
129
130 #[test]
131 fn validate_rejects_whitespace_only_rpc_url() {
132 let cfg = AtupaConfig { rpc_url: " ".to_string(), ..Default::default() };
133 assert!(cfg.validate().is_err(), "whitespace-only rpc_url should fail validation");
134 }
135
136 #[test]
137 fn validate_accepts_default_config() {
138 assert!(
139 AtupaConfig::default().validate().is_ok(),
140 "default config should pass validation"
141 );
142 }
143
144 #[test]
145 fn env_vars_override_rpc_url_and_key() {
146 let _guard = ENV_LOCK.lock().unwrap();
148
149 unsafe {
150 std::env::set_var("ATUPA_RPC_URL", "http://test-rpc.local");
151 std::env::set_var("ATUPA_ETHERSCAN_KEY", "test-key-123");
152 }
153
154 let cfg = AtupaConfig::load();
155
156 unsafe {
158 std::env::remove_var("ATUPA_RPC_URL");
159 std::env::remove_var("ATUPA_ETHERSCAN_KEY");
160 }
161
162 assert_eq!(cfg.rpc_url, "http://test-rpc.local");
163 assert_eq!(cfg.etherscan_key, Some("test-key-123".to_string()));
164 }
165}