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 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 fs::remove_file(&config_path).ok();
295 }
296
297 #[test]
298 #[serial]
299 fn test_indodax_config_load_no_file() {
300 let config_path = IndodaxConfig::config_path();
302 if config_path.exists() {
303 fs::remove_file(&config_path).ok();
304 }
305
306 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 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 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 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}