Skip to main content

run_stack/
config.rs

1//! run.config.toml: read, write, and the section grouping.
2//!
3//! The file is written as one table per section. Older JSON files
4//! (`.run/run.config.json`, flat or sectioned) still load, and are rewritten
5//! as TOML on save / self-update. Every consumer looks up a plain key, so the
6//! in-memory form is flat and the sections are applied on write.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::Path;
11
12use anyhow::{Context, Result};
13use serde_json::{Map, Value};
14
15/// The sections, in the order the setup asks about them. A key is placed by an
16/// exact match, a `PREFIX_` match, or a `_SUFFIX` match, in that order.
17const 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
86/// Top-level sections that are not flat env keys: service name → enabled.
87const FLAG_SECTIONS: &[&str] = &["essentials"];
88/// Older spellings still accepted on load, then rewritten.
89const LEGACY_FLAG_SECTIONS: &[&str] = &["essential"];
90
91#[derive(Debug, Default, Clone)]
92pub struct Config {
93    /// Every setting, flat, whatever shape the file had.
94    values: BTreeMap<String, Value>,
95    /// Flag sections: `essentials.backend = true`, 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        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    /// Accepts both shapes: a section object contributes its keys, a plain key
132    /// is taken as it is. Flag sections (`essentials`) stay nested.
133    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    /// True when a JSON file still has settings at the top level: it predates
158    /// the sections, and rewriting it is the upgrade.
159    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    /// A setting as a string: numbers and booleans render the way the shell
174    /// wrote them, so `.env` output is unchanged.
175    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    /// The shell's is_true: only these spellings are true.
191    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    /// The workspace names in EXTRA_APPS.
227    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    /// Compose services enabled under `[essentials]` (`service = true`).
236    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    /// Sensible defaults when a workspace has never declared essentials.
250    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    /// Add / normalise `[essentials]`. Returns true when the file should be rewritten.
269    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(&section);
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    /// Grouped into one table per section. Empty sections are left out.
314    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    /// Same grouping as TOML, as JSON — used by tests and migration checks.
353    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    /// An extra app's own keys sit with the apps, next to EXTRA_APPS — except
405    /// its port, which belongs with the ports.
406    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
423/// `partner-portal` -> `PARTNER_PORTAL`
424pub 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        // Legacy empty object `{ }` meant "include this service".
478        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}