Skip to main content

cardanowall_cli/config/
read_config_file.rs

1//! Reads `~/.cardanowall/config.toml` (or the path overridden by
2//! `CARDANOWALL_CONFIG_PATH`).
3//!
4//! A missing default file is NOT an error — it returns `None`. An explicit
5//! `CARDANOWALL_CONFIG_PATH` that does not resolve, or a TOML parse error, is a
6//! CLI input error (exit `4`) so the user sees a clear failure. Unknown top-level
7//! keys emit a stderr warning but do not fail the read.
8
9use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use crate::util::CliError;
15
16/// One or many strings — config values that accept either a scalar or a list.
17#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
18#[serde(untagged)]
19pub enum StringOrList {
20    /// A single value.
21    One(String),
22    /// A list of values.
23    Many(Vec<String>),
24}
25
26impl StringOrList {
27    /// Flatten to a `Vec`, dropping empties — the resolver consumes a uniform list.
28    #[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/// One named service-gateway profile: a base URL plus an optional opaque API key.
44///
45/// This is NOT a login — the gateway API is key-only — so the profile just pairs
46/// an endpoint with the bearer the user forwards to it. Persisted under
47/// `[gateways.<name>]` in `config.toml`.
48#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
49pub struct GatewayProfile {
50    /// The service-gateway base URL (e.g. `https://gateway.example.com`).
51    pub base_url: String,
52    /// The opaque bearer API key forwarded to this gateway, when set.
53    #[serde(skip_serializing_if = "Option::is_none", default)]
54    pub api_key: Option<String>,
55}
56
57/// The parsed `config.toml` shape. Every field is optional; the gateway resolver
58/// applies precedence and defaults.
59#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
60#[serde(deny_unknown_fields)]
61pub struct CardanoWallConfig {
62    /// Cardano gateway URL(s) (Koios-compatible).
63    #[serde(skip_serializing_if = "Option::is_none", default)]
64    pub cardano_gateway: Option<StringOrList>,
65    /// Blockfrost project id (enables the Blockfrost fallback).
66    #[serde(skip_serializing_if = "Option::is_none", default)]
67    pub blockfrost_project_id: Option<String>,
68    /// Arweave gateway URL(s).
69    #[serde(skip_serializing_if = "Option::is_none", default)]
70    pub arweave_gateway: Option<StringOrList>,
71    /// IPFS gateway URL(s).
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub ipfs_gateway: Option<StringOrList>,
74    /// Confirmation-depth threshold.
75    #[serde(skip_serializing_if = "Option::is_none", default)]
76    pub confirmation_depth_threshold: Option<i64>,
77    /// Extra deny-host patterns.
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    pub deny_host: Option<Vec<String>>,
80    /// The active service-gateway profile name (`gateway use <name>`).
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub default_gateway: Option<String>,
83    /// The named service-gateway profiles (`[gateways.<name>]`). A `BTreeMap` so
84    /// the on-disk order is stable (deterministic round-trips).
85    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
86    pub gateways: BTreeMap<String, GatewayProfile>,
87}
88
89impl CardanoWallConfig {
90    /// The active gateway profile: the one named by `default_gateway`, if it
91    /// resolves to a defined profile.
92    #[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    /// Select the gateway profile a network command should use: the one named by
100    /// `--gateway-profile <name>` when given, else the config `default_gateway`.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`CliError`] (exit `4`) when an explicit `--gateway-profile` names
105    /// a profile that is not defined.
106    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
122/// The environment + filesystem surface the reader needs, injected for tests.
123pub trait ConfigEnv {
124    /// Read an environment variable.
125    fn var(&self, key: &str) -> Option<String>;
126    /// The user's home directory, when known.
127    fn home_dir(&self) -> Option<PathBuf>;
128    /// Read a file to a UTF-8 string; `Err(None)` means "not found".
129    fn read_to_string(&self, path: &std::path::Path) -> Result<String, Option<std::io::Error>>;
130    /// Write a diagnostic line to stderr.
131    fn warn(&self, message: &str);
132}
133
134/// The production environment: real env vars, real home dir, real filesystem,
135/// real stderr.
136pub 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        // HOME on Unix, USERPROFILE on Windows — the same locations `dirs` checks,
145        // without pulling the crate.
146        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
164/// Read the config file, applying the `CARDANOWALL_CONFIG_PATH` override.
165///
166/// # Errors
167///
168/// Returns [`CliError`] (exit `4`) when an explicit config path is set but
169/// missing, when the file cannot be read for another reason, or when the TOML
170/// fails to parse / carries a malformed value.
171pub 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
203/// Parse a config TOML string: warn on unknown keys, then strict-parse the known
204/// keys. Shared by the read path and the gateway-edit path so both treat unknown
205/// keys and malformed values identically.
206///
207/// # Errors
208///
209/// Returns [`CliError`] (exit `4`) when the TOML fails to parse or a value is the
210/// wrong type.
211pub fn parse_config_str(
212    raw: &str,
213    path: &std::path::Path,
214    env: &dyn ConfigEnv,
215) -> Result<CardanoWallConfig, CliError> {
216    // First parse permissively to surface unknown keys as warnings, then parse
217    // strictly to enforce field types. `toml::Value` never rejects unknown keys.
218    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    // Strict parse: reject malformed values, but tolerate unknown keys by
230    // stripping them first (we already warned). We re-table the permissive parse
231    // restricted to known keys.
232    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
252/// The resolved on-disk config path: `CARDANOWALL_CONFIG_PATH` when set, else
253/// `<HOME>/.cardanowall/config.toml`.
254///
255/// # Errors
256///
257/// Returns [`CliError`] (exit `4`) when no home directory is discoverable and no
258/// explicit override is set (so a writer has nowhere to create the file).
259pub 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
272/// Re-serialise the parsed TOML keeping only known keys, so the strict
273/// `deny_unknown_fields` parse never trips on a key we already warned about.
274fn filter_known_keys(raw: &str) -> String {
275    let Ok(toml::Value::Table(table)) = raw.parse::<toml::Value>() else {
276        // Let the strict parse surface the real error.
277        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}