use crate::{Error, Result};
use std::{
fs,
io::Write,
path::{Path, PathBuf},
sync::OnceLock,
};
pub const ENV_FILE: &str = ".env";
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
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvState {
AlreadyExists,
Created,
}
static ENV_PATH: OnceLock<PathBuf> = OnceLock::new();
pub fn env_file_path() -> Option<PathBuf> {
ENV_PATH.get().cloned()
}
fn remember_env_path(path: &Path) {
let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let _ = ENV_PATH.set(abs);
}
static ENV_ERROR: OnceLock<String> = OnceLock::new();
pub fn env_file_error() -> Option<String> {
ENV_ERROR.get().cloned()
}
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);
}
}
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))
}
pub fn bootstrap_env_at(path: &Path) -> Result<EnvState> {
let state = if path.exists() {
EnvState::AlreadyExists
} else {
write_template(path)?;
EnvState::Created
};
if let Err(e) = dotenvy::from_path(path) {
if !matches!(e, dotenvy::Error::Io(_)) {
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(())
}
#[derive(Debug, Clone)]
pub enum DbUrl {
Sqlite(PathBuf),
Memory,
External(String),
}
impl DbUrl {
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()))
}
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())),
}
}
}
const DEFAULT_DB_FILE: &str = "foukoapi.sqlite";
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)
}
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)
}
};
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);
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");
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() {
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() {
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);
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);
}
}