foukoapi 0.1.0-alpha.1

Cross-platform bot framework in Rust. Write your handlers once, run the same bot on Telegram and Discord with shared accounts, embeds, keyboards and SQLite storage.
Documentation
//! Pluggable key-value storage.
//!
//! A [`Storage`] is any async key-value store. FoukoApi ships two
//! implementations out of the box:
//!
//! - [`MemoryStorage`] - non-persistent, perfect for tests and examples.
//! - [`SqliteStorage`] (feature `sqlite`, on by default) - a tiny bundled
//!   SQLite file, auto-created if missing. Great default for small bots.
//!
//! External backends (Postgres, Redis, etc.) are out of scope for the core
//! crate; implement [`Storage`] yourself and pass it to
//! [`crate::Accounts::new`] / your handlers.

use crate::{config::DbUrl, Error, Result};
use async_trait::async_trait;
use std::{
    collections::HashMap,
    sync::{Arc, Mutex},
};

/// Key-value storage used by FoukoApi (account linking, per-user state, ...).
#[async_trait]
pub trait Storage: Send + Sync + 'static {
    /// Fetch the value stored under `key`, or `None` if absent.
    async fn get(&self, key: &str) -> Result<Option<String>>;
    /// Write `value` under `key`, overwriting any previous value.
    async fn set(&self, key: &str, value: &str) -> Result<()>;
    /// Remove a key. It is not an error if the key didn't exist.
    async fn del(&self, key: &str) -> Result<()>;
}

/// Ready-to-use storage handle, erased behind an `Arc`.
///
/// Returned by [`open_storage`] so a bot's `main.rs` can stay short.
pub type AnyStorage = Arc<dyn Storage>;

/// Open whatever storage `FOUKO_DB` points at.
///
/// - `sqlite:/path.db` (or empty/`memory:`): local file created if missing /
///   in-memory.
/// - `postgres://...` and other URL schemes are returned as
///   [`Error::Other`] for now - implement your own [`Storage`] impl.
pub fn open_storage() -> Result<AnyStorage> {
    open_storage_from(DbUrl::from_env()?)
}

/// Like [`open_storage`] but you get to pass the URL directly.
pub fn open_storage_from(url: DbUrl) -> Result<AnyStorage> {
    match url {
        DbUrl::Memory => Ok(Arc::new(MemoryStorage::new())),
        #[cfg(feature = "sqlite")]
        DbUrl::Sqlite(path) => Ok(Arc::new(SqliteStorage::open(&path)?)),
        #[cfg(not(feature = "sqlite"))]
        DbUrl::Sqlite(_) => Err(Error::Other(
            "sqlite backend is not enabled - build with the `sqlite` feature".into(),
        )),
        DbUrl::External(url) => Err(Error::Other(format!(
            "FoukoApi does not bundle a driver for {url}. Implement Storage yourself and plug it in."
        ))),
    }
}

// ---------- Memory backend ---------------------------------------------------

/// In-memory, non-persistent storage. Lost on restart.
///
/// Useful in tests, examples, and for bots that genuinely don't need state.
#[derive(Debug, Clone, Default)]
pub struct MemoryStorage {
    inner: Arc<Mutex<HashMap<String, String>>>,
}

impl MemoryStorage {
    /// New empty store.
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl Storage for MemoryStorage {
    async fn get(&self, key: &str) -> Result<Option<String>> {
        Ok(self.inner.lock().unwrap().get(key).cloned())
    }
    async fn set(&self, key: &str, value: &str) -> Result<()> {
        self.inner
            .lock()
            .unwrap()
            .insert(key.to_owned(), value.to_owned());
        Ok(())
    }
    async fn del(&self, key: &str) -> Result<()> {
        self.inner.lock().unwrap().remove(key);
        Ok(())
    }
}

// ---------- SQLite backend ---------------------------------------------------

#[cfg(feature = "sqlite")]
#[cfg_attr(docsrs, doc(cfg(feature = "sqlite")))]
mod sqlite_impl {
    use super::*;
    use rusqlite::{params, Connection};
    use std::path::Path;
    use std::sync::Mutex as StdMutex;

    /// Persistent SQLite-backed storage.
    ///
    /// `SqliteStorage::open(path)` auto-creates the file and table if they
    /// don't exist, so a first-run bot just works.
    pub struct SqliteStorage {
        conn: StdMutex<Connection>,
    }

    impl SqliteStorage {
        /// Open (or create) a SQLite file at `path`.
        pub fn open(path: &Path) -> Result<Self> {
            if let Some(parent) = path.parent() {
                if !parent.as_os_str().is_empty() {
                    std::fs::create_dir_all(parent).map_err(|e| {
                        Error::Other(format!("creating {} dir: {e}", parent.display()))
                    })?;
                }
            }
            let conn = Connection::open(path)
                .map_err(|e| Error::Other(format!("opening sqlite {}: {e}", path.display())))?;
            conn.execute_batch(
                "CREATE TABLE IF NOT EXISTS foukoapi_kv (
                    k TEXT PRIMARY KEY,
                    v TEXT NOT NULL
                 );",
            )
            .map_err(|e| Error::Other(format!("creating kv table: {e}")))?;
            Ok(Self {
                conn: StdMutex::new(conn),
            })
        }
    }

    #[async_trait]
    impl Storage for SqliteStorage {
        async fn get(&self, key: &str) -> Result<Option<String>> {
            let conn = self
                .conn
                .lock()
                .map_err(|_| Error::Other("sqlite mutex poisoned".into()))?;
            Ok(conn
                .query_row(
                    "SELECT v FROM foukoapi_kv WHERE k = ?1",
                    params![key],
                    |row| row.get::<_, String>(0),
                )
                .ok())
        }
        async fn set(&self, key: &str, value: &str) -> Result<()> {
            let conn = self
                .conn
                .lock()
                .map_err(|_| Error::Other("sqlite mutex poisoned".into()))?;
            conn.execute(
                "INSERT INTO foukoapi_kv (k, v) VALUES (?1, ?2)
                 ON CONFLICT(k) DO UPDATE SET v = excluded.v",
                params![key, value],
            )
            .map_err(|e| Error::Other(format!("sqlite set: {e}")))?;
            Ok(())
        }
        async fn del(&self, key: &str) -> Result<()> {
            let conn = self
                .conn
                .lock()
                .map_err(|_| Error::Other("sqlite mutex poisoned".into()))?;
            conn.execute("DELETE FROM foukoapi_kv WHERE k = ?1", params![key])
                .map_err(|e| Error::Other(format!("sqlite del: {e}")))?;
            Ok(())
        }
    }
}

#[cfg(feature = "sqlite")]
pub use sqlite_impl::SqliteStorage;