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