Skip to main content

indodax_cli/
config.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct SecretValue(String);
8
9impl fmt::Debug for SecretValue {
10    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
11        if self.0.is_empty() {
12            f.debug_struct("SecretValue").field("value", &"").finish()
13        } else {
14            f.debug_struct("SecretValue")
15                .field("value", &"****")
16                .finish()
17        }
18    }
19}
20
21impl SecretValue {
22    pub fn new(s: impl Into<String>) -> Self {
23        Self(s.into())
24    }
25
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29
30    pub fn is_empty(&self) -> bool {
31        self.0.is_empty()
32    }
33}
34
35impl fmt::Display for SecretValue {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        if self.0.is_empty() {
38            write!(f, "")
39        } else {
40            write!(f, "********")
41        }
42    }
43}
44
45impl From<String> for SecretValue {
46    fn from(s: String) -> Self {
47        SecretValue(s)
48    }
49}
50
51impl From<&str> for SecretValue {
52    fn from(s: &str) -> Self {
53        SecretValue(s.to_string())
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct IndodaxConfig {
59    pub api_key: Option<SecretValue>,
60    pub api_secret: Option<SecretValue>,
61    pub ws_token: Option<SecretValue>,
62    pub callback_url: Option<String>,
63    pub paper_balances: Option<serde_json::Value>,
64}
65
66#[derive(Debug, Clone)]
67pub struct ResolvedCredentials {
68    pub api_key: SecretValue,
69    pub api_secret: SecretValue,
70}
71
72impl IndodaxConfig {
73    fn get_base_dir() -> PathBuf {
74        match dirs::config_dir() {
75            Some(dir) => dir,
76            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
77        }
78    }
79
80    pub fn config_path() -> PathBuf {
81        Self::get_base_dir().join("indodax").join("config.toml")
82    }
83
84    pub fn config_dir() -> PathBuf {
85        Self::get_base_dir().join("indodax")
86    }
87
88    pub fn paper_state_path() -> PathBuf {
89        Self::config_dir().join("paper_state.json")
90    }
91
92    pub fn load() -> Result<Self, anyhow::Error> {
93        if dirs::config_dir().is_none() {
94            eprintln!("Warning: Could not determine user config directory. Falling back to current directory.");
95        }
96        let path = Self::config_path();
97        if !path.exists() {
98            return Ok(Self::default());
99        }
100        let content = fs::read_to_string(&path)?;
101        let config: IndodaxConfig = toml::from_str(&content)?;
102        Ok(config)
103    }
104
105    pub fn save(&self) -> Result<(), anyhow::Error> {
106        let dir = Self::config_dir();
107        fs::create_dir_all(&dir)?;
108        let path = Self::config_path();
109        let content = toml::to_string_pretty(self)?;
110        #[cfg(unix)]
111        {
112            use std::os::unix::fs::OpenOptionsExt;
113            let mut file = std::fs::OpenOptions::new()
114                .write(true)
115                .create(true)
116                .truncate(true)
117                .mode(0o600)
118                .open(&path)?;
119            use std::io::Write;
120            file.write_all(content.as_bytes())?;
121        }
122        #[cfg(not(unix))]
123        {
124            fs::write(&path, content)?;
125        }
126        Ok(())
127    }
128
129    pub fn resolve_credentials(
130        &self,
131        cli_key: Option<String>,
132        cli_secret: Option<String>,
133    ) -> Result<Option<ResolvedCredentials>, anyhow::Error> {
134        let api_key = if let Some(ref key) = cli_key {
135            let trimmed = key.trim();
136            if trimmed.is_empty() {
137                None
138            } else {
139                Some(SecretValue::new(trimmed.to_string()))
140            }
141        } else {
142            std::env::var("INDODAX_API_KEY")
143                .ok()
144                .map(|k| k.trim().to_string())
145                .filter(|k| !k.is_empty())
146                .map(SecretValue::new)
147                .or_else(|| self.api_key.clone())
148        };
149
150        let api_secret = if let Some(ref secret) = cli_secret {
151            let trimmed = secret.trim();
152            if trimmed.is_empty() {
153                None
154            } else {
155                Some(SecretValue::new(trimmed.to_string()))
156            }
157        } else {
158            std::env::var("INDODAX_API_SECRET")
159                .ok()
160                .map(|s| s.trim().to_string())
161                .filter(|s| !s.is_empty())
162                .map(SecretValue::new)
163                .or_else(|| self.api_secret.clone())
164        };
165
166        match (api_key, api_secret) {
167            (Some(key), Some(secret)) => Ok(Some(ResolvedCredentials {
168                api_key: key,
169                api_secret: secret,
170            })),
171            _ => Ok(None),
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serial_test::serial;
180    use std::env;
181
182    #[test]
183    fn test_secret_value_new() {
184        let sv = SecretValue::new("test_secret");
185        assert_eq!(sv.as_str(), "test_secret");
186    }
187
188    #[test]
189    fn test_secret_value_as_str() {
190        let sv = SecretValue::new("mykey");
191        assert_eq!(sv.as_str(), "mykey");
192    }
193
194    #[test]
195    fn test_secret_value_is_empty() {
196        let sv_empty = SecretValue::new("");
197        assert!(sv_empty.is_empty());
198
199        let sv_non_empty = SecretValue::new("value");
200        assert!(!sv_non_empty.is_empty());
201    }
202
203    #[test]
204    fn test_secret_value_display_masked() {
205        let sv = SecretValue::new("secret123");
206        let display = format!("{}", sv);
207        assert_eq!(display, "********");
208    }
209
210    #[test]
211    fn test_secret_value_display_empty() {
212        let sv = SecretValue::new("");
213        let display = format!("{}", sv);
214        assert_eq!(display, "");
215    }
216
217    #[test]
218    fn test_secret_value_serialize_raw() {
219        let sv = SecretValue::new("serialize_me");
220        let serialized = serde_json::to_string(&sv).unwrap();
221        assert!(serialized.contains("serialize_me"));
222    }
223
224    #[test]
225    fn test_secret_value_serialize_empty() {
226        let sv = SecretValue::new("");
227        let serialized = serde_json::to_string(&sv).unwrap();
228        assert_eq!(serialized, "\"\"");
229    }
230
231    #[test]
232    fn test_secret_value_deserialize() {
233        // JSON string format
234        let json_str = "\"deserialize_me\"";
235        let sv: SecretValue = serde_json::from_str(json_str).unwrap();
236        assert_eq!(sv.as_str(), "deserialize_me");
237    }
238
239    #[test]
240    fn test_secret_value_from_string() {
241        let s = String::from("from_string");
242        let sv: SecretValue = s.into();
243        assert_eq!(sv.as_str(), "from_string");
244    }
245
246    #[test]
247    fn test_secret_value_from_str() {
248        let sv: SecretValue = "from_str".into();
249        assert_eq!(sv.as_str(), "from_str");
250    }
251
252    #[test]
253    fn test_secret_value_equality() {
254        let sv1 = SecretValue::new("same");
255        let sv2 = SecretValue::new("same");
256        let sv3 = SecretValue::new("different");
257        assert_eq!(sv1, sv2);
258        assert_ne!(sv1, sv3);
259    }
260
261    #[test]
262    fn test_indodax_config_default() {
263        let config = IndodaxConfig::default();
264        assert!(config.api_key.is_none());
265        assert!(config.api_secret.is_none());
266        assert!(config.callback_url.is_none());
267        assert!(config.paper_balances.is_none());
268    }
269
270    #[test]
271    #[serial]
272    fn test_indodax_config_save_and_load() {
273        let config = IndodaxConfig {
274            api_key: Some(SecretValue::new("test_key")),
275            api_secret: Some(SecretValue::new("test_secret")),
276            ws_token: None,
277            callback_url: Some("http://callback.test".into()),
278            paper_balances: None,
279        };
280
281        let config_path = IndodaxConfig::config_path();
282        config.save().unwrap();
283        assert!(config_path.exists());
284
285        let loaded = IndodaxConfig::load().unwrap();
286        assert_eq!(loaded.api_key.as_ref().unwrap().as_str(), "test_key");
287        assert_eq!(loaded.api_secret.as_ref().unwrap().as_str(), "test_secret");
288        assert_eq!(
289            loaded.callback_url.as_ref().unwrap(),
290            "http://callback.test"
291        );
292
293        // Clean up
294        fs::remove_file(&config_path).ok();
295    }
296
297    #[test]
298    #[serial]
299    fn test_indodax_config_load_no_file() {
300        // Remove any existing config file to ensure clean state
301        let config_path = IndodaxConfig::config_path();
302        if config_path.exists() {
303            fs::remove_file(&config_path).ok();
304        }
305
306        // Just test that load() works when no config file exists
307        let config = IndodaxConfig::load().unwrap();
308        assert!(config.api_key.is_none());
309        assert!(config.api_secret.is_none());
310    }
311
312    #[test]
313    #[serial]
314    fn test_indodax_config_config_path() {
315        let path = IndodaxConfig::config_path();
316        assert!(path.to_string_lossy().contains("indodax"));
317        assert!(path.to_string_lossy().contains("config.toml"));
318    }
319
320    #[test]
321    #[serial]
322    fn test_indodax_config_config_dir() {
323        let dir = IndodaxConfig::config_dir();
324        assert!(!dir.to_string_lossy().is_empty());
325    }
326
327    #[test]
328    #[serial]
329    fn test_resolve_credentials_cli_override() {
330        env::remove_var("INDODAX_API_KEY");
331        env::remove_var("INDODAX_API_SECRET");
332
333        let config = IndodaxConfig {
334            api_key: Some(SecretValue::new("config_key")),
335            api_secret: Some(SecretValue::new("config_secret")),
336            ws_token: None,
337            callback_url: None,
338            paper_balances: None,
339        };
340
341        let result = config
342            .resolve_credentials(Some("cli_key".into()), Some("cli_secret".into()))
343            .unwrap();
344
345        assert!(result.is_some());
346        let creds = result.unwrap();
347        assert_eq!(creds.api_key.as_str(), "cli_key");
348        assert_eq!(creds.api_secret.as_str(), "cli_secret");
349    }
350
351    #[test]
352    #[serial]
353    fn test_resolve_credentials_env_variable() {
354        // Clean up any existing env vars first
355        env::remove_var("INDODAX_API_KEY");
356        env::remove_var("INDODAX_API_SECRET");
357
358        env::set_var("INDODAX_API_KEY", "env_key");
359        env::set_var("INDODAX_API_SECRET", "env_secret");
360
361        let config = IndodaxConfig::default();
362
363        let result = config.resolve_credentials(None, None).unwrap();
364        assert!(result.is_some());
365        let creds = result.unwrap();
366        assert_eq!(creds.api_key.as_str(), "env_key");
367        assert_eq!(creds.api_secret.as_str(), "env_secret");
368
369        env::remove_var("INDODAX_API_KEY");
370        env::remove_var("INDODAX_API_SECRET");
371    }
372
373    #[test]
374    #[serial]
375    fn test_resolve_credentials_env_overrides_config() {
376        // Clean up any existing env vars first
377        env::remove_var("INDODAX_API_KEY");
378        env::remove_var("INDODAX_API_SECRET");
379
380        env::set_var("INDODAX_API_KEY", "env_key");
381        env::set_var("INDODAX_API_SECRET", "env_secret");
382
383        let config = IndodaxConfig {
384            api_key: Some(SecretValue::new("config_key")),
385            api_secret: Some(SecretValue::new("config_secret")),
386            ws_token: None,
387            callback_url: None,
388            paper_balances: None,
389        };
390
391        let result = config.resolve_credentials(None, None).unwrap();
392        assert!(result.is_some());
393        let creds = result.unwrap();
394        assert_eq!(creds.api_key.as_str(), "env_key");
395        assert_eq!(creds.api_secret.as_str(), "env_secret");
396
397        env::remove_var("INDODAX_API_KEY");
398        env::remove_var("INDODAX_API_SECRET");
399    }
400
401    #[test]
402    #[serial]
403    fn test_resolve_credentials_empty_cli() {
404        env::remove_var("INDODAX_API_KEY");
405        env::remove_var("INDODAX_API_SECRET");
406
407        let config = IndodaxConfig::default();
408
409        let result = config
410            .resolve_credentials(Some("".into()), Some("".into()))
411            .unwrap();
412
413        assert!(result.is_none());
414    }
415
416    #[test]
417    #[serial]
418    fn test_resolve_credentials_empty_env_var() {
419        // Clean up any existing env vars first
420        env::remove_var("INDODAX_API_KEY");
421        env::remove_var("INDODAX_API_SECRET");
422
423        env::set_var("INDODAX_API_KEY", "");
424        env::set_var("INDODAX_API_SECRET", "");
425
426        let config = IndodaxConfig::default();
427
428        let result = config.resolve_credentials(None, None).unwrap();
429        assert!(result.is_none());
430
431        env::remove_var("INDODAX_API_KEY");
432        env::remove_var("INDODAX_API_SECRET");
433    }
434
435    #[test]
436    #[serial]
437    fn test_resolve_credentials_no_credentials() {
438        env::remove_var("INDODAX_API_KEY");
439        env::remove_var("INDODAX_API_SECRET");
440
441        let config = IndodaxConfig::default();
442
443        let result = config.resolve_credentials(None, None).unwrap();
444        assert!(result.is_none());
445    }
446
447    #[test]
448    #[serial]
449    fn test_resolve_credentials_partial_none() {
450        env::remove_var("INDODAX_API_KEY");
451        env::remove_var("INDODAX_API_SECRET");
452
453        let config = IndodaxConfig {
454            api_key: Some(SecretValue::new("key_only")),
455            api_secret: None,
456            ws_token: None,
457            callback_url: None,
458            paper_balances: None,
459        };
460
461        let result = config.resolve_credentials(None, None).unwrap();
462        assert!(result.is_none());
463    }
464}