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
85#[derive(Debug, Default, Clone)]
86pub struct Config {
87 values: BTreeMap<String, Value>,
89 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 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 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 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 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 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 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 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
257pub 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 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}