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#[derive(Debug, Default, Clone)]
86pub struct Config {
87    /// Every setting, flat, whatever shape the file had.
88    values: BTreeMap<String, Value>,
89    /// `_`-prefixed keys the author wrote as comments, kept at the top level.
90    comments: BTreeMap<String, Value>,
91}
92
93impl Config {
94    pub fn load(path: &Path) -> Result<Self> {
95        let text = fs::read_to_string(path)
96            .with_context(|| format!("reading {}", path.display()))?;
97        Self::from_str(&text)
98            .with_context(|| format!("parsing {}", path.display()))
99    }
100
101    /// Accepts both shapes: a section object contributes its keys, a plain key
102    /// is taken as it is.
103    pub fn from_str(text: &str) -> Result<Self> {
104        let parsed: Value = serde_json::from_str(text)?;
105        let object = parsed
106            .as_object()
107            .context("expected a JSON object of settings")?;
108
109        let mut config = Self::default();
110        for (key, value) in object {
111            if key.starts_with('_') {
112                config.comments.insert(key.clone(), value.clone());
113            } else if let Some(section) = value.as_object() {
114                for (inner, inner_value) in section {
115                    config.values.insert(inner.clone(), inner_value.clone());
116                }
117            } else {
118                config.values.insert(key.clone(), value.clone());
119            }
120        }
121        Ok(config)
122    }
123
124    /// True when the file still has settings at the top level: it predates the
125    /// sections, and rewriting it is the upgrade.
126    pub fn is_flat(text: &str) -> bool {
127        serde_json::from_str::<Value>(text)
128            .ok()
129            .and_then(|v| v.as_object().cloned())
130            .is_some_and(|o| {
131                o.iter()
132                    .any(|(k, v)| !k.starts_with('_') && !v.is_object())
133            })
134    }
135
136    pub fn get(&self, key: &str) -> Option<&Value> {
137        self.values.get(key)
138    }
139
140    /// A setting as a string: numbers and booleans render the way the shell
141    /// wrote them, so `.env` output is unchanged.
142    pub fn string(&self, key: &str) -> Option<String> {
143        self.values.get(key).map(|value| match value {
144            Value::String(text) => text.clone(),
145            Value::Null => String::new(),
146            other => other.to_string(),
147        })
148    }
149
150    pub fn str_or<'a>(&'a self, key: &str, fallback: &'a str) -> String {
151        match self.string(key) {
152            Some(text) if !text.is_empty() => text,
153            _ => fallback.to_string(),
154        }
155    }
156
157    /// The shell's is_true: only these spellings are true.
158    pub fn bool_or(&self, key: &str, fallback: bool) -> bool {
159        match self.values.get(key) {
160            Some(Value::Bool(value)) => *value,
161            Some(Value::String(text)) => matches!(
162                text.as_str(),
163                "true" | "TRUE" | "1" | "y" | "Y" | "yes" | "YES" | "on" | "ON"
164            ),
165            Some(Value::Number(number)) => number.as_i64() == Some(1),
166            _ => fallback,
167        }
168    }
169
170    pub fn port(&self, key: &str, fallback: u16) -> u16 {
171        match self.values.get(key) {
172            Some(Value::Number(number)) => {
173                number.as_u64().and_then(|n| u16::try_from(n).ok()).unwrap_or(fallback)
174            }
175            Some(Value::String(text)) => text.trim().parse().unwrap_or(fallback),
176            _ => fallback,
177        }
178    }
179
180    pub fn set(&mut self, key: &str, value: Value) {
181        self.values.insert(key.to_string(), value);
182    }
183
184    pub fn remove(&mut self, key: &str) {
185        self.values.remove(key);
186    }
187
188    pub fn keys(&self) -> impl Iterator<Item = &String> {
189        self.values.keys()
190    }
191
192    /// The workspace names in EXTRA_APPS.
193    pub fn extra_apps(&self) -> Vec<String> {
194        self.string("EXTRA_APPS")
195            .unwrap_or_default()
196            .split_whitespace()
197            .map(|name| name.split(':').next().unwrap_or(name).to_string())
198            .collect()
199    }
200
201    pub fn save(&self, path: &Path) -> Result<()> {
202        if let Some(parent) = path.parent() {
203            fs::create_dir_all(parent)
204                .with_context(|| format!("creating {}", parent.display()))?;
205        }
206        fs::write(path, self.to_json())
207            .with_context(|| format!("writing {}", path.display()))
208    }
209
210    /// Grouped into one object per section. Empty sections are left out.
211    pub fn to_json(&self) -> String {
212        let placed = self.extra_app_placements();
213
214        let mut sections: Vec<Map<String, Value>> = vec![Map::new(); GROUPS.len()];
215        let mut ordered: Vec<&String> = self.values.keys().collect();
216        ordered.sort_by_key(|key| (placed.get(*key).copied().unwrap_or_else(|| rank(key)), (*key).clone()));
217
218        for key in ordered {
219            let (group, _) = placed.get(key).copied().unwrap_or_else(|| rank(key));
220            sections[group].insert(key.clone(), self.values[key].clone());
221        }
222
223        let mut out = Map::new();
224        for (key, value) in &self.comments {
225            out.insert(key.clone(), value.clone());
226        }
227        for (index, (name, _)) in GROUPS.iter().enumerate() {
228            if !sections[index].is_empty() {
229                out.insert((*name).to_string(), Value::Object(std::mem::take(&mut sections[index])));
230            }
231        }
232        let mut text = serde_json::to_string_pretty(&Value::Object(out))
233            .expect("a map of strings always serialises");
234        text.push('\n');
235        text
236    }
237
238    /// An extra app's own keys sit with the apps, next to EXTRA_APPS — except
239    /// its port, which belongs with the ports.
240    fn extra_app_placements(&self) -> BTreeMap<String, (usize, usize)> {
241        let mut placed = BTreeMap::new();
242        for (offset, app) in self.extra_apps().iter().enumerate() {
243            let prefix = format!("{}_", key_of(app));
244            for key in self.values.keys() {
245                if key.starts_with(&prefix) && !key.ends_with("_PORT") {
246                    placed.insert(
247                        key.clone(),
248                        (APPS_GROUP, GROUPS[APPS_GROUP].1.len() + offset),
249                    );
250                }
251            }
252        }
253        placed
254    }
255}
256
257/// `partner-portal` -> `PARTNER_PORTAL`
258pub fn key_of(app: &str) -> String {
259    app.to_uppercase()
260        .chars()
261        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
262        .collect()
263}
264
265fn rank(key: &str) -> (usize, usize) {
266    for (group, (_, entries)) in GROUPS.iter().enumerate() {
267        for (position, entry) in entries.iter().enumerate() {
268            let matched = key == *entry
269                || (entry.ends_with('_') && key.starts_with(entry))
270                || (entry.starts_with('_') && key.ends_with(entry));
271            if matched {
272                return (group, position);
273            }
274        }
275    }
276    (GROUPS.len() - 1, 0)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    const FLAT: &str = r#"{
284      "COMPOSE_PROJECT_NAME": "althaqeel",
285      "BACKEND_STACK": "laravel",
286      "EXTRA_APPS": "mobile-provider",
287      "MOBILE_PROVIDER_CMD": "pnpm --filter mobile-provider run start",
288      "MOBILE_PROVIDER_PORT": 8082,
289      "WEB_PORT": 5156,
290      "RUN_ADMIN": true
291    }"#;
292
293    #[test]
294    fn reads_a_flat_file() {
295        let config = Config::from_str(FLAT).unwrap();
296        assert_eq!(config.string("COMPOSE_PROJECT_NAME").unwrap(), "althaqeel");
297        assert_eq!(config.port("WEB_PORT", 0), 5156);
298        assert!(config.bool_or("RUN_ADMIN", false));
299    }
300
301    #[test]
302    fn reads_a_sectioned_file_the_same_way() {
303        let sectioned = Config::from_str(&Config::from_str(FLAT).unwrap().to_json()).unwrap();
304        let flat = Config::from_str(FLAT).unwrap();
305        assert_eq!(sectioned.values, flat.values);
306    }
307
308    #[test]
309    fn writes_one_object_per_section() {
310        let json = Config::from_str(FLAT).unwrap().to_json();
311        let parsed: Value = serde_json::from_str(&json).unwrap();
312        let object = parsed.as_object().unwrap();
313        assert!(object["project"].is_object());
314        assert!(object["ports"]["WEB_PORT"].is_number());
315        // An extra app's command goes with the apps, its port with the ports.
316        assert!(object["apps"]["MOBILE_PROVIDER_CMD"].is_string());
317        assert!(object["ports"]["MOBILE_PROVIDER_PORT"].is_number());
318    }
319
320    #[test]
321    fn knows_which_files_need_upgrading() {
322        assert!(Config::is_flat(FLAT));
323        assert!(!Config::is_flat(&Config::from_str(FLAT).unwrap().to_json()));
324    }
325
326    #[test]
327    fn comments_stay_at_the_top() {
328        let config = Config::from_str(r#"{"_comment": "hi", "WEB_PORT": 5173}"#).unwrap();
329        let json = config.to_json();
330        assert!(json.find("_comment").unwrap() < json.find("ports").unwrap());
331    }
332}