Skip to main content

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}