1use 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 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 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 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 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 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
173fn resolve_dir(root: &Path, value: &str) -> String {
175 if value.starts_with('/') {
176 return value.to_string();
177 }
178 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
193fn 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 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}