Skip to main content

mlua_swarm_server/
config.rs

1//! Server config file support (`~/.mse/config.toml` by default).
2//!
3//! Resolution precedence: **CLI flag > config file > built-in default**.
4//! CLI flags are represented as `Option<T>` on the `main.rs` `Args` struct
5//! (rather than relying on `clap`'s `default_value`) so "not passed" can be
6//! distinguished from "matches the default value"; [`resolve`] performs the
7//! actual 3-way merge.
8//!
9//! Design rationale: the config file becomes the lifecycle SoT; the launchd
10//! plist's `ProgramArguments` stays fixed at `<server-bin> --config <path>`,
11//! so changing settings = editing the file + restarting, not editing the plist.
12
13use serde::Deserialize;
14use std::net::SocketAddr;
15use std::path::{Path, PathBuf};
16
17/// Default config path, `~/.mse/config.toml`. Falls back to a relative path
18/// literal when `$HOME` is unset (best-effort; dev-only edge case).
19pub fn default_config_path() -> PathBuf {
20    match std::env::var("HOME") {
21        Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
22        Err(_) => PathBuf::from(".mse/config.toml"),
23    }
24}
25
26/// Default `BlueprintStore` root, `~/.mse/store`. Same `$HOME` fallback
27/// rule as [`default_config_path`]. The store is always git-backed;
28/// config/CLI only override *where* the repos live, never whether they
29/// persist.
30pub fn default_store_path() -> PathBuf {
31    match std::env::var("HOME") {
32        Ok(home) => PathBuf::from(home).join(".mse").join("store"),
33        Err(_) => PathBuf::from(".mse/store"),
34    }
35}
36
37/// TOML config schema. All fields are optional — a missing field falls back
38/// to the CLI-supplied value or the built-in default at [`resolve`] time.
39/// Unknown fields are a hard error (`deny_unknown_fields`; typo guard).
40#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct FileConfig {
43    /// Listen address string (e.g. `"127.0.0.1:7777"`), parsed at [`resolve`] time.
44    pub bind: Option<String>,
45    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
46    pub enable_enhance_flow: Option<bool>,
47    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
48    pub blueprint_ref_base: Option<PathBuf>,
49    /// Root path for the git-backed `BlueprintStore` (when using the git2 backend).
50    pub git_store_path: Option<PathBuf>,
51    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
52    /// back to `InMemoryIssueStore` (process-volatile).
53    pub issue_store_path: Option<PathBuf>,
54    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
55    /// `None` = fall back to `InMemoryEnhanceSettingStore` (process-volatile).
56    pub enhance_setting_store_path: Option<PathBuf>,
57    /// Path to the SQLite database file backing the `EnhanceLogStore`.
58    /// `None` = fall back to `InMemoryEnhanceLogStore` (process-volatile).
59    pub enhance_log_store_path: Option<PathBuf>,
60    /// Path to the SQLite database file backing the `OutputStore`.
61    /// `None` = fall back to `InMemoryOutputStore` (process-volatile).
62    pub output_store_path: Option<PathBuf>,
63    /// Seed blueprint id used in combined-mode default routing.
64    pub seed_blueprint_id: Option<String>,
65    /// snake_case `AgentKind` literal (`operator` / `agent_block` / `rust_fn` /
66    /// `lua` / `subprocess`). Validated by the caller after [`resolve`].
67    pub default_agent_kind: Option<String>,
68    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
69    pub token_secret: Option<String>,
70}
71
72/// CLI-side overrides. Mirrors [`FileConfig`] field-for-field. Kept as a
73/// separate type (rather than reusing `clap::Args` directly) so this module
74/// stays independent of the `clap` derive on `main.rs::Args`.
75#[derive(Debug, Default, Clone)]
76pub struct CliOverrides {
77    /// `--bind` value, unparsed (mirrors [`FileConfig::bind`]).
78    pub bind: Option<String>,
79    /// `--enable-enhance-flow` flag.
80    pub enable_enhance_flow: Option<bool>,
81    /// `--blueprint-ref-base` value.
82    pub blueprint_ref_base: Option<PathBuf>,
83    /// `--git-store-path` value.
84    pub git_store_path: Option<PathBuf>,
85    /// `--issue-store-path` value (mirrors [`FileConfig::issue_store_path`]).
86    pub issue_store_path: Option<PathBuf>,
87    /// `--enhance-setting-store-path` value.
88    pub enhance_setting_store_path: Option<PathBuf>,
89    /// `--enhance-log-store-path` value.
90    pub enhance_log_store_path: Option<PathBuf>,
91    /// `--output-store-path` value.
92    pub output_store_path: Option<PathBuf>,
93    /// `--seed-blueprint-id` value.
94    pub seed_blueprint_id: Option<String>,
95    /// `--default-agent-kind` value (snake_case `AgentKind` literal, unvalidated).
96    pub default_agent_kind: Option<String>,
97    /// `--token-secret` value.
98    pub token_secret: Option<String>,
99}
100
101/// Fully resolved config — every field has the built-in default applied.
102#[derive(Debug, Clone, PartialEq)]
103pub struct ResolvedConfig {
104    /// Parsed listen address for the server to bind to.
105    pub bind: SocketAddr,
106    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
107    pub enable_enhance_flow: bool,
108    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
109    pub blueprint_ref_base: Option<PathBuf>,
110    /// Root path for the git-backed `BlueprintStore`. Always set — defaults
111    /// to [`default_store_path`] (`~/.mse/store`) when neither CLI nor config
112    /// file provides one.
113    pub git_store_path: PathBuf,
114    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
115    /// back to `InMemoryIssueStore` (process-volatile).
116    pub issue_store_path: Option<PathBuf>,
117    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
118    /// `None` = `InMemoryEnhanceSettingStore`.
119    pub enhance_setting_store_path: Option<PathBuf>,
120    /// Path to the SQLite database file backing the `EnhanceLogStore`.
121    /// `None` = `InMemoryEnhanceLogStore`.
122    pub enhance_log_store_path: Option<PathBuf>,
123    /// Path to the SQLite database file backing the `OutputStore`.
124    /// `None` = `InMemoryOutputStore`.
125    pub output_store_path: Option<PathBuf>,
126    /// Seed blueprint id used in combined-mode default routing.
127    pub seed_blueprint_id: String,
128    /// snake_case `AgentKind` literal, unvalidated. `None` = caller applies
129    /// the schema-impl `Default` (`Operator`).
130    pub default_agent_kind: Option<String>,
131    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
132    pub token_secret: Option<String>,
133}
134
135impl Default for ResolvedConfig {
136    fn default() -> Self {
137        Self {
138            bind: default_bind(),
139            enable_enhance_flow: false,
140            blueprint_ref_base: None,
141            git_store_path: default_store_path(),
142            issue_store_path: None,
143            enhance_setting_store_path: None,
144            enhance_log_store_path: None,
145            output_store_path: None,
146            seed_blueprint_id: "main".into(),
147            default_agent_kind: None,
148            token_secret: None,
149        }
150    }
151}
152
153fn default_bind() -> SocketAddr {
154    "127.0.0.1:7777"
155        .parse()
156        .expect("literal default bind must parse")
157}
158
159/// Load + parse a TOML config file. A missing file resolves to
160/// `Ok(FileConfig::default())` (built-in default fallback, per module doc);
161/// any other IO error or a parse error is `Err` — a malformed config file
162/// must not be silently ignored (fail-loud).
163pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
164    match std::fs::read_to_string(path) {
165        Ok(text) => toml::from_str(&text)
166            .map_err(|e| format!("config file {} parse error: {e}", path.display())),
167        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
168        Err(e) => Err(format!("config file {} read error: {e}", path.display())),
169    }
170}
171
172/// 3-way merge: CLI > file > built-in default. `bind` requires a parse step
173/// (both CLI and file carry it as a string); a parse error surfaces as `Err`.
174pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
175    let default = ResolvedConfig::default();
176
177    let bind = match cli.bind.or(file.bind) {
178        Some(s) => s
179            .parse::<SocketAddr>()
180            .map_err(|e| format!("bind {s:?}: {e}"))?,
181        None => default.bind,
182    };
183
184    Ok(ResolvedConfig {
185        bind,
186        enable_enhance_flow: cli
187            .enable_enhance_flow
188            .or(file.enable_enhance_flow)
189            .unwrap_or(default.enable_enhance_flow),
190        blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
191        git_store_path: cli
192            .git_store_path
193            .or(file.git_store_path)
194            .unwrap_or_else(default_store_path),
195        issue_store_path: cli.issue_store_path.or(file.issue_store_path),
196        enhance_setting_store_path: cli
197            .enhance_setting_store_path
198            .or(file.enhance_setting_store_path),
199        enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
200        output_store_path: cli.output_store_path.or(file.output_store_path),
201        seed_blueprint_id: cli
202            .seed_blueprint_id
203            .or(file.seed_blueprint_id)
204            .unwrap_or(default.seed_blueprint_id),
205        default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
206        token_secret: cli.token_secret.or(file.token_secret),
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn resolve_cli_flag_wins_over_file_and_default() {
216        let cli = CliOverrides {
217            bind: Some("127.0.0.1:9999".into()),
218            ..Default::default()
219        };
220        let file = FileConfig {
221            bind: Some("127.0.0.1:8888".into()),
222            ..Default::default()
223        };
224        let resolved = resolve(cli, file).expect("resolve");
225        assert_eq!(
226            resolved.bind,
227            "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
228        );
229    }
230
231    #[test]
232    fn resolve_file_wins_over_built_in_default_when_cli_absent() {
233        let cli = CliOverrides::default();
234        let file = FileConfig {
235            seed_blueprint_id: Some("from-file".into()),
236            enable_enhance_flow: Some(true),
237            ..Default::default()
238        };
239        let resolved = resolve(cli, file).expect("resolve");
240        assert_eq!(resolved.seed_blueprint_id, "from-file");
241        assert!(resolved.enable_enhance_flow);
242    }
243
244    #[test]
245    fn resolve_built_in_default_when_cli_and_file_absent() {
246        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
247        assert_eq!(resolved.bind, default_bind());
248        assert_eq!(resolved.seed_blueprint_id, "main");
249        assert!(!resolved.enable_enhance_flow);
250        assert_eq!(resolved.git_store_path, default_store_path());
251    }
252
253    #[test]
254    fn resolve_git_store_path_file_overrides_default_location() {
255        let file = FileConfig {
256            git_store_path: Some(PathBuf::from("/tmp/custom-store")),
257            ..Default::default()
258        };
259        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
260        assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
261    }
262
263    #[test]
264    fn resolve_bind_parse_error_is_propagated() {
265        let cli = CliOverrides {
266            bind: Some("not-a-valid-addr".into()),
267            ..Default::default()
268        };
269        let err = resolve(cli, FileConfig::default()).unwrap_err();
270        assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
271    }
272
273    #[test]
274    fn load_file_config_rejects_unknown_fields() {
275        let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
276        let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
277        let msg = err.to_string();
278        assert!(
279            msg.contains("typo_field") || msg.contains("unknown field"),
280            "unexpected error message: {msg}"
281        );
282    }
283
284    #[test]
285    fn load_file_config_missing_file_falls_back_to_default() {
286        let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
287        let cfg = load_file_config(path).expect("missing file should not error");
288        assert_eq!(cfg, FileConfig::default());
289    }
290
291    #[test]
292    fn load_file_config_parses_valid_toml() {
293        let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
294        std::fs::create_dir_all(&dir).expect("create tmp dir");
295        let path = dir.join("config.toml");
296        std::fs::write(
297            &path,
298            "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
299        )
300        .expect("write tmp config");
301        let cfg = load_file_config(&path).expect("parse tmp config");
302        assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
303        assert_eq!(cfg.enable_enhance_flow, Some(true));
304        let _ = std::fs::remove_dir_all(&dir);
305    }
306}