use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::util::CliError;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum StringOrList {
One(String),
Many(Vec<String>),
}
impl StringOrList {
#[must_use]
pub fn to_vec(&self) -> Vec<String> {
match self {
StringOrList::One(s) => {
if s.is_empty() {
Vec::new()
} else {
vec![s.clone()]
}
}
StringOrList::Many(v) => v.iter().filter(|s| !s.is_empty()).cloned().collect(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct GatewayProfile {
pub base_url: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CardanowallConfig {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub cardano_gateway: Option<StringOrList>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub blockfrost_project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub arweave_gateway: Option<StringOrList>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub ipfs_gateway: Option<StringOrList>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub confirmation_depth_threshold: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub deny_host: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub default_gateway: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub gateways: BTreeMap<String, GatewayProfile>,
}
impl CardanowallConfig {
#[must_use]
pub fn active_gateway(&self) -> Option<&GatewayProfile> {
self.default_gateway
.as_deref()
.and_then(|name| self.gateways.get(name))
}
pub fn select_gateway<'a>(
&'a self,
requested: Option<&str>,
cmd: &str,
) -> Result<Option<&'a GatewayProfile>, CliError> {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(name) => self.gateways.get(name).map(Some).ok_or_else(|| {
CliError::input(format!(
"{cmd}: no gateway profile named \"{name}\" (add one with 'cardanowall gateway add')"
))
}),
None => Ok(self.active_gateway()),
}
}
}
pub trait ConfigEnv {
fn var(&self, key: &str) -> Option<String>;
fn home_dir(&self) -> Option<PathBuf>;
fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>>;
fn warn(&self, message: &str);
}
pub struct SystemConfigEnv;
impl ConfigEnv for SystemConfigEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
fn home_dir(&self) -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(None),
Err(e) => Err(Some(e)),
}
}
fn warn(&self, message: &str) {
eprintln!("{message}");
}
}
pub fn read_config_file(env: &dyn ConfigEnv) -> Result<Option<CardanowallConfig>, CliError> {
let explicit = env.var("CARDANOWALL_CONFIG_PATH").filter(|p| !p.is_empty());
let path = match &explicit {
Some(p) => PathBuf::from(p),
None => match env.home_dir() {
Some(home) => home.join(".cardanowall").join("config.toml"),
None => return Ok(None),
},
};
let raw = match env.read_to_string(&path) {
Ok(raw) => raw,
Err(None) => {
if explicit.is_some() {
return Err(CliError::input(format!(
"config: CARDANOWALL_CONFIG_PATH points at a file that does not exist: {}",
path.display()
)));
}
return Ok(None);
}
Err(Some(e)) => {
return Err(CliError::input(format!(
"config: cannot read {}: {e}",
path.display()
)));
}
};
parse_config_str(&raw, &path, env).map(Some)
}
pub fn parse_config_str(
raw: &str,
path: &std::path::Path,
env: &dyn ConfigEnv,
) -> Result<CardanowallConfig, CliError> {
if let Ok(toml::Value::Table(table)) = raw.parse::<toml::Value>() {
for key in table.keys() {
if !KNOWN_KEYS.contains(&key.as_str()) {
env.warn(&format!(
"warning: unknown key \"{key}\" in {} (ignored)",
path.display()
));
}
}
}
let filtered = filter_known_keys(raw);
toml::from_str(&filtered).map_err(|e| {
CliError::input(format!(
"config: TOML parse failed at {}: {e}",
path.display()
))
})
}
const KNOWN_KEYS: [&str; 8] = [
"cardano_gateway",
"blockfrost_project_id",
"arweave_gateway",
"ipfs_gateway",
"confirmation_depth_threshold",
"deny_host",
"default_gateway",
"gateways",
];
pub fn config_path(env: &dyn ConfigEnv) -> Result<PathBuf, CliError> {
if let Some(explicit) = env.var("CARDANOWALL_CONFIG_PATH").filter(|p| !p.is_empty()) {
return Ok(PathBuf::from(explicit));
}
match env.home_dir() {
Some(home) => Ok(home.join(".cardanowall").join("config.toml")),
None => Err(CliError::input(
"config: no home directory found and CARDANOWALL_CONFIG_PATH is unset; \
set CARDANOWALL_CONFIG_PATH to choose where config.toml lives",
)),
}
}
fn filter_known_keys(raw: &str) -> String {
let Ok(toml::Value::Table(table)) = raw.parse::<toml::Value>() else {
return raw.to_string();
};
let mut kept = toml::value::Table::new();
for (k, v) in table {
if KNOWN_KEYS.contains(&k.as_str()) {
kept.insert(k, v);
}
}
toml::to_string(&toml::Value::Table(kept)).unwrap_or_else(|_| raw.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
struct FakeEnv {
vars: HashMap<String, String>,
files: HashMap<PathBuf, String>,
warnings: RefCell<Vec<String>>,
}
impl ConfigEnv for FakeEnv {
fn var(&self, key: &str) -> Option<String> {
self.vars.get(key).cloned()
}
fn home_dir(&self) -> Option<PathBuf> {
Some(PathBuf::from("/nonexistent-home"))
}
fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>> {
self.files.get(path).cloned().ok_or(None)
}
fn warn(&self, message: &str) {
self.warnings.borrow_mut().push(message.to_string());
}
}
fn env_with(files: &[(&str, &str)], vars: &[(&str, &str)]) -> FakeEnv {
FakeEnv {
vars: vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
files: files
.iter()
.map(|(p, c)| (PathBuf::from(p), c.to_string()))
.collect(),
warnings: RefCell::new(Vec::new()),
}
}
#[test]
fn missing_default_file_returns_none() {
let env = env_with(&[], &[]);
assert_eq!(read_config_file(&env).unwrap(), None);
}
#[test]
fn explicit_missing_path_is_input_error() {
let env = env_with(&[], &[("CARDANOWALL_CONFIG_PATH", "/nope/config.toml")]);
let err = read_config_file(&env).unwrap_err();
assert_eq!(err.code, 4);
}
#[test]
fn parses_valid_toml() {
let env = env_with(
&[(
"/c.toml",
"cardano_gateway = \"https://api.koios.rest/api/v1\"\narweave_gateway = [\"https://a.example\", \"https://b.example\"]\nconfirmation_depth_threshold = 7\n",
)],
&[("CARDANOWALL_CONFIG_PATH", "/c.toml")],
);
let cfg = read_config_file(&env).unwrap().unwrap();
assert_eq!(
cfg.cardano_gateway.unwrap().to_vec(),
vec!["https://api.koios.rest/api/v1"]
);
assert_eq!(
cfg.arweave_gateway.unwrap().to_vec(),
vec!["https://a.example", "https://b.example"]
);
assert_eq!(cfg.confirmation_depth_threshold, Some(7));
}
#[test]
fn malformed_toml_is_input_error() {
let env = env_with(
&[("/bad.toml", "this is = = = not valid toml")],
&[("CARDANOWALL_CONFIG_PATH", "/bad.toml")],
);
assert_eq!(read_config_file(&env).unwrap_err().code, 4);
}
#[test]
fn unknown_key_warns_but_parses() {
let env = env_with(
&[(
"/u.toml",
"cardano_gateway = \"https://api.koios.rest\"\nunknown_key = \"ignored\"\n",
)],
&[("CARDANOWALL_CONFIG_PATH", "/u.toml")],
);
let cfg = read_config_file(&env).unwrap().unwrap();
assert!(cfg.cardano_gateway.is_some());
assert!(env
.warnings
.borrow()
.iter()
.any(|w| w.contains("unknown_key")));
}
}