Skip to main content

ows_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5/// Backup configuration.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct BackupConfig {
8    pub path: PathBuf,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub auto_backup: Option<bool>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub max_backups: Option<u32>,
13}
14
15/// Application configuration.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Config {
18    pub vault_path: PathBuf,
19    #[serde(default)]
20    pub rpc: HashMap<String, String>,
21    #[serde(default)]
22    pub plugins: HashMap<String, serde_json::Value>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub backup: Option<BackupConfig>,
25}
26
27impl Config {
28    /// Returns the built-in default RPC endpoints for well-known chains.
29    pub fn default_rpc() -> HashMap<String, String> {
30        let mut rpc = HashMap::new();
31        rpc.insert("eip155:1".into(), "https://eth.llamarpc.com".into());
32        rpc.insert("eip155:137".into(), "https://polygon-rpc.com".into());
33        rpc.insert("eip155:42161".into(), "https://arb1.arbitrum.io/rpc".into());
34        rpc.insert("eip155:10".into(), "https://mainnet.optimism.io".into());
35        rpc.insert("eip155:8453".into(), "https://mainnet.base.org".into());
36        rpc.insert(
37            "eip155:56".into(),
38            "https://bsc-dataseed.binance.org".into(),
39        );
40        rpc.insert(
41            "eip155:43114".into(),
42            "https://api.avax.network/ext/bc/C/rpc".into(),
43        );
44        rpc.insert(
45            "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into(),
46            "https://api.mainnet-beta.solana.com".into(),
47        );
48        rpc.insert(
49            "bip122:000000000019d6689c085ae165831e93".into(),
50            "https://mempool.space/api".into(),
51        );
52        rpc.insert(
53            "cosmos:cosmoshub-4".into(),
54            "https://cosmos-rest.publicnode.com".into(),
55        );
56        rpc.insert("tron:mainnet".into(), "https://api.trongrid.io".into());
57        rpc.insert("ton:mainnet".into(), "https://toncenter.com/api/v2".into());
58        rpc.insert(
59            "fil:mainnet".into(),
60            "https://api.node.glif.io/rpc/v1".into(),
61        );
62        rpc.insert(
63            "sui:mainnet".into(),
64            "https://fullnode.mainnet.sui.io:443".into(),
65        );
66        rpc
67    }
68}
69
70impl Default for Config {
71    fn default() -> Self {
72        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
73        Config {
74            vault_path: PathBuf::from(home).join(".ows"),
75            rpc: Self::default_rpc(),
76            plugins: HashMap::new(),
77            backup: None,
78        }
79    }
80}
81
82impl Config {
83    /// Look up an RPC URL by chain identifier.
84    pub fn rpc_url(&self, chain: &str) -> Option<&str> {
85        self.rpc.get(chain).map(|s| s.as_str())
86    }
87
88    /// Load config from a file path, or return defaults if file doesn't exist.
89    pub fn load(path: &std::path::Path) -> Result<Self, crate::error::OwsError> {
90        if !path.exists() {
91            return Ok(Config::default());
92        }
93        let contents =
94            std::fs::read_to_string(path).map_err(|e| crate::error::OwsError::InvalidInput {
95                message: format!("failed to read config: {}", e),
96            })?;
97        serde_json::from_str(&contents).map_err(|e| crate::error::OwsError::InvalidInput {
98            message: format!("failed to parse config: {}", e),
99        })
100    }
101
102    /// Load `~/.ows/config.json`, merging user overrides on top of defaults.
103    /// If the file doesn't exist, returns the built-in defaults.
104    pub fn load_or_default() -> Self {
105        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
106        let config_path = PathBuf::from(home).join(".ows/config.json");
107        Self::load_or_default_from(&config_path)
108    }
109
110    /// Load config from a specific path, merging user overrides on top of defaults.
111    pub fn load_or_default_from(path: &std::path::Path) -> Self {
112        let mut config = Config::default();
113        if path.exists() {
114            if let Ok(contents) = std::fs::read_to_string(path) {
115                if let Ok(user_config) = serde_json::from_str::<Config>(&contents) {
116                    // User overrides take priority
117                    for (k, v) in user_config.rpc {
118                        config.rpc.insert(k, v);
119                    }
120                    config.plugins = user_config.plugins;
121                    config.backup = user_config.backup;
122                    if user_config.vault_path.as_path() != std::path::Path::new("/tmp/.ows")
123                        && user_config.vault_path.to_string_lossy() != ""
124                    {
125                        config.vault_path = user_config.vault_path;
126                    }
127                }
128            }
129        }
130        config
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_default_vault_path() {
140        let config = Config::default();
141        let path_str = config.vault_path.to_string_lossy();
142        assert!(path_str.ends_with(".ows"));
143    }
144
145    #[test]
146    fn test_serde_roundtrip() {
147        let mut rpc = HashMap::new();
148        rpc.insert(
149            "eip155:1".to_string(),
150            "https://eth.rpc.example".to_string(),
151        );
152
153        let config = Config {
154            vault_path: PathBuf::from("/home/test/.ows"),
155            rpc,
156            plugins: HashMap::new(),
157            backup: None,
158        };
159        let json = serde_json::to_string(&config).unwrap();
160        let config2: Config = serde_json::from_str(&json).unwrap();
161        assert_eq!(config.vault_path, config2.vault_path);
162        assert_eq!(config.rpc, config2.rpc);
163    }
164
165    #[test]
166    fn test_rpc_lookup_hit() {
167        let mut config = Config::default();
168        config.rpc.insert(
169            "eip155:1".to_string(),
170            "https://eth.rpc.example".to_string(),
171        );
172        assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.rpc.example"));
173    }
174
175    #[test]
176    fn test_default_rpc_endpoints() {
177        let config = Config::default();
178        assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
179        assert_eq!(
180            config.rpc_url("eip155:137"),
181            Some("https://polygon-rpc.com")
182        );
183        assert_eq!(
184            config.rpc_url("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"),
185            Some("https://api.mainnet-beta.solana.com")
186        );
187        assert_eq!(
188            config.rpc_url("bip122:000000000019d6689c085ae165831e93"),
189            Some("https://mempool.space/api")
190        );
191        assert_eq!(
192            config.rpc_url("cosmos:cosmoshub-4"),
193            Some("https://cosmos-rest.publicnode.com")
194        );
195        assert_eq!(
196            config.rpc_url("tron:mainnet"),
197            Some("https://api.trongrid.io")
198        );
199        assert_eq!(
200            config.rpc_url("ton:mainnet"),
201            Some("https://toncenter.com/api/v2")
202        );
203    }
204
205    #[test]
206    fn test_rpc_lookup_miss() {
207        let config = Config::default();
208        assert_eq!(config.rpc_url("eip155:999"), None);
209    }
210
211    #[test]
212    fn test_optional_backup() {
213        let config = Config::default();
214        let json = serde_json::to_value(&config).unwrap();
215        assert!(json.get("backup").is_none());
216    }
217
218    #[test]
219    fn test_backup_config_serde() {
220        let config = Config {
221            vault_path: PathBuf::from("/tmp/.ows"),
222            rpc: HashMap::new(),
223            plugins: HashMap::new(),
224            backup: Some(BackupConfig {
225                path: PathBuf::from("/tmp/backup"),
226                auto_backup: Some(true),
227                max_backups: Some(5),
228            }),
229        };
230        let json = serde_json::to_value(&config).unwrap();
231        assert!(json.get("backup").is_some());
232        assert_eq!(json["backup"]["auto_backup"], true);
233    }
234
235    #[test]
236    fn test_load_nonexistent_returns_default() {
237        let config = Config::load(std::path::Path::new("/nonexistent/path/config.json")).unwrap();
238        assert!(config.vault_path.to_string_lossy().ends_with(".ows"));
239    }
240
241    #[test]
242    fn test_load_or_default_nonexistent() {
243        let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json"));
244        // Should have all default RPCs
245        assert_eq!(config.rpc.len(), 14);
246        assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
247    }
248
249    #[test]
250    fn test_load_or_default_merges_overrides() {
251        let dir = tempfile::tempdir().unwrap();
252        let config_path = dir.path().join("config.json");
253        let user_config = serde_json::json!({
254            "vault_path": "/tmp/custom-vault",
255            "rpc": {
256                "eip155:1": "https://custom-eth.rpc",
257                "eip155:11155111": "https://sepolia.rpc"
258            }
259        });
260        std::fs::write(&config_path, serde_json::to_string(&user_config).unwrap()).unwrap();
261
262        let config = Config::load_or_default_from(&config_path);
263        // User override replaces default
264        assert_eq!(config.rpc_url("eip155:1"), Some("https://custom-eth.rpc"));
265        // User-added chain
266        assert_eq!(
267            config.rpc_url("eip155:11155111"),
268            Some("https://sepolia.rpc")
269        );
270        // Defaults preserved
271        assert_eq!(
272            config.rpc_url("eip155:137"),
273            Some("https://polygon-rpc.com")
274        );
275        // Custom vault path
276        assert_eq!(config.vault_path, PathBuf::from("/tmp/custom-vault"));
277    }
278}