run-stack 0.6.4

One command to boot a full local stack in Docker: API, Vite apps, Expo mobile, desktop renderer, database, mail and a status dashboard.
Documentation
//! The environment a compose command runs with.
//!
//! `.run/.env` is the file compose interpolates from; the shell sources it and
//! then derives a handful of values from the settings (database host, S3
//! endpoint, the LAN address Metro must advertise). Those derivations are here
//! because compose reads them from the process environment, not from the file.

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 {
    /// Read `.run/.env`. A missing file is not an error: the defaults baked
    /// into docker-compose.yml still apply.
    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),
        }
    }

    /// The same spelling of yes the shell accepts, for a value read from
    /// anywhere — a file, or a running container's environment.
    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()
    }

    /// The values the shell's load_env computes after sourcing the file.
    ///
    /// `root` is the workspace directory: the settings hold paths relative to
    /// it, and compose resolves a relative path against its own project
    /// directory, which is somewhere else entirely — so they are made absolute
    /// here or the bind mounts point at nothing.
    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();

        // The shell builds this as "${BACKEND_DIR%/}/${BACKEND_SUBDIR}", which
        // leaves a trailing slash when there is no subdirectory. Matched so
        // the two produce byte-identical mounts.
        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);
    }

    /// A device on the LAN cannot reach Metro on "localhost": it needs this
    /// machine's address, and the API URL baked into the app needs it too.
    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"),
            );
        }
    }
}

/// Absolute wins; anything else is relative to the workspace root.
fn resolve_dir(root: &Path, value: &str) -> String {
    if value.starts_with('/') {
        return value.to_string();
    }
    // "./x" is kept rather than tidied away: the shell joins the two verbatim,
    // and the point of this is to produce the same string it does.
    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()
}

/// The address this machine has on the LAN. No packet is sent: connecting a
/// UDP socket only picks the route, which is what names the interface.
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"));
        // Compose resolves a relative path against its own project directory,
        // which is not the workspace: these have to be absolute.
        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"));
    }
}