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