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