1use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use crate::util::CliError;
15
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
18#[serde(untagged)]
19pub enum StringOrList {
20 One(String),
22 Many(Vec<String>),
24}
25
26impl StringOrList {
27 #[must_use]
29 pub fn to_vec(&self) -> Vec<String> {
30 match self {
31 StringOrList::One(s) => {
32 if s.is_empty() {
33 Vec::new()
34 } else {
35 vec![s.clone()]
36 }
37 }
38 StringOrList::Many(v) => v.iter().filter(|s| !s.is_empty()).cloned().collect(),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
49pub struct GatewayProfile {
50 pub base_url: String,
52 #[serde(skip_serializing_if = "Option::is_none", default)]
54 pub api_key: Option<String>,
55}
56
57#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
60#[serde(deny_unknown_fields)]
61pub struct CardanoWallConfig {
62 #[serde(skip_serializing_if = "Option::is_none", default)]
64 pub cardano_gateway: Option<StringOrList>,
65 #[serde(skip_serializing_if = "Option::is_none", default)]
67 pub blockfrost_project_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub arweave_gateway: Option<StringOrList>,
71 #[serde(skip_serializing_if = "Option::is_none", default)]
73 pub ipfs_gateway: Option<StringOrList>,
74 #[serde(skip_serializing_if = "Option::is_none", default)]
76 pub confirmation_depth_threshold: Option<i64>,
77 #[serde(skip_serializing_if = "Option::is_none", default)]
79 pub deny_host: Option<Vec<String>>,
80 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub default_gateway: Option<String>,
83 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
86 pub gateways: BTreeMap<String, GatewayProfile>,
87}
88
89impl CardanoWallConfig {
90 #[must_use]
93 pub fn active_gateway(&self) -> Option<&GatewayProfile> {
94 self.default_gateway
95 .as_deref()
96 .and_then(|name| self.gateways.get(name))
97 }
98
99 pub fn select_gateway<'a>(
107 &'a self,
108 requested: Option<&str>,
109 cmd: &str,
110 ) -> Result<Option<&'a GatewayProfile>, CliError> {
111 match requested.map(str::trim).filter(|s| !s.is_empty()) {
112 Some(name) => self.gateways.get(name).map(Some).ok_or_else(|| {
113 CliError::input(format!(
114 "{cmd}: no gateway profile named \"{name}\" (add one with 'cardanowall gateway add')"
115 ))
116 }),
117 None => Ok(self.active_gateway()),
118 }
119 }
120}
121
122pub trait ConfigEnv {
124 fn var(&self, key: &str) -> Option<String>;
126 fn home_dir(&self) -> Option<PathBuf>;
128 fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>>;
130 fn warn(&self, message: &str);
132}
133
134pub struct SystemConfigEnv;
137
138impl ConfigEnv for SystemConfigEnv {
139 fn var(&self, key: &str) -> Option<String> {
140 std::env::var(key).ok()
141 }
142
143 fn home_dir(&self) -> Option<PathBuf> {
144 std::env::var_os("HOME")
147 .or_else(|| std::env::var_os("USERPROFILE"))
148 .map(PathBuf::from)
149 }
150
151 fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>> {
152 match std::fs::read_to_string(path) {
153 Ok(s) => Ok(s),
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(None),
155 Err(e) => Err(Some(e)),
156 }
157 }
158
159 fn warn(&self, message: &str) {
160 eprintln!("{message}");
161 }
162}
163
164pub fn read_config_file(env: &dyn ConfigEnv) -> Result<Option<CardanoWallConfig>, CliError> {
172 let explicit = env.var("CARDANOWALL_CONFIG_PATH").filter(|p| !p.is_empty());
173 let path = match &explicit {
174 Some(p) => PathBuf::from(p),
175 None => match env.home_dir() {
176 Some(home) => home.join(".cardanowall").join("config.toml"),
177 None => return Ok(None),
178 },
179 };
180
181 let raw = match env.read_to_string(&path) {
182 Ok(raw) => raw,
183 Err(None) => {
184 if explicit.is_some() {
185 return Err(CliError::input(format!(
186 "config: CARDANOWALL_CONFIG_PATH points at a file that does not exist: {}",
187 path.display()
188 )));
189 }
190 return Ok(None);
191 }
192 Err(Some(e)) => {
193 return Err(CliError::input(format!(
194 "config: cannot read {}: {e}",
195 path.display()
196 )));
197 }
198 };
199
200 parse_config_str(&raw, &path, env).map(Some)
201}
202
203pub fn parse_config_str(
212 raw: &str,
213 path: &std::path::Path,
214 env: &dyn ConfigEnv,
215) -> Result<CardanoWallConfig, CliError> {
216 if let Ok(toml::Value::Table(table)) = raw.parse::<toml::Value>() {
219 for key in table.keys() {
220 if !KNOWN_KEYS.contains(&key.as_str()) {
221 env.warn(&format!(
222 "warning: unknown key \"{key}\" in {} (ignored)",
223 path.display()
224 ));
225 }
226 }
227 }
228
229 let filtered = filter_known_keys(raw);
233 toml::from_str(&filtered).map_err(|e| {
234 CliError::input(format!(
235 "config: TOML parse failed at {}: {e}",
236 path.display()
237 ))
238 })
239}
240
241const KNOWN_KEYS: [&str; 8] = [
242 "cardano_gateway",
243 "blockfrost_project_id",
244 "arweave_gateway",
245 "ipfs_gateway",
246 "confirmation_depth_threshold",
247 "deny_host",
248 "default_gateway",
249 "gateways",
250];
251
252pub fn config_path(env: &dyn ConfigEnv) -> Result<PathBuf, CliError> {
260 if let Some(explicit) = env.var("CARDANOWALL_CONFIG_PATH").filter(|p| !p.is_empty()) {
261 return Ok(PathBuf::from(explicit));
262 }
263 match env.home_dir() {
264 Some(home) => Ok(home.join(".cardanowall").join("config.toml")),
265 None => Err(CliError::input(
266 "config: no home directory found and CARDANOWALL_CONFIG_PATH is unset; \
267 set CARDANOWALL_CONFIG_PATH to choose where config.toml lives",
268 )),
269 }
270}
271
272fn filter_known_keys(raw: &str) -> String {
275 let Ok(toml::Value::Table(table)) = raw.parse::<toml::Value>() else {
276 return raw.to_string();
278 };
279 let mut kept = toml::value::Table::new();
280 for (k, v) in table {
281 if KNOWN_KEYS.contains(&k.as_str()) {
282 kept.insert(k, v);
283 }
284 }
285 toml::to_string(&toml::Value::Table(kept)).unwrap_or_else(|_| raw.to_string())
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::cell::RefCell;
292 use std::collections::HashMap;
293
294 struct FakeEnv {
295 vars: HashMap<String, String>,
296 files: HashMap<PathBuf, String>,
297 warnings: RefCell<Vec<String>>,
298 }
299
300 impl ConfigEnv for FakeEnv {
301 fn var(&self, key: &str) -> Option<String> {
302 self.vars.get(key).cloned()
303 }
304 fn home_dir(&self) -> Option<PathBuf> {
305 Some(PathBuf::from("/nonexistent-home"))
306 }
307 fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>> {
308 self.files.get(path).cloned().ok_or(None)
309 }
310 fn warn(&self, message: &str) {
311 self.warnings.borrow_mut().push(message.to_string());
312 }
313 }
314
315 fn env_with(files: &[(&str, &str)], vars: &[(&str, &str)]) -> FakeEnv {
316 FakeEnv {
317 vars: vars
318 .iter()
319 .map(|(k, v)| (k.to_string(), v.to_string()))
320 .collect(),
321 files: files
322 .iter()
323 .map(|(p, c)| (PathBuf::from(p), c.to_string()))
324 .collect(),
325 warnings: RefCell::new(Vec::new()),
326 }
327 }
328
329 #[test]
330 fn missing_default_file_returns_none() {
331 let env = env_with(&[], &[]);
332 assert_eq!(read_config_file(&env).unwrap(), None);
333 }
334
335 #[test]
336 fn explicit_missing_path_is_input_error() {
337 let env = env_with(&[], &[("CARDANOWALL_CONFIG_PATH", "/nope/config.toml")]);
338 let err = read_config_file(&env).unwrap_err();
339 assert_eq!(err.code, 4);
340 }
341
342 #[test]
343 fn parses_valid_toml() {
344 let env = env_with(
345 &[(
346 "/c.toml",
347 "cardano_gateway = \"https://api.koios.rest/api/v1\"\narweave_gateway = [\"https://a.example\", \"https://b.example\"]\nconfirmation_depth_threshold = 7\n",
348 )],
349 &[("CARDANOWALL_CONFIG_PATH", "/c.toml")],
350 );
351 let cfg = read_config_file(&env).unwrap().unwrap();
352 assert_eq!(
353 cfg.cardano_gateway.unwrap().to_vec(),
354 vec!["https://api.koios.rest/api/v1"]
355 );
356 assert_eq!(
357 cfg.arweave_gateway.unwrap().to_vec(),
358 vec!["https://a.example", "https://b.example"]
359 );
360 assert_eq!(cfg.confirmation_depth_threshold, Some(7));
361 }
362
363 #[test]
364 fn malformed_toml_is_input_error() {
365 let env = env_with(
366 &[("/bad.toml", "this is = = = not valid toml")],
367 &[("CARDANOWALL_CONFIG_PATH", "/bad.toml")],
368 );
369 assert_eq!(read_config_file(&env).unwrap_err().code, 4);
370 }
371
372 #[test]
373 fn unknown_key_warns_but_parses() {
374 let env = env_with(
375 &[(
376 "/u.toml",
377 "cardano_gateway = \"https://api.koios.rest\"\nunknown_key = \"ignored\"\n",
378 )],
379 &[("CARDANOWALL_CONFIG_PATH", "/u.toml")],
380 );
381 let cfg = read_config_file(&env).unwrap().unwrap();
382 assert!(cfg.cardano_gateway.is_some());
383 assert!(env
384 .warnings
385 .borrow()
386 .iter()
387 .any(|w| w.contains("unknown_key")));
388 }
389}