foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
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 no `.env` file is found (working directory first,
//! then next to the binary), [`bootstrap_env`] writes a commented template
//! into the working directory 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},
    sync::OnceLock,
};

/// 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.
#
# The bot looks for this file in the working directory first, then next to
# the binary. First match wins and is the one that gets loaded.
#
# Quoting: values with spaces need double quotes (KEY=\"two words\"),
# values with $ or \\ need single quotes (KEY='pa$$word'). A line that
# breaks these rules stops the rest of the file from loading.

# --- 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
# When unset, a foukoapi.sqlite is created next to this .env file.
# 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?
///
/// The path of the file that was found/created is available through
/// [`env_file_path`] after [`bootstrap_env`] returns.
#[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,
}

/// Remembered location of the `.env` we loaded. Set once per process by
/// [`bootstrap_env`] / [`bootstrap_env_at`]; the first call wins.
static ENV_PATH: OnceLock<PathBuf> = OnceLock::new();

/// Path of the `.env` file found or created by [`bootstrap_env`].
///
/// `None` if bootstrap has not run yet. The path is absolute when we could
/// canonicalize it, so `parent()` points at a real directory.
pub fn env_file_path() -> Option<PathBuf> {
    ENV_PATH.get().cloned()
}

fn remember_env_path(path: &Path) {
    // Absolute paths keep parent() meaningful for the DB default below.
    let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    let _ = ENV_PATH.set(abs);
}

/// A parse problem hit while loading the `.env`, kept so bots can repeat
/// the warning once their logging is up. `bootstrap_env` runs before any
/// logger exists; the eprintln it does may scroll away or land nowhere
/// under a service manager, so late re-emission is the reliable channel.
static ENV_ERROR: OnceLock<String> = OnceLock::new();

/// The `.env` parse error caught by [`bootstrap_env`], if there was one.
/// Log it after your logging is initialized.
pub fn env_file_error() -> Option<String> {
    ENV_ERROR.get().cloned()
}

/// Make sure a `.env` exists and is loaded.
///
/// Search order for an existing file:
/// 1. the working directory (`./.env`),
/// 2. next to the running executable.
///
/// The first match is loaded with `dotenvy::from_path` and remembered (see
/// [`env_file_path`]), so every platform token lookup via `std::env::var`
/// just works and edits to that exact file get picked up on restart.
///
/// - Found one: returns [`EnvState::AlreadyExists`], nothing written.
/// - Found none: writes [`DEFAULT_ENV_TEMPLATE`] to `./.env` and returns
///   [`EnvState::Created`]. The operator should fill it in and restart.
pub fn bootstrap_env() -> Result<EnvState> {
    let cwd_env = PathBuf::from(ENV_FILE);
    if cwd_env.exists() {
        return bootstrap_env_at(&cwd_env);
    }
    if let Some(exe_env) = env_next_to_exe() {
        if exe_env.exists() {
            return bootstrap_env_at(&exe_env);
        }
    }
    // Nothing anywhere - drop the template into the working directory.
    bootstrap_env_at(&cwd_env)
}

fn env_next_to_exe() -> Option<PathBuf> {
    let exe = std::env::current_exe().ok()?;
    Some(exe.parent()?.join(ENV_FILE))
}

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

    // Load whatever is in the file now. A missing file is fine, but a
    // parse error is not silent: dotenvy stops at the broken line and
    // everything below it would quietly not exist (values with spaces
    // need quotes, e.g. KEY="two words"; values with `$` or `\` want
    // single quotes, double quotes expand those).
    if let Err(e) = dotenvy::from_path(path) {
        if !matches!(e, dotenvy::Error::Io(_)) {
            // Straight to stderr as well: bootstrap_env usually runs
            // before the bot wires up its logging, and a warning nobody
            // can see costs hours of blind debugging.
            eprintln!(
                "warning: bad line in {} ({e}) - variables below it were not loaded",
                path.display()
            );
            tracing::warn!(
                file = %path.display(),
                error = %e,
                "bad line in the env file - variables below it were not loaded"
            );
            let _ = ENV_ERROR.set(format!(
                "bad line in {} ({e}) - variables below it were not loaded",
                path.display()
            ));
        }
    }
    remember_env_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::open_storage`] 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 named `foukoapi.sqlite`:
    ///
    /// 1. next to the `.env` found by [`bootstrap_env`], if any;
    /// 2. otherwise the working directory;
    /// 3. never next to the binary when it sits inside `target/` - a
    ///    `cargo clean` would wipe the database.
    ///
    /// If an old install already has a database next to the binary
    /// (the pre-0.1.2 default) and the new spot is empty, the old file
    /// keeps being used so no data is lost.
    ///
    /// 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_from_env())),
        }
    }
}

/// Default SQLite file name when `FOUKO_DB` is unset.
const DEFAULT_DB_FILE: &str = "foukoapi.sqlite";

/// Resolve the default SQLite path using the real process environment.
fn default_sqlite_path_from_env() -> PathBuf {
    let exe = std::env::current_exe().ok();
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    default_sqlite_path(env_file_path().as_deref(), exe.as_deref(), &cwd)
}

/// Where the default SQLite file lives. Pure so tests can poke at it.
///
/// Preference order:
/// 1. next to the loaded `.env` - state lives with the config;
/// 2. no `.env`: the working directory. Never next to the binary when it
///    sits inside `target/` (a `cargo clean` would eat the database).
///
/// Migration: if the pre-0.1.2 default (next to the executable) already
/// holds a database and the new spot does not, the old file wins so
/// existing installs keep their data.
fn default_sqlite_path(env_file: Option<&Path>, exe: Option<&Path>, cwd: &Path) -> PathBuf {
    let exe_dir = exe.and_then(Path::parent);
    let in_target = exe_dir
        .map(|d| d.components().any(|c| c.as_os_str() == "target"))
        .unwrap_or(false);

    let new_path = match env_file.and_then(Path::parent) {
        Some(dir) if !dir.as_os_str().is_empty() => dir.join(DEFAULT_DB_FILE),
        _ => {
            if in_target {
                tracing::warn!(
                    "no .env found and the binary lives in target/ - database would land in target/, using {} instead",
                    cwd.join(DEFAULT_DB_FILE).display()
                );
            }
            cwd.join(DEFAULT_DB_FILE)
        }
    };

    // Old default was next to the binary. If data already lives there and
    // the new spot is empty, keep using the old file rather than silently
    // starting fresh.
    if let Some(dir) = exe_dir {
        let legacy = dir.join(DEFAULT_DB_FILE);
        if legacy != new_path && legacy.exists() && !new_path.exists() {
            tracing::warn!(
                "using legacy database at {} - consider moving it to {}",
                legacy.display(),
                new_path.display()
            );
            return legacy;
        }
    }

    new_path
}

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

    fn tmp_dir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("foukoapi-cfg-{tag}-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn bootstrap_env_at_remembers_path() {
        let dir = tmp_dir("envpath");
        let path = dir.join(".env");
        let state = bootstrap_env_at(&path).unwrap();
        assert_eq!(state, EnvState::Created);
        // OnceLock: first bootstrap in the process wins, so just check
        // that something got remembered and it ends with .env.
        let remembered = env_file_path().expect("path should be remembered");
        assert!(remembered.ends_with(".env"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn bad_env_line_is_reported() {
        let dir = tmp_dir("envbad");
        let path = dir.join(".env");
        // Unquoted value with spaces: dotenvy refuses the line and stops.
        std::fs::write(&path, "GOOD=1\nBROKEN=two words\nBELOW=lost\n").unwrap();
        let _ = bootstrap_env_at(&path);
        let err = env_file_error().expect("parse error should be kept");
        assert!(err.contains("variables below it were not loaded"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn default_db_next_to_env_file() {
        let got = default_sqlite_path(
            Some(Path::new("/srv/bot/.env")),
            Some(Path::new("/srv/bot/bin/bot")),
            Path::new("/somewhere/else"),
        );
        assert_eq!(got, Path::new("/srv/bot").join(DEFAULT_DB_FILE));
    }

    #[test]
    fn default_db_falls_back_to_cwd_without_env() {
        let got = default_sqlite_path(
            None,
            Some(Path::new("/usr/local/bin/bot")),
            Path::new("/srv/bot"),
        );
        assert_eq!(got, Path::new("/srv/bot").join(DEFAULT_DB_FILE));
    }

    #[test]
    fn default_db_avoids_target_dir() {
        // Binary sits in target/release - the db must land in cwd, not there.
        let got = default_sqlite_path(
            None,
            Some(Path::new("/proj/target/release/bot")),
            Path::new("/proj"),
        );
        assert_eq!(got, Path::new("/proj").join(DEFAULT_DB_FILE));
    }

    #[test]
    fn default_db_keeps_legacy_file_next_to_exe() {
        // Simulate a pre-0.1.2 install: db already next to the binary.
        let exe_dir = tmp_dir("legacy");
        let legacy = exe_dir.join(DEFAULT_DB_FILE);
        std::fs::write(&legacy, b"old data").unwrap();
        let cwd = tmp_dir("legacy-cwd");
        let exe = exe_dir.join("bot");

        let got = default_sqlite_path(None, Some(&exe), &cwd);
        assert_eq!(got, legacy);

        // Once the new spot has a db, it wins over the legacy one.
        std::fs::write(cwd.join(DEFAULT_DB_FILE), b"new data").unwrap();
        let got = default_sqlite_path(None, Some(&exe), &cwd);
        assert_eq!(got, cwd.join(DEFAULT_DB_FILE));

        let _ = std::fs::remove_dir_all(&exe_dir);
        let _ = std::fs::remove_dir_all(&cwd);
    }
}