use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretValue(String);
impl SecretValue {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Display for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0.is_empty() {
write!(f, "")
} else {
write!(f, "********")
}
}
}
impl From<String> for SecretValue {
fn from(s: String) -> Self {
SecretValue(s)
}
}
impl From<&str> for SecretValue {
fn from(s: &str) -> Self {
SecretValue(s.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IndodaxConfig {
pub api_key: Option<SecretValue>,
pub api_secret: Option<SecretValue>,
pub ws_token: Option<SecretValue>,
pub callback_url: Option<String>,
pub paper_balances: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct ResolvedCredentials {
pub api_key: SecretValue,
pub api_secret: SecretValue,
}
impl IndodaxConfig {
fn get_base_dir() -> PathBuf {
match dirs::config_dir() {
Some(dir) => dir,
None => {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
}
pub fn config_path() -> PathBuf {
Self::get_base_dir().join("indodax").join("config.toml")
}
pub fn config_dir() -> PathBuf {
Self::get_base_dir().join("indodax")
}
pub fn load() -> Result<Self, anyhow::Error> {
let path = Self::config_path();
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&path)?;
let config: IndodaxConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn save(&self) -> Result<(), anyhow::Error> {
let dir = Self::config_dir();
if dirs::config_dir().is_none() {
eprintln!(
"Warning: Could not determine user config directory. Falling back to current directory: {}",
dir.parent().unwrap_or(&dir).display()
);
}
fs::create_dir_all(&dir)?;
let path = Self::config_path();
let content = toml::to_string_pretty(self)?;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
use std::io::Write;
file.write_all(content.as_bytes())?;
}
#[cfg(not(unix))]
{
fs::write(&path, content)?;
}
Ok(())
}
pub fn resolve_credentials(
&self,
cli_key: Option<String>,
cli_secret: Option<String>,
) -> Result<Option<ResolvedCredentials>, anyhow::Error> {
let api_key = if let Some(ref key) = cli_key {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(SecretValue::new(trimmed.to_string()))
}
} else {
std::env::var("INDODAX_API_KEY")
.ok()
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.map(SecretValue::new)
.or_else(|| self.api_key.clone())
};
let api_secret = if let Some(ref secret) = cli_secret {
let trimmed = secret.trim();
if trimmed.is_empty() {
None
} else {
Some(SecretValue::new(trimmed.to_string()))
}
} else {
std::env::var("INDODAX_API_SECRET")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(SecretValue::new)
.or_else(|| self.api_secret.clone())
};
match (api_key, api_secret) {
(Some(key), Some(secret)) => Ok(Some(ResolvedCredentials {
api_key: key,
api_secret: secret,
})),
_ => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use serial_test::serial;
#[test]
fn test_secret_value_new() {
let sv = SecretValue::new("test_secret");
assert_eq!(sv.as_str(), "test_secret");
}
#[test]
fn test_secret_value_as_str() {
let sv = SecretValue::new("mykey");
assert_eq!(sv.as_str(), "mykey");
}
#[test]
fn test_secret_value_is_empty() {
let sv_empty = SecretValue::new("");
assert!(sv_empty.is_empty());
let sv_non_empty = SecretValue::new("value");
assert!(!sv_non_empty.is_empty());
}
#[test]
fn test_secret_value_display_masked() {
let sv = SecretValue::new("secret123");
let display = format!("{}", sv);
assert_eq!(display, "********");
}
#[test]
fn test_secret_value_display_empty() {
let sv = SecretValue::new("");
let display = format!("{}", sv);
assert_eq!(display, "");
}
#[test]
fn test_secret_value_serialize_raw() {
let sv = SecretValue::new("serialize_me");
let serialized = serde_json::to_string(&sv).unwrap();
assert!(serialized.contains("serialize_me"));
}
#[test]
fn test_secret_value_serialize_empty() {
let sv = SecretValue::new("");
let serialized = serde_json::to_string(&sv).unwrap();
assert_eq!(serialized, "\"\"");
}
#[test]
fn test_secret_value_deserialize() {
let json_str = "\"deserialize_me\"";
let sv: SecretValue = serde_json::from_str(json_str).unwrap();
assert_eq!(sv.as_str(), "deserialize_me");
}
#[test]
fn test_secret_value_from_string() {
let s = String::from("from_string");
let sv: SecretValue = s.into();
assert_eq!(sv.as_str(), "from_string");
}
#[test]
fn test_secret_value_from_str() {
let sv: SecretValue = "from_str".into();
assert_eq!(sv.as_str(), "from_str");
}
#[test]
fn test_secret_value_equality() {
let sv1 = SecretValue::new("same");
let sv2 = SecretValue::new("same");
let sv3 = SecretValue::new("different");
assert_eq!(sv1, sv2);
assert_ne!(sv1, sv3);
}
#[test]
fn test_indodax_config_default() {
let config = IndodaxConfig::default();
assert!(config.api_key.is_none());
assert!(config.api_secret.is_none());
assert!(config.callback_url.is_none());
assert!(config.paper_balances.is_none());
}
#[test]
#[serial]
fn test_indodax_config_save_and_load() {
let config = IndodaxConfig {
api_key: Some(SecretValue::new("test_key")),
api_secret: Some(SecretValue::new("test_secret")),
callback_url: Some("http://callback.test".into()),
paper_balances: None,
};
let config_path = IndodaxConfig::config_path();
config.save().unwrap();
assert!(config_path.exists());
let loaded = IndodaxConfig::load().unwrap();
assert_eq!(loaded.api_key.as_ref().unwrap().as_str(), "test_key");
assert_eq!(loaded.api_secret.as_ref().unwrap().as_str(), "test_secret");
assert_eq!(loaded.callback_url.as_ref().unwrap(), "http://callback.test");
fs::remove_file(&config_path).ok();
}
#[test]
#[serial]
fn test_indodax_config_load_no_file() {
let config_path = IndodaxConfig::config_path();
if config_path.exists() {
fs::remove_file(&config_path).ok();
}
let config = IndodaxConfig::load().unwrap();
assert!(config.api_key.is_none());
assert!(config.api_secret.is_none());
}
#[test]
#[serial]
fn test_indodax_config_config_path() {
let path = IndodaxConfig::config_path();
assert!(path.to_string_lossy().contains("indodax"));
assert!(path.to_string_lossy().contains("config.toml"));
}
#[test]
#[serial]
fn test_indodax_config_config_dir() {
let dir = IndodaxConfig::config_dir();
assert!(dir.to_string_lossy().len() > 0);
}
#[test]
#[serial]
fn test_resolve_credentials_cli_override() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
let config = IndodaxConfig {
api_key: Some(SecretValue::new("config_key")),
api_secret: Some(SecretValue::new("config_secret")),
callback_url: None,
paper_balances: None,
};
let result = config.resolve_credentials(
Some("cli_key".into()),
Some("cli_secret".into()),
).unwrap();
assert!(result.is_some());
let creds = result.unwrap();
assert_eq!(creds.api_key.as_str(), "cli_key");
assert_eq!(creds.api_secret.as_str(), "cli_secret");
}
#[test]
#[serial]
fn test_resolve_credentials_env_variable() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
env::set_var("INDODAX_API_KEY", "env_key");
env::set_var("INDODAX_API_SECRET", "env_secret");
let config = IndodaxConfig::default();
let result = config.resolve_credentials(None, None).unwrap();
assert!(result.is_some());
let creds = result.unwrap();
assert_eq!(creds.api_key.as_str(), "env_key");
assert_eq!(creds.api_secret.as_str(), "env_secret");
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
}
#[test]
#[serial]
fn test_resolve_credentials_env_overrides_config() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
env::set_var("INDODAX_API_KEY", "env_key");
env::set_var("INDODAX_API_SECRET", "env_secret");
let config = IndodaxConfig {
api_key: Some(SecretValue::new("config_key")),
api_secret: Some(SecretValue::new("config_secret")),
callback_url: None,
paper_balances: None,
};
let result = config.resolve_credentials(None, None).unwrap();
assert!(result.is_some());
let creds = result.unwrap();
assert_eq!(creds.api_key.as_str(), "env_key");
assert_eq!(creds.api_secret.as_str(), "env_secret");
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
}
#[test]
#[serial]
fn test_resolve_credentials_empty_cli() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
let config = IndodaxConfig::default();
let result = config.resolve_credentials(
Some("".into()),
Some("".into()),
).unwrap();
assert!(result.is_none());
}
#[test]
#[serial]
fn test_resolve_credentials_empty_env_var() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
env::set_var("INDODAX_API_KEY", "");
env::set_var("INDODAX_API_SECRET", "");
let config = IndodaxConfig::default();
let result = config.resolve_credentials(None, None).unwrap();
assert!(result.is_none());
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
}
#[test]
#[serial]
fn test_resolve_credentials_no_credentials() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
let config = IndodaxConfig::default();
let result = config.resolve_credentials(None, None).unwrap();
assert!(result.is_none());
}
#[test]
#[serial]
fn test_resolve_credentials_partial_none() {
env::remove_var("INDODAX_API_KEY");
env::remove_var("INDODAX_API_SECRET");
let config = IndodaxConfig {
api_key: Some(SecretValue::new("key_only")),
api_secret: None,
callback_url: None,
paper_balances: None,
};
let result = config.resolve_credentials(None, None).unwrap();
assert!(result.is_none());
}
}