use std::collections::BTreeMap;
use std::fs;
use std::net::UdpSocket;
use std::path::Path;
use anyhow::Result;
#[derive(Debug, Default, Clone)]
pub struct Env {
values: BTreeMap<String, String>,
}
impl Env {
pub fn load(path: &Path) -> Result<Self> {
let mut env = Self::default();
let Ok(text) = fs::read_to_string(path) else {
return Ok(env);
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((key, value)) = line.split_once('=') else {
continue;
};
env.values
.insert(key.trim().to_string(), unquote(value.trim()));
}
Ok(env)
}
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
pub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
match self.values.get(key) {
Some(value) if !value.is_empty() => value,
_ => fallback,
}
}
pub fn set(&mut self, key: &str, value: impl Into<String>) {
self.values.insert(key.to_string(), value.into());
}
pub fn is_true(&self, key: &str, fallback: bool) -> bool {
match self.values.get(key).map(String::as_str) {
Some("") | None => fallback,
Some(value) => Self::truthy(value),
}
}
pub fn truthy(value: &str) -> bool {
matches!(
value.trim(),
"true" | "TRUE" | "1" | "y" | "Y" | "yes" | "YES" | "on" | "ON"
)
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.values.iter()
}
pub fn derive(&mut self, root: &Path) {
self.derive_paths(root);
self.derive_database();
self.derive_storage();
self.derive_mobile_host();
}
fn derive_paths(&mut self, root: &Path) {
let backend = resolve_dir(root, self.get_or("BACKEND_DIR", "../backend"));
let frontend = resolve_dir(root, self.get_or("FRONTEND_DIR", "../frontend"));
let subdir = self.get_or("BACKEND_SUBDIR", "").to_string();
let app_dir = if subdir.is_empty() {
format!("{}/", backend.trim_end_matches('/'))
} else {
format!("{}/{}", backend.trim_end_matches('/'), subdir)
};
let env_file = match self.get("BACKEND_ENV_FILE") {
Some(path) if !path.is_empty() => resolve_dir(root, path),
_ => format!("{}/.env", app_dir.trim_end_matches('/')),
};
let project = self.get_or("COMPOSE_PROJECT_NAME", "myapp").to_string();
self.set("BACKEND_DIR", backend);
self.set("FRONTEND_DIR", frontend);
self.set("BACKEND_APP_DIR", app_dir);
self.set("BACKEND_ENV_FILE", env_file);
self.set("HOST_OPEN_LABEL", format!("local.{project}.host-open"));
self.set("PROJECT_NAME", project);
}
fn derive_database(&mut self) {
let user = self.get_or("DB_USERNAME", "myapp").to_string();
let password = self.get_or("DB_PASSWORD", "secret").to_string();
let database = self.get_or("DB_DATABASE", "myapp").to_string();
let (connection, host, port, url) = match self.get_or("DB_ENGINE", "postgres") {
"mysql" => (
"mysql",
"mysql",
"3306",
format!("mysql://{user}:{password}@mysql:3306/{database}"),
),
"none" => ("sqlite", "", "", String::new()),
_ => (
"pgsql",
"postgres",
"5432",
format!("postgresql://{user}:{password}@postgres:5432/{database}"),
),
};
self.set("DB_CONNECTION", connection);
self.set("DB_HOST", host);
self.set("DB_INTERNAL_PORT", port);
self.set("DATABASE_URL", url);
}
fn derive_storage(&mut self) {
let endpoint = if self.is_true("RUN_MINIO", false) {
"http://minio:9000"
} else {
""
};
self.set("S3_ENDPOINT", endpoint);
}
fn derive_mobile_host(&mut self) {
let host = match self.get_or("REACT_NATIVE_PACKAGER_HOSTNAME", "localhost") {
"localhost" | "127.0.0.1" | "" => lan_ip().unwrap_or_else(|| "127.0.0.1".to_string()),
other => other.to_string(),
};
self.set("REACT_NATIVE_PACKAGER_HOSTNAME", &host);
let backend_port = self.get_or("BACKEND_PORT", "8000").to_string();
let api = self.get_or("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000/api");
if api.is_empty() || api.starts_with("http://localhost:") || api.starts_with("http://127.0.0.1:")
{
self.set(
"EXPO_PUBLIC_API_BASE_URL",
format!("http://{host}:{backend_port}/api"),
);
}
}
}
fn resolve_dir(root: &Path, value: &str) -> String {
if value.starts_with('/') {
return value.to_string();
}
root.join(value).display().to_string()
}
fn unquote(value: &str) -> String {
let trimmed = value.trim();
for quote in ['"', '\''] {
if trimmed.len() >= 2 && trimmed.starts_with(quote) && trimmed.ends_with(quote) {
return trimmed[1..trimmed.len() - 1].to_string();
}
}
trimmed.to_string()
}
fn lan_ip() -> Option<String> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("1.1.1.1:80").ok()?;
Some(socket.local_addr().ok()?.ip().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn env_from(text: &str) -> Env {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
fs::write(&path, text).unwrap();
Env::load(&path).unwrap()
}
#[test]
fn makes_workspace_paths_absolute() {
let mut env = env_from("FRONTEND_DIR=./platform\nBACKEND_DIR=../api\n");
env.derive(Path::new("/w"));
assert_eq!(env.get("FRONTEND_DIR"), Some("/w/./platform"));
assert_eq!(env.get("BACKEND_DIR"), Some("/w/../api"));
assert_eq!(env.get("BACKEND_APP_DIR"), Some("/w/../api/"));
}
#[test]
fn keeps_an_absolute_path_as_it_is() {
let mut env = env_from("FRONTEND_DIR=/srv/platform\n");
env.derive(Path::new("/w"));
assert_eq!(env.get("FRONTEND_DIR"), Some("/srv/platform"));
}
#[test]
fn reads_quoted_and_exported_values() {
let env = env_from("A=1\nexport B=two\nC=\"a b\"\nD='x'\n# comment\n\nE=\n");
assert_eq!(env.get("A"), Some("1"));
assert_eq!(env.get("B"), Some("two"));
assert_eq!(env.get("C"), Some("a b"));
assert_eq!(env.get("D"), Some("x"));
assert_eq!(env.get("E"), Some(""));
}
#[test]
fn derives_postgres_by_default() {
let mut env = env_from("DB_USERNAME=app\nDB_PASSWORD=pw\nDB_DATABASE=app\n");
env.derive(Path::new("/workspace"));
assert_eq!(env.get("DB_CONNECTION"), Some("pgsql"));
assert_eq!(
env.get("DATABASE_URL"),
Some("postgresql://app:pw@postgres:5432/app")
);
}
#[test]
fn derives_mysql_and_none() {
let mut env = env_from("DB_ENGINE=mysql\n");
env.derive(Path::new("/workspace"));
assert_eq!(env.get("DB_HOST"), Some("mysql"));
let mut env = env_from("DB_ENGINE=none\n");
env.derive(Path::new("/workspace"));
assert_eq!(env.get("DB_CONNECTION"), Some("sqlite"));
assert_eq!(env.get("DATABASE_URL"), Some(""));
}
#[test]
fn points_expo_at_the_lan_address() {
let mut env = env_from("BACKEND_PORT=8072\nEXPO_PUBLIC_API_BASE_URL=http://localhost:8000/api\n");
env.derive(Path::new("/workspace"));
let host = env.get("REACT_NATIVE_PACKAGER_HOSTNAME").unwrap();
assert_ne!(host, "localhost");
assert_eq!(
env.get("EXPO_PUBLIC_API_BASE_URL").unwrap(),
format!("http://{host}:8072/api")
);
}
#[test]
fn keeps_an_explicit_api_url() {
let mut env = env_from("EXPO_PUBLIC_API_BASE_URL=https://api.example.com\n");
env.derive(Path::new("/workspace"));
assert_eq!(env.get("EXPO_PUBLIC_API_BASE_URL"), Some("https://api.example.com"));
}
}