1use std::collections::BTreeMap;
8use std::fs;
9use std::path::Path;
10
11use anyhow::{Context, Result};
12use serde_json::{Map, Value};
13
14const GROUPS: &[(&str, &[&str])] = &[
17 ("project", &["COMPOSE_PROJECT_NAME", "PROJECT_LABEL"]),
18 ("repositories", &["BACKEND_DIR", "BACKEND_SUBDIR", "FRONTEND_DIR"]),
19 (
20 "backend",
21 &[
22 "BACKEND_STACK",
23 "BACKEND_INSTALL_CMD",
24 "BACKEND_BUILD_CMD",
25 "BACKEND_START_CMD",
26 "BACKEND_MIGRATE_CMD",
27 "BACKEND_SEED_CMD",
28 "BACKEND_QUEUE_CMD",
29 "BACKEND_SCHEDULE_CMD",
30 "BACKEND_HEALTH_PATH",
31 "BACKEND_LOGIN_PATH",
32 "RUN_QUEUE",
33 "RUN_SCHEDULER",
34 ],
35 ),
36 (
37 "apps",
38 &[
39 "WEB_APP",
40 "WEB_CMD",
41 "RUN_ADMIN",
42 "ADMIN_APP",
43 "ADMIN_CMD",
44 "RUN_LANDING",
45 "LANDING_APP",
46 "LANDING_CMD",
47 "RUN_MOBILE",
48 "MOBILE_APP",
49 "MOBILE_CMD",
50 "RUN_DESKTOP",
51 "DESKTOP_STACK",
52 "DESKTOP_APP",
53 "DESKTOP_CMD",
54 "DESKTOP_HOST_CMD",
55 "EXTRA_DEPS_APPS",
56 "EXTRA_APPS",
57 ],
58 ),
59 (
60 "infrastructure",
61 &[
62 "DB_ENGINE",
63 "DB_DATABASE",
64 "DB_USERNAME",
65 "DB_PASSWORD",
66 "RUN_MIGRATIONS",
67 "RUN_SEEDERS",
68 "RUN_REDIS",
69 "RUN_MAILPIT",
70 "RUN_MINIO",
71 "MINIO_BUCKET",
72 "MINIO_ROOT_USER",
73 "MINIO_ROOT_PASSWORD",
74 ],
75 ),
76 ("deploy", &["DEPLOY_"]),
77 ("ports", &["_PORT"]),
78 ("urls", &["VITE_", "EXPO_", "REACT_NATIVE_"]),
79 ("docker", &["_MEMORY_LIMIT", "_CPU_LIMIT", "DOCKER_SHM_SIZE"]),
80 ("other", &[]),
81];
82
83const APPS_GROUP: usize = 3;
84
85const NESTED_SECTIONS: &[&str] = &["essential"];
88
89#[derive(Debug, Default, Clone)]
90pub struct Config {
91 values: BTreeMap<String, Value>,
93 comments: BTreeMap<String, Value>,
95 nested: BTreeMap<String, Map<String, Value>>,
97}
98
99impl Config {
100 pub fn load(path: &Path) -> Result<Self> {
101 let text = fs::read_to_string(path)
102 .with_context(|| format!("reading {}", path.display()))?;
103 Self::from_str(&text)
104 .with_context(|| format!("parsing {}", path.display()))
105 }
106
107 pub fn from_str(text: &str) -> Result<Self> {
110 let parsed: Value = serde_json::from_str(text)?;
111 let object = parsed
112 .as_object()
113 .context("expected a JSON object of settings")?;
114
115 let mut config = Self::default();
116 for (key, value) in object {
117 if key.starts_with('_') {
118 config.comments.insert(key.clone(), value.clone());
119 } else if NESTED_SECTIONS.contains(&key.as_str()) {
120 if let Some(section) = value.as_object() {
121 config.nested.insert(key.clone(), section.clone());
122 }
123 } else if let Some(section) = value.as_object() {
124 for (inner, inner_value) in section {
125 config.values.insert(inner.clone(), inner_value.clone());
126 }
127 } else {
128 config.values.insert(key.clone(), value.clone());
129 }
130 }
131 Ok(config)
132 }
133
134 pub fn is_flat(text: &str) -> bool {
137 serde_json::from_str::<Value>(text)
138 .ok()
139 .and_then(|v| v.as_object().cloned())
140 .is_some_and(|o| {
141 o.iter()
142 .any(|(k, v)| !k.starts_with('_') && !v.is_object())
143 })
144 }
145
146 pub fn get(&self, key: &str) -> Option<&Value> {
147 self.values.get(key)
148 }
149
150 pub fn string(&self, key: &str) -> Option<String> {
153 self.values.get(key).map(|value| match value {
154 Value::String(text) => text.clone(),
155 Value::Null => String::new(),
156 other => other.to_string(),
157 })
158 }
159
160 pub fn str_or<'a>(&'a self, key: &str, fallback: &'a str) -> String {
161 match self.string(key) {
162 Some(text) if !text.is_empty() => text,
163 _ => fallback.to_string(),
164 }
165 }
166
167 pub fn bool_or(&self, key: &str, fallback: bool) -> bool {
169 match self.values.get(key) {
170 Some(Value::Bool(value)) => *value,
171 Some(Value::String(text)) => matches!(
172 text.as_str(),
173 "true" | "TRUE" | "1" | "y" | "Y" | "yes" | "YES" | "on" | "ON"
174 ),
175 Some(Value::Number(number)) => number.as_i64() == Some(1),
176 _ => fallback,
177 }
178 }
179
180 pub fn port(&self, key: &str, fallback: u16) -> u16 {
181 match self.values.get(key) {
182 Some(Value::Number(number)) => {
183 number.as_u64().and_then(|n| u16::try_from(n).ok()).unwrap_or(fallback)
184 }
185 Some(Value::String(text)) => text.trim().parse().unwrap_or(fallback),
186 _ => fallback,
187 }
188 }
189
190 pub fn set(&mut self, key: &str, value: Value) {
191 self.values.insert(key.to_string(), value);
192 }
193
194 pub fn remove(&mut self, key: &str) {
195 self.values.remove(key);
196 }
197
198 pub fn keys(&self) -> impl Iterator<Item = &String> {
199 self.values.keys()
200 }
201
202 pub fn extra_apps(&self) -> Vec<String> {
204 self.string("EXTRA_APPS")
205 .unwrap_or_default()
206 .split_whitespace()
207 .map(|name| name.split(':').next().unwrap_or(name).to_string())
208 .collect()
209 }
210
211 pub fn essential_services(&self) -> Vec<String> {
213 self.nested
214 .get("essential")
215 .map(|section| {
216 section
217 .keys()
218 .filter(|key| !key.starts_with('_'))
219 .cloned()
220 .collect()
221 })
222 .unwrap_or_default()
223 }
224
225 pub fn default_essential(&self) -> Map<String, Value> {
227 let mut section = Map::new();
228 section.insert("backend".into(), Value::Object(Map::new()));
229 match self.str_or("DB_ENGINE", "postgres").as_str() {
230 "none" => {}
231 "mysql" => {
232 section.insert("mysql".into(), Value::Object(Map::new()));
233 }
234 _ => {
235 section.insert("postgres".into(), Value::Object(Map::new()));
236 }
237 }
238 if self.bool_or("RUN_REDIS", true) {
239 section.insert("redis".into(), Value::Object(Map::new()));
240 }
241 section
242 }
243
244 pub fn ensure_essential(&mut self) -> bool {
246 if self.nested.contains_key("essential") {
247 return false;
248 }
249 self.nested
250 .insert("essential".into(), self.default_essential());
251 true
252 }
253
254 pub fn save(&self, path: &Path) -> Result<()> {
255 if let Some(parent) = path.parent() {
256 fs::create_dir_all(parent)
257 .with_context(|| format!("creating {}", parent.display()))?;
258 }
259 fs::write(path, self.to_json())
260 .with_context(|| format!("writing {}", path.display()))
261 }
262
263 pub fn to_json(&self) -> String {
265 let placed = self.extra_app_placements();
266
267 let mut sections: Vec<Map<String, Value>> = vec![Map::new(); GROUPS.len()];
268 let mut ordered: Vec<&String> = self.values.keys().collect();
269 ordered.sort_by_key(|key| (placed.get(*key).copied().unwrap_or_else(|| rank(key)), (*key).clone()));
270
271 for key in ordered {
272 let (group, _) = placed.get(key).copied().unwrap_or_else(|| rank(key));
273 sections[group].insert(key.clone(), self.values[key].clone());
274 }
275
276 let mut out = Map::new();
277 for (key, value) in &self.comments {
278 out.insert(key.clone(), value.clone());
279 }
280 for (index, (name, _)) in GROUPS.iter().enumerate() {
281 if !sections[index].is_empty() {
282 out.insert((*name).to_string(), Value::Object(std::mem::take(&mut sections[index])));
283 }
284 if *name == "infrastructure" {
286 for nested_name in NESTED_SECTIONS {
287 if let Some(section) = self.nested.get(*nested_name) {
288 out.insert((*nested_name).to_string(), Value::Object(section.clone()));
289 }
290 }
291 }
292 }
293 for nested_name in NESTED_SECTIONS {
295 if out.contains_key(*nested_name) {
296 continue;
297 }
298 if let Some(section) = self.nested.get(*nested_name) {
299 out.insert((*nested_name).to_string(), Value::Object(section.clone()));
300 }
301 }
302 let mut text = serde_json::to_string_pretty(&Value::Object(out))
303 .expect("a map of strings always serialises");
304 text.push('\n');
305 text
306 }
307
308 fn extra_app_placements(&self) -> BTreeMap<String, (usize, usize)> {
311 let mut placed = BTreeMap::new();
312 for (offset, app) in self.extra_apps().iter().enumerate() {
313 let prefix = format!("{}_", key_of(app));
314 for key in self.values.keys() {
315 if key.starts_with(&prefix) && !key.ends_with("_PORT") {
316 placed.insert(
317 key.clone(),
318 (APPS_GROUP, GROUPS[APPS_GROUP].1.len() + offset),
319 );
320 }
321 }
322 }
323 placed
324 }
325}
326
327pub fn key_of(app: &str) -> String {
329 app.to_uppercase()
330 .chars()
331 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
332 .collect()
333}
334
335fn rank(key: &str) -> (usize, usize) {
336 for (group, (_, entries)) in GROUPS.iter().enumerate() {
337 for (position, entry) in entries.iter().enumerate() {
338 let matched = key == *entry
339 || (entry.ends_with('_') && key.starts_with(entry))
340 || (entry.starts_with('_') && key.ends_with(entry));
341 if matched {
342 return (group, position);
343 }
344 }
345 }
346 (GROUPS.len() - 1, 0)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 const FLAT: &str = r#"{
354 "COMPOSE_PROJECT_NAME": "althaqeel",
355 "BACKEND_STACK": "laravel",
356 "EXTRA_APPS": "mobile-provider",
357 "MOBILE_PROVIDER_CMD": "pnpm --filter mobile-provider run start",
358 "MOBILE_PROVIDER_PORT": 8082,
359 "WEB_PORT": 5156,
360 "RUN_ADMIN": true
361 }"#;
362
363 #[test]
364 fn reads_a_flat_file() {
365 let config = Config::from_str(FLAT).unwrap();
366 assert_eq!(config.string("COMPOSE_PROJECT_NAME").unwrap(), "althaqeel");
367 assert_eq!(config.port("WEB_PORT", 0), 5156);
368 assert!(config.bool_or("RUN_ADMIN", false));
369 }
370
371 #[test]
372 fn reads_a_sectioned_file_the_same_way() {
373 let sectioned = Config::from_str(&Config::from_str(FLAT).unwrap().to_json()).unwrap();
374 let flat = Config::from_str(FLAT).unwrap();
375 assert_eq!(sectioned.values, flat.values);
376 }
377
378 #[test]
379 fn writes_one_object_per_section() {
380 let json = Config::from_str(FLAT).unwrap().to_json();
381 let parsed: Value = serde_json::from_str(&json).unwrap();
382 let object = parsed.as_object().unwrap();
383 assert!(object["project"].is_object());
384 assert!(object["ports"]["WEB_PORT"].is_number());
385 assert!(object["apps"]["MOBILE_PROVIDER_CMD"].is_string());
387 assert!(object["ports"]["MOBILE_PROVIDER_PORT"].is_number());
388 }
389
390 #[test]
391 fn knows_which_files_need_upgrading() {
392 assert!(Config::is_flat(FLAT));
393 assert!(!Config::is_flat(&Config::from_str(FLAT).unwrap().to_json()));
394 }
395
396 #[test]
397 fn comments_stay_at_the_top() {
398 let config = Config::from_str(r#"{"_comment": "hi", "WEB_PORT": 5173}"#).unwrap();
399 let json = config.to_json();
400 assert!(json.find("_comment").unwrap() < json.find("ports").unwrap());
401 }
402
403 #[test]
404 fn keeps_essential_as_object_of_objects() {
405 let config = Config::from_str(
406 r#"{
407 "infrastructure": { "DB_ENGINE": "postgres", "RUN_REDIS": true },
408 "essential": { "backend": {}, "postgres": {}, "redis": {} }
409 }"#,
410 )
411 .unwrap();
412 assert_eq!(
413 config.essential_services(),
414 vec![
415 "backend".to_string(),
416 "postgres".to_string(),
417 "redis".to_string()
418 ]
419 );
420 assert!(config.get("backend").is_none());
422 let json = config.to_json();
423 let parsed: Value = serde_json::from_str(&json).unwrap();
424 assert!(parsed["essential"]["backend"].is_object());
425 assert!(parsed["essential"]["postgres"].is_object());
426 }
427
428 #[test]
429 fn migrates_missing_essential_from_infrastructure() {
430 let mut config = Config::from_str(
431 r#"{
432 "infrastructure": {
433 "DB_ENGINE": "mysql",
434 "RUN_REDIS": false
435 }
436 }"#,
437 )
438 .unwrap();
439 assert!(config.ensure_essential());
440 assert_eq!(
441 config.essential_services(),
442 vec!["backend".to_string(), "mysql".to_string()]
443 );
444 assert!(!config.ensure_essential());
445 }
446}