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