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