Skip to main content

run_stack/
env.rs

1//! The environment a compose command runs with.
2//!
3//! `.run/.env` is the file compose interpolates from; the shell sources it and
4//! then derives a handful of values from the settings (database host, S3
5//! endpoint, the LAN address Metro must advertise). Those derivations are here
6//! because compose reads them from the process environment, not from the file.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::net::UdpSocket;
11use std::path::Path;
12
13use anyhow::Result;
14
15#[derive(Debug, Default, Clone)]
16pub struct Env {
17    values: BTreeMap<String, String>,
18}
19
20impl Env {
21    /// Read `.run/.env`. A missing file is not an error: the defaults baked
22    /// into docker-compose.yml still apply.
23    pub fn load(path: &Path) -> Result<Self> {
24        let mut env = Self::default();
25        let Ok(text) = fs::read_to_string(path) else {
26            return Ok(env);
27        };
28        for line in text.lines() {
29            let line = line.trim();
30            if line.is_empty() || line.starts_with('#') {
31                continue;
32            }
33            let line = line.strip_prefix("export ").unwrap_or(line);
34            let Some((key, value)) = line.split_once('=') else {
35                continue;
36            };
37            env.values
38                .insert(key.trim().to_string(), unquote(value.trim()));
39        }
40        Ok(env)
41    }
42
43    pub fn get(&self, key: &str) -> Option<&str> {
44        self.values.get(key).map(String::as_str)
45    }
46
47    pub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
48        match self.values.get(key) {
49            Some(value) if !value.is_empty() => value,
50            _ => fallback,
51        }
52    }
53
54    pub fn set(&mut self, key: &str, value: impl Into<String>) {
55        self.values.insert(key.to_string(), value.into());
56    }
57
58    pub fn is_true(&self, key: &str, fallback: bool) -> bool {
59        match self.values.get(key).map(String::as_str) {
60            Some("") | None => fallback,
61            Some(value) => Self::truthy(value),
62        }
63    }
64
65    /// The same spelling of yes the shell accepts, for a value read from
66    /// anywhere — a file, or a running container's environment.
67    pub fn truthy(value: &str) -> bool {
68        matches!(
69            value.trim(),
70            "true" | "TRUE" | "1" | "y" | "Y" | "yes" | "YES" | "on" | "ON"
71        )
72    }
73
74    pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
75        self.values.iter()
76    }
77
78    /// The values the shell's load_env computes after sourcing the file.
79    ///
80    /// `root` is the workspace directory: the settings hold paths relative to
81    /// it, and compose resolves a relative path against its own project
82    /// directory, which is somewhere else entirely — so they are made absolute
83    /// here or the bind mounts point at nothing.
84    pub fn derive(&mut self, root: &Path) {
85        self.derive_paths(root);
86        self.derive_database();
87        self.derive_storage();
88        self.derive_mobile_host();
89    }
90
91    fn derive_paths(&mut self, root: &Path) {
92        let backend = resolve_dir(root, self.get_or("BACKEND_DIR", "../backend"));
93        let frontend = resolve_dir(root, self.get_or("FRONTEND_DIR", "../frontend"));
94        let subdir = self.get_or("BACKEND_SUBDIR", "").to_string();
95
96        // The shell builds this as "${BACKEND_DIR%/}/${BACKEND_SUBDIR}", which
97        // leaves a trailing slash when there is no subdirectory. Matched so
98        // the two produce byte-identical mounts.
99        let app_dir = if subdir.is_empty() {
100            format!("{}/", backend.trim_end_matches('/'))
101        } else {
102            format!("{}/{}", backend.trim_end_matches('/'), subdir)
103        };
104        let env_file = match self.get("BACKEND_ENV_FILE") {
105            Some(path) if !path.is_empty() => resolve_dir(root, path),
106            _ => format!("{}/.env", app_dir.trim_end_matches('/')),
107        };
108        let project = self.get_or("COMPOSE_PROJECT_NAME", "myapp").to_string();
109
110        self.set("BACKEND_DIR", backend);
111        self.set("FRONTEND_DIR", frontend);
112        self.set("BACKEND_APP_DIR", app_dir);
113        self.set("BACKEND_ENV_FILE", env_file);
114        self.set("HOST_OPEN_LABEL", format!("local.{project}.host-open"));
115        self.set("PROJECT_NAME", project);
116    }
117
118    fn derive_database(&mut self) {
119        let user = self.get_or("DB_USERNAME", "myapp").to_string();
120        let password = self.get_or("DB_PASSWORD", "secret").to_string();
121        let database = self.get_or("DB_DATABASE", "myapp").to_string();
122        let (connection, host, port, url) = match self.get_or("DB_ENGINE", "postgres") {
123            "mysql" => (
124                "mysql",
125                "mysql",
126                "3306",
127                format!("mysql://{user}:{password}@mysql:3306/{database}"),
128            ),
129            "none" => ("sqlite", "", "", String::new()),
130            _ => (
131                "pgsql",
132                "postgres",
133                "5432",
134                format!("postgresql://{user}:{password}@postgres:5432/{database}"),
135            ),
136        };
137        self.set("DB_CONNECTION", connection);
138        self.set("DB_HOST", host);
139        self.set("DB_INTERNAL_PORT", port);
140        self.set("DATABASE_URL", url);
141    }
142
143    fn derive_storage(&mut self) {
144        let endpoint = if self.is_true("RUN_MINIO", false) {
145            "http://minio:9000"
146        } else {
147            ""
148        };
149        self.set("S3_ENDPOINT", endpoint);
150    }
151
152    /// A device on the LAN cannot reach Metro on "localhost": it needs this
153    /// machine's address, and the API URL baked into the app needs it too.
154    fn derive_mobile_host(&mut self) {
155        let host = match self.get_or("REACT_NATIVE_PACKAGER_HOSTNAME", "localhost") {
156            "localhost" | "127.0.0.1" | "" => lan_ip().unwrap_or_else(|| "127.0.0.1".to_string()),
157            other => other.to_string(),
158        };
159        self.set("REACT_NATIVE_PACKAGER_HOSTNAME", &host);
160
161        let backend_port = self.get_or("BACKEND_PORT", "8000").to_string();
162        let api = self.get_or("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000/api");
163        if api.is_empty() || api.starts_with("http://localhost:") || api.starts_with("http://127.0.0.1:")
164        {
165            self.set(
166                "EXPO_PUBLIC_API_BASE_URL",
167                format!("http://{host}:{backend_port}/api"),
168            );
169        }
170    }
171}
172
173/// Absolute wins; anything else is relative to the workspace root.
174fn resolve_dir(root: &Path, value: &str) -> String {
175    if value.starts_with('/') {
176        return value.to_string();
177    }
178    // "./x" is kept rather than tidied away: the shell joins the two verbatim,
179    // and the point of this is to produce the same string it does.
180    root.join(value).display().to_string()
181}
182
183fn unquote(value: &str) -> String {
184    let trimmed = value.trim();
185    for quote in ['"', '\''] {
186        if trimmed.len() >= 2 && trimmed.starts_with(quote) && trimmed.ends_with(quote) {
187            return trimmed[1..trimmed.len() - 1].to_string();
188        }
189    }
190    trimmed.to_string()
191}
192
193/// The address this machine has on the LAN. No packet is sent: connecting a
194/// UDP socket only picks the route, which is what names the interface.
195fn lan_ip() -> Option<String> {
196    let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
197    socket.connect("1.1.1.1:80").ok()?;
198    Some(socket.local_addr().ok()?.ip().to_string())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn env_from(text: &str) -> Env {
206        let dir = tempfile::tempdir().unwrap();
207        let path = dir.path().join(".env");
208        fs::write(&path, text).unwrap();
209        Env::load(&path).unwrap()
210    }
211
212    #[test]
213    fn makes_workspace_paths_absolute() {
214        let mut env = env_from("FRONTEND_DIR=./platform\nBACKEND_DIR=../api\n");
215        env.derive(Path::new("/w"));
216        // Compose resolves a relative path against its own project directory,
217        // which is not the workspace: these have to be absolute.
218        assert_eq!(env.get("FRONTEND_DIR"), Some("/w/./platform"));
219        assert_eq!(env.get("BACKEND_DIR"), Some("/w/../api"));
220        assert_eq!(env.get("BACKEND_APP_DIR"), Some("/w/../api/"));
221    }
222
223    #[test]
224    fn keeps_an_absolute_path_as_it_is() {
225        let mut env = env_from("FRONTEND_DIR=/srv/platform\n");
226        env.derive(Path::new("/w"));
227        assert_eq!(env.get("FRONTEND_DIR"), Some("/srv/platform"));
228    }
229
230    #[test]
231    fn reads_quoted_and_exported_values() {
232        let env = env_from("A=1\nexport B=two\nC=\"a b\"\nD='x'\n# comment\n\nE=\n");
233        assert_eq!(env.get("A"), Some("1"));
234        assert_eq!(env.get("B"), Some("two"));
235        assert_eq!(env.get("C"), Some("a b"));
236        assert_eq!(env.get("D"), Some("x"));
237        assert_eq!(env.get("E"), Some(""));
238    }
239
240    #[test]
241    fn derives_postgres_by_default() {
242        let mut env = env_from("DB_USERNAME=app\nDB_PASSWORD=pw\nDB_DATABASE=app\n");
243        env.derive(Path::new("/workspace"));
244        assert_eq!(env.get("DB_CONNECTION"), Some("pgsql"));
245        assert_eq!(
246            env.get("DATABASE_URL"),
247            Some("postgresql://app:pw@postgres:5432/app")
248        );
249    }
250
251    #[test]
252    fn derives_mysql_and_none() {
253        let mut env = env_from("DB_ENGINE=mysql\n");
254        env.derive(Path::new("/workspace"));
255        assert_eq!(env.get("DB_HOST"), Some("mysql"));
256        let mut env = env_from("DB_ENGINE=none\n");
257        env.derive(Path::new("/workspace"));
258        assert_eq!(env.get("DB_CONNECTION"), Some("sqlite"));
259        assert_eq!(env.get("DATABASE_URL"), Some(""));
260    }
261
262    #[test]
263    fn points_expo_at_the_lan_address() {
264        let mut env = env_from("BACKEND_PORT=8072\nEXPO_PUBLIC_API_BASE_URL=http://localhost:8000/api\n");
265        env.derive(Path::new("/workspace"));
266        let host = env.get("REACT_NATIVE_PACKAGER_HOSTNAME").unwrap();
267        assert_ne!(host, "localhost");
268        assert_eq!(
269            env.get("EXPO_PUBLIC_API_BASE_URL").unwrap(),
270            format!("http://{host}:8072/api")
271        );
272    }
273
274    #[test]
275    fn keeps_an_explicit_api_url() {
276        let mut env = env_from("EXPO_PUBLIC_API_BASE_URL=https://api.example.com\n");
277        env.derive(Path::new("/workspace"));
278        assert_eq!(env.get("EXPO_PUBLIC_API_BASE_URL"), Some("https://api.example.com"));
279    }
280}