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
//! Small config helpers.
//!
//! Most bots look the same on startup: load `.env`, pick up tokens from
//! environment variables, pick a database. This module wraps those steps so
//! a bot's `main.rs` can stay two-liner short.
//!
//! The opinionated bit: if the current directory has no `.env` file at all,
//! `Config::bootstrap_env` writes a commented template and returns so the
//! operator has something to fill in. Nothing reads secrets in the process.

use crate::{Error, Result};
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
};

/// Name of the file we create on first run.
pub const ENV_FILE: &str = ".env";

/// Default template written when there is no `.env` yet.
pub const DEFAULT_ENV_TEMPLATE: &str = "\
# FoukoApi configuration. Fill in whatever you need and remove the rest.

# --- Platforms --------------------------------------------------------------
# Telegram bot token (from @BotFather). Leave empty to disable Telegram.
TG_TOKEN=

# Discord bot token. Leave empty to disable Discord.
DISCORD_TOKEN=

# --- Database ---------------------------------------------------------------
# Storage backend URL. Supported schemes:
#   sqlite:./foukobot.sqlite     local SQLite file (auto-created)
#   memory:                      in-memory, lost on restart
#   postgres://user:pass@host/db connect to an external Postgres instance
# FOUKO_DB=sqlite:./foukobot.sqlite

# --- Logging ----------------------------------------------------------------
# RUST_LOG=info,foukoapi=info
";

/// Result of bootstrapping: did we just create the `.env`, or was it there
/// already?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvState {
    /// Found an existing `.env`. Nothing written.
    AlreadyExists,
    /// No `.env` was found - we wrote the template. The operator should fill
    /// it in and restart.
    Created,
}

/// Make sure there's a `.env` next to the binary's current directory.
///
/// - If one exists, returns [`EnvState::AlreadyExists`] and does nothing.
/// - If not, writes [`DEFAULT_ENV_TEMPLATE`] to `ENV_FILE` and returns
///   [`EnvState::Created`].
///
/// Either way, the file is loaded with `dotenvy::dotenv()` after this call
/// so every platform token lookup via `std::env::var` just works.
pub fn bootstrap_env() -> Result<EnvState> {
    bootstrap_env_at(Path::new(ENV_FILE))
}

/// Same as [`bootstrap_env`] but you get to pick the path - useful for tests.
pub fn bootstrap_env_at(path: &Path) -> Result<EnvState> {
    let state = if path.exists() {
        EnvState::AlreadyExists
    } else {
        write_template(path)?;
        EnvState::Created
    };

    // Best-effort: load whatever is in the file now. Missing keys are fine.
    let _ = dotenvy::from_path(path);

    Ok(state)
}

fn write_template(path: &Path) -> Result<()> {
    let mut f = fs::File::create(path)
        .map_err(|e| Error::Other(format!("could not create {}: {e}", path.display())))?;
    f.write_all(DEFAULT_ENV_TEMPLATE.as_bytes())
        .map_err(|e| Error::Other(format!("could not write {}: {e}", path.display())))?;
    Ok(())
}

/// Supported storage URLs.
///
/// This is the parsed form of `FOUKO_DB`. Bots use
/// [`crate::Storage::from_env`] to go straight from env to a ready storage
/// handle.
#[derive(Debug, Clone)]
pub enum DbUrl {
    /// `sqlite:/path/to/file.db`. The file is created if missing.
    Sqlite(PathBuf),
    /// `memory:` - in-memory only, not persisted.
    Memory,
    /// Any other URL (e.g. `postgres://...`). Kept as-is so adapters can
    /// decide how to use it.
    External(String),
}

impl DbUrl {
    /// Parse an URL string.
    pub fn parse(url: &str) -> Result<Self> {
        let url = url.trim();
        if url.is_empty()
            || url.eq_ignore_ascii_case("memory:")
            || url.eq_ignore_ascii_case("memory")
        {
            return Ok(Self::Memory);
        }
        if let Some(rest) = url
            .strip_prefix("sqlite:")
            .or_else(|| url.strip_prefix("sqlite://"))
        {
            if rest.is_empty() {
                return Err(Error::Other("sqlite URL is missing a path".into()));
            }
            return Ok(Self::Sqlite(PathBuf::from(rest)));
        }
        Ok(Self::External(url.to_owned()))
    }

    /// Read `FOUKO_DB` from the environment.
    ///
    /// If the variable is not set (or empty), we fall back to a SQLite
    /// file placed **next to the current executable** under
    /// `foukoapi.sqlite`. The idea: a compiled bot just ships as one
    /// binary and picks up its own database file on the first run, no
    /// config needed. Tests and in-memory use cases can still force
    /// `FOUKO_DB=memory:` explicitly.
    pub fn from_env() -> Result<Self> {
        match std::env::var("FOUKO_DB") {
            Ok(v) if !v.trim().is_empty() => Self::parse(&v),
            _ => Ok(Self::Sqlite(default_sqlite_path())),
        }
    }
}

/// Where the default SQLite file lives.
///
/// Tries to put the file next to the running executable, so a built bot
/// keeps its state right beside itself (great for `./foukobot` shipped to
/// a VPS). If we can't figure that out for whatever reason, falls back
/// to the current working directory.
fn default_sqlite_path() -> PathBuf {
    const FILE_NAME: &str = "foukoapi.sqlite";
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            return dir.join(FILE_NAME);
        }
    }
    PathBuf::from(FILE_NAME)
}