Skip to main content

run_stack/
config.rs

1//! run.config.json: read, write, and the section grouping.
2//!
3//! The file is written as one object per section, and an older file is one
4//! flat object. Both mean the same thing, and every consumer looks up a plain
5//! key, so the in-memory form is flat and the sections are applied on write.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::Path;
10
11use anyhow::{Context, Result};
12use serde_json::{Map, Value};
13
14/// The sections, in the order the setup asks about them. A key is placed by an
15/// exact match, a `PREFIX_` match, or a `_SUFFIX` match, in that order.
16const 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
85/// Top-level sections that stay as object-of-object (service name → options).
86/// Flat config keys never live here, so these are not merged into `values`.
87const NESTED_SECTIONS: &[&str] = &["essential"];
88
89#[derive(Debug, Default, Clone)]
90pub struct Config {
91    /// Every setting, flat, whatever shape the file had.
92    values: BTreeMap<String, Value>,
93    /// `_`-prefixed keys the author wrote as comments, kept at the top level.
94    comments: BTreeMap<String, Value>,
95    /// Nested sections: `essential.backend = {}`, etc.
96    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    /// Accepts both shapes: a section object contributes its keys, a plain key
108    /// is taken as it is.
109    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    /// True when the file still has settings at the top level: it predates the
135    /// sections, and rewriting it is the upgrade.
136    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    /// A setting as a string: numbers and booleans render the way the shell
151    /// wrote them, so `.env` output is unchanged.
152    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    /// The shell's is_true: only these spellings are true.
168    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    /// The workspace names in EXTRA_APPS.
203    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    /// Compose service names listed under `essential` (object-of-object keys).
212    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    /// Sensible defaults when a workspace has never declared essentials.
226    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    /// Add `essential` when missing. Returns true when the file should be rewritten.
245    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    /// Grouped into one object per section. Empty sections are left out.
264    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            // Nested object-of-object sections sit next to related flat groups.
285            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        // Anything nested that was not placed above still has to round-trip.
294        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    /// An extra app's own keys sit with the apps, next to EXTRA_APPS — except
309    /// its port, which belongs with the ports.
310    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
327/// `partner-portal` -> `PARTNER_PORTAL`
328pub 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        // An extra app's command goes with the apps, its port with the ports.
386        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        // Nested keys must not leak into the flat map.
421        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}