goldenpay 0.3.0

Production-oriented Rust SDK for FunPay automation
Documentation
use crate::error::GoldenPayError;
use crate::models::BotState;
use async_trait::async_trait;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tokio::sync::Mutex;

#[async_trait]
pub trait StateStore: Send + Sync {
    async fn load(&self) -> Result<BotState, GoldenPayError>;
    async fn save(&self, state: &BotState) -> Result<(), GoldenPayError>;
}

#[derive(Default)]
pub struct MemoryStateStore {
    state: Arc<Mutex<BotState>>,
}

impl MemoryStateStore {
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl StateStore for MemoryStateStore {
    async fn load(&self) -> Result<BotState, GoldenPayError> {
        Ok(self.state.lock().await.clone())
    }

    async fn save(&self, state: &BotState) -> Result<(), GoldenPayError> {
        *self.state.lock().await = state.clone();
        Ok(())
    }
}

pub struct JsonStateStore {
    path: PathBuf,
    lock: Arc<Mutex<()>>,
}

impl JsonStateStore {
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Self {
            path: path.into(),
            lock: Arc::new(Mutex::new(())),
        }
    }
}

#[async_trait]
impl StateStore for JsonStateStore {
    async fn load(&self) -> Result<BotState, GoldenPayError> {
        let _guard = self.lock.lock().await;
        if !self.path.exists() {
            return Ok(BotState::default());
        }

        let raw = fs::read_to_string(&self.path).await?;
        Ok(serde_json::from_str(&raw)?)
    }

    async fn save(&self, state: &BotState) -> Result<(), GoldenPayError> {
        let _guard = self.lock.lock().await;
        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent).await?;
        }

        let raw = serde_json::to_string_pretty(state)?;
        write_atomic_json(&self.path, &raw).await?;
        Ok(())
    }
}

async fn write_atomic_json(path: &std::path::Path, raw: &str) -> Result<(), GoldenPayError> {
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| GoldenPayError::state(format!("invalid file name for {}", path.display())))?;
    let tmp_path = path.with_file_name(format!("{file_name}.tmp"));

    fs::write(&tmp_path, raw).await?;
    fs::rename(&tmp_path, path).await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_path(name: &str) -> PathBuf {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("goldenpay-{name}-{stamp}.json"))
    }

    #[tokio::test]
    async fn json_store_roundtrip() {
        let path = temp_path("state");
        let store = JsonStateStore::new(&path);

        let mut state = BotState::default();
        state.seen_orders.push("ORDER123".to_string());
        state.seen_messages.insert("users-1-2".to_string(), 42);

        store.save(&state).await.unwrap();
        let loaded = store.load().await.unwrap();

        assert_eq!(loaded.seen_orders, vec!["ORDER123".to_string()]);
        assert_eq!(loaded.seen_messages.get("users-1-2"), Some(&42));

        let _ = fs::remove_file(path).await;
    }
}