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 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 fs::remove_file(&config_path).ok();
299 }
300
301 #[test]
302 #[serial]
303 fn test_indodax_config_load_no_file() {
304 let config_path = IndodaxConfig::config_path();
306 if config_path.exists() {
307 fs::remove_file(&config_path).ok();
308 }
309
310 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 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 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 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}