rust-okx 0.6.3

Async Rust client for the OKX v5 REST API
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OkxProfile {
    pub api_key: String,
    pub secret_key: String,
    pub passphrase: String,
    pub demo: bool,
    pub base_url: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub watchlist: Vec<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct OkxConfig {
    pub default_profile: String,
    pub profiles: HashMap<String, OkxProfile>,
}

impl OkxConfig {
    pub fn config_path() -> PathBuf {
        okx_dir().join("config.toml")
    }

    pub fn load() -> Result<Option<Self>> {
        let path = Self::config_path();
        if !path.exists() {
            return Ok(None);
        }
        let text =
            fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
        let config: Self =
            toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
        Ok(Some(config))
    }

    pub fn save(&self) -> Result<()> {
        let path = Self::config_path();
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
        }
        let text = toml::to_string_pretty(self)?;
        fs::write(&path, text)?;
        Ok(())
    }

    pub fn profile_watchlist_or_default(
        &self,
        profile_name: &str,
        default_watchlist: &[&str],
    ) -> Vec<String> {
        self.profiles
            .get(profile_name)
            .map(|profile| normalize_watchlist(&profile.watchlist, default_watchlist))
            .unwrap_or_else(|| {
                default_watchlist
                    .iter()
                    .map(|id| (*id).to_owned())
                    .collect()
            })
    }

    pub fn set_profile_watchlist(
        &mut self,
        profile_name: &str,
        watchlist: &[String],
    ) -> Result<()> {
        let Some(profile) = self.profiles.get_mut(profile_name) else {
            anyhow::bail!("profile `{profile_name}` does not exist");
        };
        profile.watchlist = normalize_watchlist(watchlist, &[]);
        Ok(())
    }

    pub fn save_profile_watchlist(profile_name: &str, watchlist: &[String]) -> Result<()> {
        let mut config = Self::load()?.context("missing ~/.okx/config.toml")?;
        config.set_profile_watchlist(profile_name, watchlist)?;
        config.save()
    }
}

fn okx_dir() -> PathBuf {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| ".".to_owned());
    PathBuf::from(home).join(".okx")
}

fn normalize_watchlist(values: &[String], default_watchlist: &[&str]) -> Vec<String> {
    let source: Vec<String> = if values.is_empty() {
        default_watchlist
            .iter()
            .map(|id| (*id).to_owned())
            .collect()
    } else {
        values.to_vec()
    };
    let mut normalized = Vec::new();
    for inst_id in source {
        let inst_id = inst_id.trim().to_ascii_uppercase();
        if inst_id.is_empty() || normalized.iter().any(|existing| existing == &inst_id) {
            continue;
        }
        normalized.push(inst_id);
    }
    normalized
}

#[cfg(test)]
mod tests {
    use super::*;

    fn profile() -> OkxProfile {
        OkxProfile {
            api_key: "key".to_owned(),
            secret_key: "secret".to_owned(),
            passphrase: "pass".to_owned(),
            demo: true,
            base_url: None,
            watchlist: Vec::new(),
        }
    }

    #[test]
    fn old_profile_without_watchlist_deserializes() {
        let config: OkxConfig = toml::from_str(
            r#"
default_profile = "live"

[profiles.live]
api_key = "key"
secret_key = "secret"
passphrase = "pass"
demo = true
"#,
        )
        .unwrap();

        assert!(config.profiles["live"].watchlist.is_empty());
    }

    #[test]
    fn empty_profile_watchlist_uses_default() {
        let mut profiles = HashMap::new();
        profiles.insert("live".to_owned(), profile());
        let config = OkxConfig {
            default_profile: "live".to_owned(),
            profiles,
        };

        assert_eq!(
            config.profile_watchlist_or_default("live", &["BTC-USDT", "ETH-USDT"]),
            vec!["BTC-USDT".to_owned(), "ETH-USDT".to_owned()]
        );
    }

    #[test]
    fn profile_watchlist_roundtrips_normalized() {
        let mut profiles = HashMap::new();
        profiles.insert("live".to_owned(), profile());
        let mut config = OkxConfig {
            default_profile: "live".to_owned(),
            profiles,
        };

        config
            .set_profile_watchlist(
                "live",
                &[
                    "eth-usdt".to_owned(),
                    "ETH-USDT".to_owned(),
                    " sol-usdt ".to_owned(),
                ],
            )
            .unwrap();

        let text = toml::to_string_pretty(&config).unwrap();
        let parsed: OkxConfig = toml::from_str(&text).unwrap();
        assert_eq!(
            parsed.profile_watchlist_or_default("live", &["BTC-USDT"]),
            vec!["ETH-USDT".to_owned(), "SOL-USDT".to_owned()]
        );
    }
}