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 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 fs::remove_file(&config_path).ok();
283 }
284
285 #[test]
286 #[serial]
287 fn test_indodax_config_load_no_file() {
288 let config_path = IndodaxConfig::config_path();
290 if config_path.exists() {
291 fs::remove_file(&config_path).ok();
292 }
293
294 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 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 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 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}