mini_app_core/config.rs
1/// Runtime configuration for mini-app-mcp.
2///
3/// Configuration is read from the environment (optionally loaded from a
4/// `.mini-app-mcp.env` file via [`dotenvy`]).
5///
6/// # Modes
7///
8/// **Legacy mode** (single-table): both `MINI_APP_SCHEMA` and `MINI_APP_DB`
9/// must be set. Directory scan is skipped. The single table described by the
10/// schema file is registered as the default table.
11///
12/// **Multi-table mode**: `MINI_APP_USER_DIR` and/or `MINI_APP_PROJECT_DIR` are
13/// used to discover tables from directory layout. The legacy env vars remain
14/// available; when both legacy and dir env vars are set at the same time, the
15/// legacy single-table entry is also included in the registry (duplicate table
16/// names are resolved with legacy taking precedence, and a warning is logged).
17use std::env;
18use std::path::PathBuf;
19
20use crate::error::MiniAppError;
21
22/// Runtime configuration for the mini-app-mcp server.
23///
24/// Constructed exclusively via [`Config::load`]; there is no public constructor
25/// so callers always go through the validated loading path.
26///
27/// # Fields
28/// - `schema_path` / `db_path`: legacy single-table paths (set when
29/// `MINI_APP_SCHEMA` and `MINI_APP_DB` are present).
30/// - `user_dir`: base directory for User-scope tables
31/// (`~/.mini-app/` by default, overridden by `MINI_APP_USER_DIR`).
32/// - `project_dir`: override directory for Project-scope tables
33/// (`./.mini-app/` by default, overridden by `MINI_APP_PROJECT_DIR`).
34#[derive(Debug, Clone)]
35pub struct Config {
36 /// Path to the `schema.yaml` file that defines the table's field schema.
37 ///
38 /// Set via the `MINI_APP_SCHEMA` environment variable.
39 /// `None` when only directory-based multi-table configuration is used.
40 pub schema_path: Option<PathBuf>,
41
42 /// Path to the SQLite database file.
43 ///
44 /// Set via the `MINI_APP_DB` environment variable.
45 /// `None` when only directory-based multi-table configuration is used.
46 pub db_path: Option<PathBuf>,
47
48 /// Base directory for User-scope table definitions.
49 ///
50 /// Defaults to `~/.mini-app/` when `MINI_APP_USER_DIR` is not set.
51 /// Each subdirectory `<table>/` is expected to contain `schema.yaml` and
52 /// `<table>.db`.
53 pub user_dir: Option<PathBuf>,
54
55 /// Override directory for Project-scope table definitions.
56 ///
57 /// Defaults to `./.mini-app/` (current working directory) when
58 /// `MINI_APP_PROJECT_DIR` is not set.
59 /// Same subdirectory layout as `user_dir`; Project entries override
60 /// User entries with the same table name (file-level swap, not
61 /// field-level merge).
62 pub project_dir: Option<PathBuf>,
63
64 /// Number of backup pairs (`{table}.{ts}.yaml` + `{table}.{ts}.db`) to
65 /// retain in `_backup/` before the oldest are purged.
66 ///
67 /// Defaults to `10` when `MINI_APP_BACKUP_RETENTION` is not set.
68 /// Set via the `MINI_APP_BACKUP_RETENTION` environment variable
69 /// (must be a positive integer; non-parsable values are silently ignored
70 /// and the default of `10` is used).
71 pub backup_retention: Option<usize>,
72
73 /// Number of snapshot files (`{table}.{ts}.db`) to retain in `_snapshots/`
74 /// before the oldest are purged.
75 ///
76 /// Defaults to `10` when `MINI_APP_SNAPSHOT_RETENTION` is not set.
77 /// Set via the `MINI_APP_SNAPSHOT_RETENTION` environment variable
78 /// (must be a positive integer; non-parsable values are silently ignored
79 /// and the default of `10` is used).
80 ///
81 /// This field is intentionally separate from `backup_retention` to enforce
82 /// the snapshot retention isolation Crux constraint: snapshot and backup
83 /// lifecycles are managed independently.
84 pub snapshot_retention: Option<usize>,
85}
86
87impl Config {
88 /// Load configuration from the environment.
89 ///
90 /// Attempts to read `.mini-app-mcp.env` from the current directory first
91 /// (via [`dotenvy`]). Variables already set in the process environment take
92 /// precedence over values from the file (dotenvy's default behaviour).
93 ///
94 /// # Environment variables
95 ///
96 /// | Variable | Required | Description |
97 /// |---|---|---|
98 /// | `MINI_APP_SCHEMA` | Legacy mode only | Path to `schema.yaml` |
99 /// | `MINI_APP_DB` | Legacy mode only | Path to the SQLite database file |
100 /// | `MINI_APP_USER_DIR` | Optional | User-scope table directory (default `~/.mini-app/`) |
101 /// | `MINI_APP_PROJECT_DIR` | Optional | Project-scope table directory (default `./.mini-app/`) |
102 /// | `MINI_APP_BACKUP_RETENTION` | Optional | Number of backup pairs to keep (default `10`) |
103 /// | `MINI_APP_SNAPSHOT_RETENTION` | Optional | Number of snapshot files to keep (default `10`) |
104 ///
105 /// At least one of the legacy pair (`MINI_APP_SCHEMA` + `MINI_APP_DB`) or
106 /// a directory env must resolve to a usable table configuration. When
107 /// neither legacy env vars nor a discoverable directory exist the server
108 /// can still start (directory scan skips missing dirs with a warning).
109 ///
110 /// # Returns
111 ///
112 /// A [`Config`] on success. The presence of legacy fields vs directory
113 /// fields determines which mode the server operates in.
114 ///
115 /// # Errors
116 ///
117 /// Returns [`MiniAppError::Config`] only if an env var is present but
118 /// cannot be read (which is unusual — the standard env API only fails on
119 /// invalid UTF-8). Does **not** panic.
120 pub fn load() -> Result<Self, MiniAppError> {
121 // Best-effort: the env file is optional.
122 if let Err(e) = dotenvy::from_filename(".mini-app-mcp.env") {
123 tracing::debug!(error = %e, "no .mini-app-mcp.env file found, relying on process env");
124 }
125
126 // Legacy single-table env vars — both optional at the Config level;
127 // TableRegistry::mount_legacy enforces that both are present together.
128 let schema_path = env::var("MINI_APP_SCHEMA").ok().map(PathBuf::from);
129 let db_path = env::var("MINI_APP_DB").ok().map(PathBuf::from);
130
131 // User-scope dir: explicit env or default to ~/.mini-app/
132 let user_dir = match env::var("MINI_APP_USER_DIR") {
133 Ok(v) => Some(PathBuf::from(v)),
134 Err(_) => dirs::home_dir().map(|h| h.join(".mini-app")),
135 };
136
137 // Project-scope dir: explicit env or default to ./.mini-app/
138 let project_dir = match env::var("MINI_APP_PROJECT_DIR") {
139 Ok(v) => Some(PathBuf::from(v)),
140 Err(_) => Some(PathBuf::from(".mini-app")),
141 };
142
143 // Backup retention: explicit env or default to None (caller uses 10).
144 let backup_retention = env::var("MINI_APP_BACKUP_RETENTION")
145 .ok()
146 .and_then(|v| v.parse::<usize>().ok());
147
148 // Snapshot retention: explicit env or default to None (caller uses 10).
149 // Intentionally separate from backup_retention (Crux: retention isolation).
150 let snapshot_retention = env::var("MINI_APP_SNAPSHOT_RETENTION")
151 .ok()
152 .and_then(|v| v.parse::<usize>().ok());
153
154 Ok(Config {
155 schema_path,
156 db_path,
157 user_dir,
158 project_dir,
159 backup_retention,
160 snapshot_retention,
161 })
162 }
163
164 /// Returns `true` when both legacy single-table env vars (`MINI_APP_SCHEMA`
165 /// and `MINI_APP_DB`) are present in this config.
166 ///
167 /// # Returns
168 ///
169 /// `true` if and only if both `schema_path` and `db_path` are `Some`.
170 pub fn has_legacy_env(&self) -> bool {
171 self.schema_path.is_some() && self.db_path.is_some()
172 }
173
174 /// Returns the number of backup pairs to keep per table.
175 ///
176 /// Uses the value of `backup_retention` when set, otherwise defaults to
177 /// `10`. This default is documented in `MINI_APP_BACKUP_RETENTION`.
178 ///
179 /// # Returns
180 ///
181 /// The retention limit as a `usize`. Always at least `1` (a value of `0`
182 /// would delete all backups on every write).
183 pub fn backup_retention(&self) -> usize {
184 self.backup_retention.unwrap_or(10)
185 }
186
187 /// Returns the number of snapshot files to keep per table.
188 ///
189 /// Uses the value of `snapshot_retention` when set, otherwise defaults to
190 /// `10`. This default is documented in `MINI_APP_SNAPSHOT_RETENTION`.
191 ///
192 /// This getter is intentionally separate from [`backup_retention`] to
193 /// enforce snapshot retention isolation: snapshot and backup lifecycles are
194 /// managed via independent environment variables and independent retention
195 /// counters (Crux: snapshot retention isolation).
196 ///
197 /// # Returns
198 ///
199 /// The retention limit as a `usize`. Always at least `1` (a value of `0`
200 /// would delete all snapshots on every write).
201 ///
202 /// [`backup_retention`]: Config::backup_retention
203 pub fn snapshot_retention(&self) -> usize {
204 self.snapshot_retention.unwrap_or(10)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use std::sync::Mutex;
211
212 use super::*;
213
214 // Serialises all config tests that mutate env vars so they don't race with
215 // each other in a multi-threaded test runner.
216 static ENV_LOCK: Mutex<()> = Mutex::new(());
217
218 // Helper: lock ENV_LOCK, temporarily set env vars, run the closure, then
219 // restore. All config tests must use this helper.
220 // SAFETY: env var mutation is inherently racy; the lock ensures only one
221 // test is mutating the environment at a time.
222 fn with_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
223 let _guard = ENV_LOCK.lock().expect("env lock poisoned");
224
225 // Save originals
226 let saved: Vec<(&str, Option<String>)> =
227 vars.iter().map(|(k, _)| (*k, env::var(*k).ok())).collect();
228
229 // Apply
230 for (k, v) in vars {
231 match v {
232 Some(val) => unsafe { env::set_var(k, val) },
233 None => unsafe { env::remove_var(k) },
234 }
235 }
236
237 f();
238
239 // Restore
240 for (k, original) in &saved {
241 match original {
242 Some(val) => unsafe { env::set_var(k, val) },
243 None => unsafe { env::remove_var(k) },
244 }
245 }
246 }
247
248 // T1: happy-path — legacy env vars set, schema_path / db_path populated
249 #[test]
250 fn load_success_legacy_vars() {
251 with_env(
252 &[
253 ("MINI_APP_SCHEMA", Some("./schema.yaml")),
254 ("MINI_APP_DB", Some("./mini-app.db")),
255 ("MINI_APP_USER_DIR", None),
256 ("MINI_APP_PROJECT_DIR", None),
257 ],
258 || {
259 let cfg = Config::load().expect("should succeed with legacy vars set");
260 assert_eq!(cfg.schema_path, Some(PathBuf::from("./schema.yaml")));
261 assert_eq!(cfg.db_path, Some(PathBuf::from("./mini-app.db")));
262 assert!(cfg.has_legacy_env());
263 },
264 );
265 }
266
267 // T1: happy-path — user_dir / project_dir explicit env vars
268 #[test]
269 fn load_success_dir_env_vars() {
270 with_env(
271 &[
272 ("MINI_APP_SCHEMA", None),
273 ("MINI_APP_DB", None),
274 ("MINI_APP_USER_DIR", Some("/tmp/user-tables")),
275 ("MINI_APP_PROJECT_DIR", Some("/tmp/project-tables")),
276 ],
277 || {
278 let cfg = Config::load().expect("should succeed with dir vars set");
279 assert_eq!(cfg.schema_path, None);
280 assert_eq!(cfg.db_path, None);
281 assert_eq!(cfg.user_dir, Some(PathBuf::from("/tmp/user-tables")));
282 assert_eq!(cfg.project_dir, Some(PathBuf::from("/tmp/project-tables")));
283 assert!(!cfg.has_legacy_env());
284 },
285 );
286 }
287
288 // T1: happy-path — both legacy and dir vars set simultaneously
289 #[test]
290 fn load_success_both_legacy_and_dir_vars() {
291 with_env(
292 &[
293 ("MINI_APP_SCHEMA", Some("./schema.yaml")),
294 ("MINI_APP_DB", Some("./mini-app.db")),
295 ("MINI_APP_USER_DIR", Some("/tmp/user-tables")),
296 ("MINI_APP_PROJECT_DIR", Some("/tmp/project-tables")),
297 ],
298 || {
299 let cfg = Config::load().expect("should succeed with all vars set");
300 assert_eq!(cfg.schema_path, Some(PathBuf::from("./schema.yaml")));
301 assert_eq!(cfg.db_path, Some(PathBuf::from("./mini-app.db")));
302 assert_eq!(cfg.user_dir, Some(PathBuf::from("/tmp/user-tables")));
303 assert_eq!(cfg.project_dir, Some(PathBuf::from("/tmp/project-tables")));
304 assert!(cfg.has_legacy_env());
305 },
306 );
307 }
308
309 // T2: edge case — no env vars at all; load still succeeds (no required vars)
310 #[test]
311 fn load_no_env_vars_succeeds() {
312 with_env(
313 &[
314 ("MINI_APP_SCHEMA", None),
315 ("MINI_APP_DB", None),
316 ("MINI_APP_USER_DIR", None),
317 ("MINI_APP_PROJECT_DIR", None),
318 ],
319 || {
320 // Config::load() always succeeds; lack of env vars is OK —
321 // the TableRegistry will handle missing dirs gracefully.
322 let cfg = Config::load().expect("load must not fail with no env vars");
323 assert_eq!(cfg.schema_path, None);
324 assert_eq!(cfg.db_path, None);
325 assert!(!cfg.has_legacy_env());
326 // project_dir defaults to Some(./.mini-app/) when env is absent
327 assert_eq!(cfg.project_dir, Some(PathBuf::from(".mini-app")));
328 },
329 );
330 }
331
332 // T2: edge case — only MINI_APP_SCHEMA set (no DB), has_legacy_env is false
333 #[test]
334 fn load_only_schema_env_has_legacy_env_false() {
335 with_env(
336 &[
337 ("MINI_APP_SCHEMA", Some("./schema.yaml")),
338 ("MINI_APP_DB", None),
339 ],
340 || {
341 let cfg = Config::load().expect("load must not fail");
342 assert_eq!(cfg.schema_path, Some(PathBuf::from("./schema.yaml")));
343 assert_eq!(cfg.db_path, None);
344 assert!(!cfg.has_legacy_env());
345 },
346 );
347 }
348
349 // T3: error path — load does not panic with no env file
350 #[test]
351 fn load_does_not_panic_with_no_env_file() {
352 // Even without a .mini-app-mcp.env file, load() must not panic.
353 with_env(
354 &[
355 ("MINI_APP_SCHEMA", None),
356 ("MINI_APP_DB", None),
357 ("MINI_APP_USER_DIR", None),
358 ("MINI_APP_PROJECT_DIR", None),
359 ],
360 || {
361 // Must not panic — only returns Err on truly invalid env state.
362 let result = Config::load();
363 // With no env vars, Config::load() succeeds (empty config).
364 assert!(result.is_ok());
365 },
366 );
367 }
368
369 // Backward-compatibility: original load_success test behaviour preserved
370 #[test]
371 fn load_success() {
372 with_env(
373 &[
374 ("MINI_APP_SCHEMA", Some("./schema.yaml")),
375 ("MINI_APP_DB", Some("./mini-app.db")),
376 ],
377 || {
378 let cfg = Config::load().expect("should succeed with both vars set");
379 assert_eq!(cfg.schema_path, Some(PathBuf::from("./schema.yaml")));
380 assert_eq!(cfg.db_path, Some(PathBuf::from("./mini-app.db")));
381 },
382 );
383 }
384
385 // Backward-compatibility: missing MINI_APP_SCHEMA → schema_path is None
386 #[test]
387 fn load_missing_schema_returns_none() {
388 with_env(
389 &[
390 ("MINI_APP_SCHEMA", None),
391 ("MINI_APP_DB", Some("./mini-app.db")),
392 ],
393 || {
394 let cfg =
395 Config::load().expect("load must succeed even with MINI_APP_SCHEMA absent");
396 assert_eq!(cfg.schema_path, None);
397 assert!(!cfg.has_legacy_env());
398 },
399 );
400 }
401
402 // Backward-compatibility: missing MINI_APP_DB → db_path is None
403 #[test]
404 fn load_missing_db_returns_none() {
405 with_env(
406 &[
407 ("MINI_APP_SCHEMA", Some("./schema.yaml")),
408 ("MINI_APP_DB", None),
409 ],
410 || {
411 let cfg = Config::load().expect("load must succeed even with MINI_APP_DB absent");
412 assert_eq!(cfg.db_path, None);
413 assert!(!cfg.has_legacy_env());
414 },
415 );
416 }
417
418 // T1: backup_retention defaults to 10 when env var is absent
419 #[test]
420 fn backup_retention_defaults_to_10() {
421 with_env(&[("MINI_APP_BACKUP_RETENTION", None)], || {
422 let cfg = Config::load().expect("load must succeed");
423 assert_eq!(cfg.backup_retention(), 10);
424 assert_eq!(cfg.backup_retention, None);
425 });
426 }
427
428 // T1: backup_retention reads from env var when set
429 #[test]
430 fn backup_retention_reads_from_env() {
431 with_env(&[("MINI_APP_BACKUP_RETENTION", Some("5"))], || {
432 let cfg = Config::load().expect("load must succeed");
433 assert_eq!(cfg.backup_retention, Some(5));
434 assert_eq!(cfg.backup_retention(), 5);
435 });
436 }
437
438 // T2: backup_retention with non-parsable env var falls back to None / default 10
439 #[test]
440 fn backup_retention_non_parsable_env_falls_back_to_default() {
441 with_env(
442 &[("MINI_APP_BACKUP_RETENTION", Some("not-a-number"))],
443 || {
444 let cfg = Config::load().expect("load must succeed even with bad retention value");
445 assert_eq!(cfg.backup_retention, None);
446 assert_eq!(cfg.backup_retention(), 10);
447 },
448 );
449 }
450
451 // T3: backup_retention getter always returns value >= 1 even when field is None
452 #[test]
453 fn backup_retention_getter_never_zero() {
454 let cfg = Config {
455 schema_path: None,
456 db_path: None,
457 user_dir: None,
458 project_dir: None,
459 backup_retention: None,
460 snapshot_retention: None,
461 };
462 assert!(cfg.backup_retention() >= 1);
463 }
464
465 // T1: snapshot_retention defaults to 10 when env var is absent
466 #[test]
467 fn snapshot_retention_defaults_to_10() {
468 with_env(&[("MINI_APP_SNAPSHOT_RETENTION", None)], || {
469 let cfg = Config::load().expect("load must succeed");
470 assert_eq!(cfg.snapshot_retention(), 10);
471 assert_eq!(cfg.snapshot_retention, None);
472 });
473 }
474
475 // T1: snapshot_retention reads from env var when set
476 #[test]
477 fn snapshot_retention_reads_from_env() {
478 with_env(&[("MINI_APP_SNAPSHOT_RETENTION", Some("5"))], || {
479 let cfg = Config::load().expect("load must succeed");
480 assert_eq!(cfg.snapshot_retention, Some(5));
481 assert_eq!(cfg.snapshot_retention(), 5);
482 });
483 }
484
485 // T2: snapshot_retention with non-parsable env var falls back to None / default 10
486 #[test]
487 fn snapshot_retention_non_parsable_env_falls_back_to_default() {
488 with_env(
489 &[("MINI_APP_SNAPSHOT_RETENTION", Some("not-a-number"))],
490 || {
491 let cfg = Config::load().expect("load must succeed even with bad retention value");
492 assert_eq!(cfg.snapshot_retention, None);
493 assert_eq!(cfg.snapshot_retention(), 10);
494 },
495 );
496 }
497
498 // T3: snapshot_retention getter always returns value >= 1 even when field is None
499 #[test]
500 fn snapshot_retention_getter_never_zero() {
501 let cfg = Config {
502 schema_path: None,
503 db_path: None,
504 user_dir: None,
505 project_dir: None,
506 backup_retention: None,
507 snapshot_retention: None,
508 };
509 assert!(cfg.snapshot_retention() >= 1);
510 }
511
512 // T3: snapshot_retention and backup_retention are independent — setting one
513 // does not affect the other (retention isolation).
514 #[test]
515 fn snapshot_retention_independent_from_backup_retention() {
516 with_env(
517 &[
518 ("MINI_APP_BACKUP_RETENTION", Some("3")),
519 ("MINI_APP_SNAPSHOT_RETENTION", Some("7")),
520 ],
521 || {
522 let cfg = Config::load().expect("load must succeed");
523 assert_eq!(cfg.backup_retention(), 3);
524 assert_eq!(cfg.snapshot_retention(), 7);
525 // The two getters must return different values to confirm isolation.
526 assert_ne!(cfg.backup_retention(), cfg.snapshot_retention());
527 },
528 );
529 }
530}