1use std::collections::BTreeMap;
9use std::fs;
10use std::path::Path;
11
12use anyhow::{Context, Result};
13use serde_json::{Map, Value};
14
15const GROUPS: &[(&str, &[&str])] = &[
18 ("project", &["COMPOSE_PROJECT_NAME", "PROJECT_LABEL"]),
19 ("repositories", &["BACKEND_DIR", "BACKEND_SUBDIR", "FRONTEND_DIR"]),
20 (
21 "backend",
22 &[
23 "BACKEND_STACK",
24 "BACKEND_INSTALL_CMD",
25 "BACKEND_BUILD_CMD",
26 "BACKEND_START_CMD",
27 "BACKEND_MIGRATE_CMD",
28 "BACKEND_SEED_CMD",
29 "BACKEND_QUEUE_CMD",
30 "BACKEND_SCHEDULE_CMD",
31 "BACKEND_HEALTH_PATH",
32 "BACKEND_LOGIN_PATH",
33 "RUN_QUEUE",
34 "RUN_SCHEDULER",
35 ],
36 ),
37 (
38 "apps",
39 &[
40 "WEB_APP",
41 "WEB_CMD",
42 "RUN_ADMIN",
43 "ADMIN_APP",
44 "ADMIN_CMD",
45 "RUN_LANDING",
46 "LANDING_APP",
47 "LANDING_CMD",
48 "RUN_MOBILE",
49 "MOBILE_APP",
50 "MOBILE_CMD",
51 "RUN_DESKTOP",
52 "DESKTOP_STACK",
53 "DESKTOP_APP",
54 "DESKTOP_CMD",
55 "DESKTOP_HOST_CMD",
56 "EXTRA_DEPS_APPS",
57 "EXTRA_APPS",
58 ],
59 ),
60 (
61 "infrastructure",
62 &[
63 "DB_ENGINE",
64 "DB_DATABASE",
65 "DB_USERNAME",
66 "DB_PASSWORD",
67 "RUN_MIGRATIONS",
68 "RUN_SEEDERS",
69 "RUN_REDIS",
70 "RUN_MAILPIT",
71 "RUN_MINIO",
72 "MINIO_BUCKET",
73 "MINIO_ROOT_USER",
74 "MINIO_ROOT_PASSWORD",
75 ],
76 ),
77 ("deploy", &["DEPLOY_"]),
78 ("ports", &["_PORT"]),
79 ("urls", &["VITE_", "EXPO_", "REACT_NATIVE_"]),
80 ("docker", &["_MEMORY_LIMIT", "_CPU_LIMIT", "DOCKER_SHM_SIZE"]),
81 ("other", &[]),
82];
83
84const APPS_GROUP: usize = 3;
85
86const FLAG_SECTIONS: &[&str] = &["essentials"];
88const LEGACY_FLAG_SECTIONS: &[&str] = &["essential"];
90
91#[derive(Debug, Default, Clone)]
92pub struct Config {
93 values: 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 let ext = path
104 .extension()
105 .and_then(|e| e.to_str())
106 .unwrap_or_default();
107 match ext {
108 "toml" => Self::from_toml(&text),
109 "json" => Self::from_json(&text),
110 _ => Self::from_toml(&text).or_else(|_| Self::from_json(&text)),
111 }
112 .with_context(|| format!("parsing {}", path.display()))
113 }
114
115 pub fn from_toml(text: &str) -> Result<Self> {
116 let parsed: toml::Value = toml::from_str(text)?;
117 let table = parsed
118 .as_table()
119 .context("expected a TOML table of settings")?;
120 Self::from_table(table.iter().map(|(k, v)| (k.clone(), toml_to_json(v))))
121 }
122
123 pub fn from_json(text: &str) -> Result<Self> {
124 let parsed: Value = serde_json::from_str(text)?;
125 let object = parsed
126 .as_object()
127 .context("expected a JSON object of settings")?;
128 Self::from_table(object.iter().map(|(k, v)| (k.clone(), v.clone())))
129 }
130
131 fn from_table(entries: impl IntoIterator<Item = (String, Value)>) -> Result<Self> {
134 let mut config = Self::default();
135 for (key, value) in entries {
136 if key.starts_with('_') {
137 continue;
138 }
139 if FLAG_SECTIONS.contains(&key.as_str()) || LEGACY_FLAG_SECTIONS.contains(&key.as_str())
140 {
141 if let Some(section) = value.as_object() {
142 config
143 .nested
144 .insert(key, normalize_flag_section(section));
145 }
146 } else if let Some(section) = value.as_object() {
147 for (inner, inner_value) in section {
148 config.values.insert(inner.clone(), inner_value.clone());
149 }
150 } else {
151 config.values.insert(key, value);
152 }
153 }
154 Ok(config)
155 }
156
157 pub fn is_flat_json(text: &str) -> bool {
160 serde_json::from_str::<Value>(text)
161 .ok()
162 .and_then(|v| v.as_object().cloned())
163 .is_some_and(|o| {
164 o.iter()
165 .any(|(k, v)| !k.starts_with('_') && !v.is_object())
166 })
167 }
168
169 pub fn get(&self, key: &str) -> Option<&Value> {
170 self.values.get(key)
171 }
172
173 pub fn string(&self, key: &str) -> Option<String> {
176 self.values.get(key).map(|value| match value {
177 Value::String(text) => text.clone(),
178 Value::Null => String::new(),
179 other => other.to_string(),
180 })
181 }
182
183 pub fn str_or<'a>(&'a self, key: &str, fallback: &'a str) -> String {
184 match self.string(key) {
185 Some(text) if !text.is_empty() => text,
186 _ => fallback.to_string(),
187 }
188 }
189
190 pub fn bool_or(&self, key: &str, fallback: bool) -> bool {
192 match self.values.get(key) {
193 Some(Value::Bool(value)) => *value,
194 Some(Value::String(text)) => matches!(
195 text.as_str(),
196 "true" | "TRUE" | "1" | "y" | "Y" | "yes" | "YES" | "on" | "ON"
197 ),
198 Some(Value::Number(number)) => number.as_i64() == Some(1),
199 _ => fallback,
200 }
201 }
202
203 pub fn port(&self, key: &str, fallback: u16) -> u16 {
204 match self.values.get(key) {
205 Some(Value::Number(number)) => number
206 .as_u64()
207 .and_then(|n| u16::try_from(n).ok())
208 .unwrap_or(fallback),
209 Some(Value::String(text)) => text.trim().parse().unwrap_or(fallback),
210 _ => fallback,
211 }
212 }
213
214 pub fn set(&mut self, key: &str, value: Value) {
215 self.values.insert(key.to_string(), value);
216 }
217
218 pub fn remove(&mut self, key: &str) {
219 self.values.remove(key);
220 }
221
222 pub fn keys(&self) -> impl Iterator<Item = &String> {
223 self.values.keys()
224 }
225
226 pub fn extra_apps(&self) -> Vec<String> {
228 self.string("EXTRA_APPS")
229 .unwrap_or_default()
230 .split_whitespace()
231 .map(|name| name.split(':').next().unwrap_or(name).to_string())
232 .collect()
233 }
234
235 pub fn essential_services(&self) -> Vec<String> {
237 self.nested
238 .get("essentials")
239 .map(|section| {
240 section
241 .iter()
242 .filter(|(key, value)| !key.starts_with('_') && flag_enabled(value))
243 .map(|(key, _)| key.clone())
244 .collect()
245 })
246 .unwrap_or_default()
247 }
248
249 pub fn default_essentials(&self) -> Map<String, Value> {
251 let mut section = Map::new();
252 section.insert("backend".into(), Value::Bool(true));
253 match self.str_or("DB_ENGINE", "postgres").as_str() {
254 "none" => {}
255 "mysql" => {
256 section.insert("mysql".into(), Value::Bool(true));
257 }
258 _ => {
259 section.insert("postgres".into(), Value::Bool(true));
260 }
261 }
262 if self.bool_or("RUN_REDIS", true) {
263 section.insert("redis".into(), Value::Bool(true));
264 }
265 section
266 }
267
268 pub fn ensure_essential(&mut self) -> bool {
270 let mut changed = false;
271
272 if let Some(legacy) = self.nested.remove("essential") {
273 let mut merged = self
274 .nested
275 .remove("essentials")
276 .unwrap_or_default();
277 for (key, value) in legacy {
278 if key.starts_with('_') {
279 continue;
280 }
281 merged
282 .entry(key)
283 .or_insert_with(|| Value::Bool(flag_enabled(&value)));
284 }
285 self.nested.insert("essentials".into(), normalize_flag_section(&merged));
286 changed = true;
287 }
288
289 if let Some(section) = self.nested.get("essentials").cloned() {
290 let normalized = normalize_flag_section(§ion);
291 if normalized != section {
292 self.nested.insert("essentials".into(), normalized);
293 changed = true;
294 }
295 } else {
296 self.nested
297 .insert("essentials".into(), self.default_essentials());
298 changed = true;
299 }
300
301 changed
302 }
303
304 pub fn save(&self, path: &Path) -> Result<()> {
305 if let Some(parent) = path.parent() {
306 fs::create_dir_all(parent)
307 .with_context(|| format!("creating {}", parent.display()))?;
308 }
309 fs::write(path, self.to_toml())
310 .with_context(|| format!("writing {}", path.display()))
311 }
312
313 pub fn to_toml(&self) -> String {
315 let root = match self.to_root_value() {
316 Value::Object(map) => map,
317 _ => return String::new(),
318 };
319 let mut lines = Vec::new();
320 let mut written = std::collections::BTreeSet::new();
321
322 for (name, _) in GROUPS {
323 if let Some(section) = root.get(*name).and_then(Value::as_object) {
324 emit_flat_table(*name, section, &mut lines);
325 written.insert(*name);
326 }
327 if *name == "infrastructure" {
328 for flag_name in FLAG_SECTIONS {
329 if let Some(section) = root.get(*flag_name).and_then(Value::as_object) {
330 emit_flat_table(*flag_name, section, &mut lines);
331 written.insert(*flag_name);
332 }
333 }
334 }
335 }
336 for (name, value) in &root {
337 if written.contains(name.as_str()) {
338 continue;
339 }
340 if let Some(section) = value.as_object() {
341 emit_flat_table(name, section, &mut lines);
342 }
343 }
344
345 let mut text = lines.join("\n");
346 if !text.ends_with('\n') {
347 text.push('\n');
348 }
349 text
350 }
351
352 pub fn to_json(&self) -> String {
354 let mut text = serde_json::to_string_pretty(&self.to_root_value())
355 .expect("a map of strings always serialises");
356 text.push('\n');
357 text
358 }
359
360 fn to_root_value(&self) -> Value {
361 let placed = self.extra_app_placements();
362
363 let mut sections: Vec<Map<String, Value>> = vec![Map::new(); GROUPS.len()];
364 let mut ordered: Vec<&String> = self.values.keys().collect();
365 ordered.sort_by_key(|key| {
366 (
367 placed.get(*key).copied().unwrap_or_else(|| rank(key)),
368 (*key).clone(),
369 )
370 });
371
372 for key in ordered {
373 let (group, _) = placed.get(key).copied().unwrap_or_else(|| rank(key));
374 sections[group].insert(key.clone(), self.values[key].clone());
375 }
376
377 let mut out = Map::new();
378 for (index, (name, _)) in GROUPS.iter().enumerate() {
379 if !sections[index].is_empty() {
380 out.insert(
381 (*name).to_string(),
382 Value::Object(std::mem::take(&mut sections[index])),
383 );
384 }
385 if *name == "infrastructure" {
386 for flag_name in FLAG_SECTIONS {
387 if let Some(section) = self.nested.get(*flag_name) {
388 out.insert((*flag_name).to_string(), Value::Object(section.clone()));
389 }
390 }
391 }
392 }
393 for flag_name in FLAG_SECTIONS {
394 if out.contains_key(*flag_name) {
395 continue;
396 }
397 if let Some(section) = self.nested.get(*flag_name) {
398 out.insert((*flag_name).to_string(), Value::Object(section.clone()));
399 }
400 }
401 Value::Object(out)
402 }
403
404 fn extra_app_placements(&self) -> BTreeMap<String, (usize, usize)> {
407 let mut placed = BTreeMap::new();
408 for (offset, app) in self.extra_apps().iter().enumerate() {
409 let prefix = format!("{}_", key_of(app));
410 for key in self.values.keys() {
411 if key.starts_with(&prefix) && !key.ends_with("_PORT") {
412 placed.insert(
413 key.clone(),
414 (APPS_GROUP, GROUPS[APPS_GROUP].1.len() + offset),
415 );
416 }
417 }
418 }
419 placed
420 }
421}
422
423pub fn key_of(app: &str) -> String {
425 app.to_uppercase()
426 .chars()
427 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
428 .collect()
429}
430
431fn rank(key: &str) -> (usize, usize) {
432 for (group, (_, entries)) in GROUPS.iter().enumerate() {
433 for (position, entry) in entries.iter().enumerate() {
434 let matched = key == *entry
435 || (entry.ends_with('_') && key.starts_with(entry))
436 || (entry.starts_with('_') && key.ends_with(entry));
437 if matched {
438 return (group, position);
439 }
440 }
441 }
442 (GROUPS.len() - 1, 0)
443}
444
445fn emit_flat_table(name: &str, section: &Map<String, Value>, lines: &mut Vec<String>) {
446 if section.is_empty() {
447 return;
448 }
449 lines.push(format!("[{name}]"));
450 for (key, value) in section {
451 lines.push(format!("{key} = {}", emit_toml_value(value)));
452 }
453 lines.push(String::new());
454}
455
456fn emit_toml_value(value: &Value) -> String {
457 match value {
458 Value::Bool(flag) => if *flag { "true" } else { "false" }.to_string(),
459 Value::Number(number) => number.to_string(),
460 Value::String(text) => format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")),
461 Value::Null => "\"\"".to_string(),
462 other => format!(
463 "\"{}\"",
464 other.to_string().replace('\\', "\\\\").replace('"', "\\\"")
465 ),
466 }
467}
468
469fn flag_enabled(value: &Value) -> bool {
470 match value {
471 Value::Bool(flag) => *flag,
472 Value::String(text) => matches!(
473 text.as_str(),
474 "true" | "TRUE" | "1" | "y" | "Y" | "yes" | "YES" | "on" | "ON"
475 ),
476 Value::Number(number) => number.as_i64() == Some(1),
477 Value::Object(_) => true,
479 _ => false,
480 }
481}
482
483fn normalize_flag_section(section: &Map<String, Value>) -> Map<String, Value> {
484 let mut out = Map::new();
485 for (key, value) in section {
486 if key.starts_with('_') {
487 continue;
488 }
489 out.insert(key.clone(), Value::Bool(flag_enabled(value)));
490 }
491 out
492}
493
494fn toml_to_json(value: &toml::Value) -> Value {
495 match value {
496 toml::Value::String(text) => Value::String(text.clone()),
497 toml::Value::Integer(number) => Value::Number((*number).into()),
498 toml::Value::Float(number) => serde_json::Number::from_f64(*number)
499 .map(Value::Number)
500 .unwrap_or(Value::Null),
501 toml::Value::Boolean(flag) => Value::Bool(*flag),
502 toml::Value::Datetime(dt) => Value::String(dt.to_string()),
503 toml::Value::Array(items) => Value::Array(items.iter().map(toml_to_json).collect()),
504 toml::Value::Table(table) => {
505 let mut map = Map::new();
506 for (key, inner) in table {
507 map.insert(key.clone(), toml_to_json(inner));
508 }
509 Value::Object(map)
510 }
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 const FLAT_JSON: &str = r#"{
519 "COMPOSE_PROJECT_NAME": "althaqeel",
520 "BACKEND_STACK": "laravel",
521 "EXTRA_APPS": "mobile-provider",
522 "MOBILE_PROVIDER_CMD": "pnpm --filter mobile-provider run start",
523 "MOBILE_PROVIDER_PORT": 8082,
524 "WEB_PORT": 5156,
525 "RUN_ADMIN": true
526 }"#;
527
528 #[test]
529 fn reads_a_flat_json_file() {
530 let config = Config::from_json(FLAT_JSON).unwrap();
531 assert_eq!(config.string("COMPOSE_PROJECT_NAME").unwrap(), "althaqeel");
532 assert_eq!(config.port("WEB_PORT", 0), 5156);
533 assert!(config.bool_or("RUN_ADMIN", false));
534 }
535
536 #[test]
537 fn writes_toml_sections() {
538 let toml = Config::from_json(FLAT_JSON).unwrap().to_toml();
539 assert!(toml.contains("[project]"));
540 assert!(toml.contains("[ports]"));
541 assert!(toml.contains("WEB_PORT = 5156"));
542 assert!(toml.contains("[apps]"));
543 assert!(toml.contains("MOBILE_PROVIDER_CMD"));
544 let round = Config::from_toml(&toml).unwrap();
545 let flat = Config::from_json(FLAT_JSON).unwrap();
546 assert_eq!(round.values, flat.values);
547 }
548
549 #[test]
550 fn knows_which_json_files_need_sectioning() {
551 assert!(Config::is_flat_json(FLAT_JSON));
552 assert!(!Config::is_flat_json(&Config::from_json(FLAT_JSON).unwrap().to_json()));
553 }
554
555 #[test]
556 fn keeps_essentials_as_bool_flags() {
557 let config = Config::from_toml(
558 r#"
559 [infrastructure]
560 DB_ENGINE = "postgres"
561 RUN_REDIS = true
562
563 [essentials]
564 backend = true
565 postgres = true
566 redis = true
567 web = false
568 "#,
569 )
570 .unwrap();
571 assert_eq!(
572 config.essential_services(),
573 vec![
574 "backend".to_string(),
575 "postgres".to_string(),
576 "redis".to_string()
577 ]
578 );
579 assert!(config.get("backend").is_none());
580 let toml = config.to_toml();
581 assert!(toml.contains("[essentials]"));
582 assert!(toml.contains("backend = true"));
583 assert!(toml.contains("web = false"));
584 let round = Config::from_toml(&toml).unwrap();
585 assert_eq!(round.essential_services(), config.essential_services());
586 }
587
588 #[test]
589 fn migrates_missing_essentials_from_infrastructure() {
590 let mut config = Config::from_toml(
591 r#"
592 [infrastructure]
593 DB_ENGINE = "mysql"
594 RUN_REDIS = false
595 "#,
596 )
597 .unwrap();
598 assert!(config.ensure_essential());
599 assert_eq!(
600 config.essential_services(),
601 vec!["backend".to_string(), "mysql".to_string()]
602 );
603 assert!(!config.ensure_essential());
604 }
605
606 #[test]
607 fn migrates_legacy_essential_tables_to_bools() {
608 let mut config = Config::from_toml(
609 r#"
610 [essential.backend]
611 [essential.postgres]
612 "#,
613 )
614 .unwrap();
615 assert!(config.ensure_essential());
616 assert_eq!(
617 config.essential_services(),
618 vec!["backend".to_string(), "postgres".to_string()]
619 );
620 let toml = config.to_toml();
621 assert!(toml.contains("[essentials]"));
622 assert!(toml.contains("backend = true"));
623 assert!(!toml.contains("[essential."));
624 }
625}