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 mlua_swarm::core::config::CheckPolicy;
14use mlua_swarm::LegacyWorkerBindingPolicy;
15use serde::Deserialize;
16use std::net::SocketAddr;
17use std::path::{Path, PathBuf};
18
19/// Default config path, `~/.mse/config.toml`. Falls back to a relative path
20/// literal when `$HOME` is unset (best-effort; dev-only edge case).
21pub fn default_config_path() -> PathBuf {
22    match std::env::var("HOME") {
23        Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
24        Err(_) => PathBuf::from(".mse/config.toml"),
25    }
26}
27
28/// Default `BlueprintStore` root, `~/.mse/store`. Same `$HOME` fallback
29/// rule as [`default_config_path`]. The store is always git-backed;
30/// config/CLI only override *where* the repos live, never whether they
31/// persist.
32pub fn default_store_path() -> PathBuf {
33    match std::env::var("HOME") {
34        Ok(home) => PathBuf::from(home).join(".mse").join("store"),
35        Err(_) => PathBuf::from(".mse/store"),
36    }
37}
38
39/// Default `TaskStore` SQLite path, `~/.mse/store/task.sqlite` (issue
40/// #35 ST1 — persist-by-default). Same `$HOME` fallback as
41/// [`default_config_path`].
42pub fn default_task_store_path() -> PathBuf {
43    match std::env::var("HOME") {
44        Ok(home) => PathBuf::from(home)
45            .join(".mse")
46            .join("store")
47            .join("task.sqlite"),
48        Err(_) => PathBuf::from(".mse/store/task.sqlite"),
49    }
50}
51
52/// Default `RunStore` SQLite path, `~/.mse/store/run.sqlite`. Sibling of
53/// [`default_task_store_path`].
54pub fn default_run_store_path() -> PathBuf {
55    match std::env::var("HOME") {
56        Ok(home) => PathBuf::from(home)
57            .join(".mse")
58            .join("store")
59            .join("run.sqlite"),
60        Err(_) => PathBuf::from(".mse/store/run.sqlite"),
61    }
62}
63
64/// Default `ReplayStore` SQLite path, `~/.mse/store/replay.sqlite`. Sibling
65/// of [`default_run_store_path`] — persisted by default so a restart can
66/// consult the replay log (see `mlua_swarm::store::replay` module doc).
67pub fn default_replay_store_path() -> PathBuf {
68    match std::env::var("HOME") {
69        Ok(home) => PathBuf::from(home)
70            .join(".mse")
71            .join("store")
72            .join("replay.sqlite"),
73        Err(_) => PathBuf::from(".mse/store/replay.sqlite"),
74    }
75}
76
77/// TOML config schema. All fields are optional — a missing field falls back
78/// to the CLI-supplied value or the built-in default at [`resolve`] time.
79/// Unknown fields are a hard error (`deny_unknown_fields`; typo guard).
80#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct FileConfig {
83    /// Listen address string (e.g. `"127.0.0.1:7777"`), parsed at [`resolve`] time.
84    pub bind: Option<String>,
85    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
86    pub enable_enhance_flow: Option<bool>,
87    /// Migration gate for deprecated `profile.worker_binding` Runner fallback.
88    pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
89    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
90    pub blueprint_ref_base: Option<PathBuf>,
91    /// Additional dirs (tier 5 of the include cascade — see
92    /// `mlua-swarm-compile::ResolveConfig`) searched after the CLI
93    /// `--include` list and before the bundled default. `None` = no
94    /// server-config includes.
95    pub blueprint_ref_includes: Option<Vec<PathBuf>>,
96    /// Server-side strict-embed switch (design table row 3 — the
97    /// strict opt-in for the register layer). When `true`, `POST
98    /// /v1/blueprints/:id` refuses any raw body that still carries
99    /// `$file` / `$agent_md` refs (returns 400 with a hint pointing at
100    /// `mse bp build --strict-embed`), so ref resolution is pushed onto
101    /// the client. Default `false` = the server runs the linker itself
102    /// (backward-compat). `None` = fall back to the built-in default
103    /// `false`.
104    pub blueprint_strict_embed: Option<bool>,
105    /// Root path for the git-backed `BlueprintStore` (when using the git2 backend).
106    pub git_store_path: Option<PathBuf>,
107    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
108    /// back to `InMemoryIssueStore` (process-volatile).
109    pub issue_store_path: Option<PathBuf>,
110    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
111    /// `None` = fall back to `InMemoryEnhanceSettingStore` (process-volatile).
112    pub enhance_setting_store_path: Option<PathBuf>,
113    /// Path to the SQLite database file backing the `EnhanceLogStore`.
114    /// `None` = fall back to `InMemoryEnhanceLogStore` (process-volatile).
115    pub enhance_log_store_path: Option<PathBuf>,
116    /// Path to the SQLite database file backing the `OutputStore`.
117    /// `None` = fall back to `InMemoryOutputStore` (process-volatile).
118    pub output_store_path: Option<PathBuf>,
119    /// Path to the SQLite database file backing the `TaskStore` (issue #13
120    /// ID-hierarchy `POST /v1/tasks` work-item records). `None` = fall back
121    /// to `InMemoryTaskStore` (process-volatile).
122    pub task_store_path: Option<PathBuf>,
123    /// Path to the SQLite database file backing the `RunStore` (one kick of
124    /// a Task). `None` = fall back to `InMemoryRunStore` (process-volatile).
125    pub run_store_path: Option<PathBuf>,
126    /// Path to the SQLite database file backing the `ReplayStore` (per-run
127    /// Ctx-snapshot + step-output log). Persisted by default even when
128    /// omitted (sibling of `run_store_path`): resolves to
129    /// `~/.mse/store/replay.sqlite` unless `ephemeral` is set. `None` = fall
130    /// back to `InMemoryReplayStore` (process-volatile).
131    pub replay_store_path: Option<PathBuf>,
132    /// Opt-out flag: when `true`, restores the InMemory default for
133    /// `task_store_path`/`run_store_path` even though the built-in default
134    /// (issue #35 ST1) is now to persist. Has no effect when an explicit
135    /// `task_store_path`/`run_store_path` (CLI or file) is set — explicit
136    /// paths always win. `None` = fall back to `false`.
137    pub ephemeral: Option<bool>,
138    /// Seed blueprint id used in combined-mode default routing.
139    pub seed_blueprint_id: Option<String>,
140    /// snake_case `AgentKind` literal (`operator` / `agent_block` / `rust_fn` /
141    /// `lua` / `subprocess`). Validated by the caller after [`resolve`].
142    pub default_agent_kind: Option<String>,
143    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
144    pub token_secret: Option<String>,
145    /// Ceiling (seconds) for the `POST /v1/tasks` synchronous launch await
146    /// (GH #33 Guard 2). Overridable per-request via `TaskLaunchRequest
147    /// .timeout_secs`; this is the server-wide fallback when the request
148    /// omits it. `None` = fall back to the built-in default (3600s / 60 min, see
149    /// [`ResolvedConfig`]'s `Default` impl).
150    pub sync_timeout_secs: Option<u64>,
151    /// Server-wide [`mlua_swarm::core::config::CheckPolicy`] — governs how
152    /// submit-time projection sinks
153    /// (`Engine::materialize_final_submission` /
154    /// `Engine::materialize_artifact_submission`) react to fail-open
155    /// conditions (missing `work_dir`/`project_root`, `OutputStore` write
156    /// error, adapter materialize error, state lookup error). `None`
157    /// falls back to the built-in default `Warn` (byte-identical to
158    /// pre-`CheckPolicy` behaviour); `"silent"` skips both the log and
159    /// error, `"strict"` returns
160    /// `EngineError::CheckPolicyStrict` so a caller who has opted in can
161    /// fail the step / launch fast. Per-task override
162    /// (`TaskSpec.check_policy`) wins over this server-wide value.
163    pub check_policy: Option<CheckPolicy>,
164}
165
166/// CLI-side overrides. Mirrors [`FileConfig`] field-for-field. Kept as a
167/// separate type (rather than reusing `clap::Args` directly) so this module
168/// stays independent of the `clap` derive on `main.rs::Args`.
169#[derive(Debug, Default, Clone)]
170pub struct CliOverrides {
171    /// `--bind` value, unparsed (mirrors [`FileConfig::bind`]).
172    pub bind: Option<String>,
173    /// `--enable-enhance-flow` flag.
174    pub enable_enhance_flow: Option<bool>,
175    /// `--legacy-worker-binding-policy` value.
176    pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
177    /// `--blueprint-ref-base` value.
178    pub blueprint_ref_base: Option<PathBuf>,
179    /// `--include` values (repeatable). Merged with the file config's
180    /// `blueprint_ref_includes` (CLI wins on conflict — see [`resolve`]).
181    pub blueprint_ref_includes: Vec<PathBuf>,
182    /// `--blueprint-strict-embed` flag (mirrors
183    /// [`FileConfig::blueprint_strict_embed`]).
184    pub blueprint_strict_embed: Option<bool>,
185    /// `--git-store-path` value.
186    pub git_store_path: Option<PathBuf>,
187    /// `--issue-store-path` value (mirrors [`FileConfig::issue_store_path`]).
188    pub issue_store_path: Option<PathBuf>,
189    /// `--enhance-setting-store-path` value.
190    pub enhance_setting_store_path: Option<PathBuf>,
191    /// `--enhance-log-store-path` value.
192    pub enhance_log_store_path: Option<PathBuf>,
193    /// `--output-store-path` value.
194    pub output_store_path: Option<PathBuf>,
195    /// `--task-store-path` value (mirrors [`FileConfig::task_store_path`]).
196    pub task_store_path: Option<PathBuf>,
197    /// `--run-store-path` value (mirrors [`FileConfig::run_store_path`]).
198    pub run_store_path: Option<PathBuf>,
199    /// `--replay-store-path` value (mirrors [`FileConfig::replay_store_path`]).
200    pub replay_store_path: Option<PathBuf>,
201    /// `--ephemeral` flag (mirrors [`FileConfig::ephemeral`]).
202    pub ephemeral: Option<bool>,
203    /// `--seed-blueprint-id` value.
204    pub seed_blueprint_id: Option<String>,
205    /// `--default-agent-kind` value (snake_case `AgentKind` literal, unvalidated).
206    pub default_agent_kind: Option<String>,
207    /// `--token-secret` value.
208    pub token_secret: Option<String>,
209    /// `--sync-timeout-secs` value (mirrors [`FileConfig::sync_timeout_secs`]).
210    pub sync_timeout_secs: Option<u64>,
211    /// `--check-policy` value (mirrors [`FileConfig::check_policy`]).
212    /// Parsed at the caller (`serve.rs`) before landing here — invalid
213    /// tokens are rejected before this struct is ever constructed.
214    pub check_policy: Option<CheckPolicy>,
215}
216
217/// Fully resolved config — every field has the built-in default applied.
218#[derive(Debug, Clone, PartialEq)]
219pub struct ResolvedConfig {
220    /// Parsed listen address for the server to bind to.
221    pub bind: SocketAddr,
222    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
223    pub enable_enhance_flow: bool,
224    /// Migration gate for fresh Blueprint declarations.
225    pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
226    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
227    pub blueprint_ref_base: Option<PathBuf>,
228    /// Merged include list (CLI `--include` first, then file
229    /// `blueprint_ref_includes`) — tier 4+5 of the include cascade.
230    /// Always set (may be empty).
231    pub blueprint_ref_includes: Vec<PathBuf>,
232    /// Server-side strict-embed switch (design table row 3). Always
233    /// set — defaults to `false` when neither CLI nor config file
234    /// provides one (backward-compat: the server runs the linker
235    /// itself). When `true`, `POST /v1/blueprints/:id` refuses raw
236    /// bodies that still carry `$file` / `$agent_md` refs.
237    pub blueprint_strict_embed: bool,
238    /// Root path for the git-backed `BlueprintStore`. Always set — defaults
239    /// to [`default_store_path`] (`~/.mse/store`) when neither CLI nor config
240    /// file provides one.
241    pub git_store_path: PathBuf,
242    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
243    /// back to `InMemoryIssueStore` (process-volatile).
244    pub issue_store_path: Option<PathBuf>,
245    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
246    /// `None` = `InMemoryEnhanceSettingStore`.
247    pub enhance_setting_store_path: Option<PathBuf>,
248    /// Path to the SQLite database file backing the `EnhanceLogStore`.
249    /// `None` = `InMemoryEnhanceLogStore`.
250    pub enhance_log_store_path: Option<PathBuf>,
251    /// Path to the SQLite database file backing the `OutputStore`.
252    /// `None` = `InMemoryOutputStore`.
253    pub output_store_path: Option<PathBuf>,
254    /// Path to the SQLite database file backing the `TaskStore`.
255    /// `None` = `InMemoryTaskStore`.
256    pub task_store_path: Option<PathBuf>,
257    /// Path to the SQLite database file backing the `RunStore`.
258    /// `None` = `InMemoryRunStore`.
259    pub run_store_path: Option<PathBuf>,
260    /// Path to the SQLite database file backing the `ReplayStore`.
261    /// `None` = `InMemoryReplayStore`.
262    pub replay_store_path: Option<PathBuf>,
263    /// Seed blueprint id used in combined-mode default routing.
264    pub seed_blueprint_id: String,
265    /// snake_case `AgentKind` literal, unvalidated. `None` = caller applies
266    /// the schema-impl `Default` (`Operator`).
267    pub default_agent_kind: Option<String>,
268    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
269    pub token_secret: Option<String>,
270    /// Ceiling (seconds) for the `POST /v1/tasks` synchronous launch await
271    /// (GH #33 Guard 2). Always set — defaults to 3600s / 60 min (see
272    /// [`default_sync_timeout_secs`]) when neither CLI nor config file
273    /// provides one. A per-request `TaskLaunchRequest.timeout_secs`
274    /// override, when present, takes priority over this server-wide value.
275    pub sync_timeout_secs: u64,
276    /// Server-wide [`mlua_swarm::core::config::CheckPolicy`]. Always set
277    /// — defaults to `CheckPolicy::Warn` (byte-identical to the
278    /// pre-`CheckPolicy` fail-open behaviour) when neither CLI nor config
279    /// file provides one. Per-task `TaskSpec.check_policy` (set via
280    /// caller code — HTTP request per-launch override wiring is a
281    /// follow-up) takes priority over this server-wide value.
282    pub check_policy: CheckPolicy,
283}
284
285impl Default for ResolvedConfig {
286    fn default() -> Self {
287        Self {
288            bind: default_bind(),
289            enable_enhance_flow: false,
290            legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
291            blueprint_ref_base: None,
292            blueprint_ref_includes: Vec::new(),
293            blueprint_strict_embed: false,
294            git_store_path: default_store_path(),
295            issue_store_path: None,
296            enhance_setting_store_path: None,
297            enhance_log_store_path: None,
298            output_store_path: None,
299            task_store_path: None,
300            run_store_path: None,
301            replay_store_path: None,
302            seed_blueprint_id: "main".into(),
303            default_agent_kind: None,
304            token_secret: None,
305            sync_timeout_secs: default_sync_timeout_secs(),
306            check_policy: CheckPolicy::default(),
307        }
308    }
309}
310
311/// Built-in default sync-launch timeout ceiling (GH #33 Guard 2), seconds.
312/// 3600s / 60 min — sized for LLM-driven agent flows where individual
313/// spawns routinely take 60-180s and full phases run 20-40 min. The
314/// previous 300s ceiling under-shot the primary workload; users hitting
315/// it were legitimate long-running runs, not stuck ones. Callers who
316/// want faster fail-loud can override per-request
317/// (`TaskLaunchRequest.timeout_secs`) or server-wide (config or CLI).
318/// GH #39.
319pub fn default_sync_timeout_secs() -> u64 {
320    3600
321}
322
323fn default_bind() -> SocketAddr {
324    "127.0.0.1:7777"
325        .parse()
326        .expect("literal default bind must parse")
327}
328
329/// Load + parse a TOML config file. A missing file resolves to
330/// `Ok(FileConfig::default())` (built-in default fallback, per module doc);
331/// any other IO error or a parse error is `Err` — a malformed config file
332/// must not be silently ignored (fail-loud).
333pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
334    match std::fs::read_to_string(path) {
335        Ok(text) => toml::from_str(&text)
336            .map_err(|e| format!("config file {} parse error: {e}", path.display())),
337        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
338        Err(e) => Err(format!("config file {} read error: {e}", path.display())),
339    }
340}
341
342/// 3-way merge: CLI > file > built-in default. `bind` requires a parse step
343/// (both CLI and file carry it as a string); a parse error surfaces as `Err`.
344pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
345    let default = ResolvedConfig::default();
346
347    let bind = match cli.bind.or(file.bind) {
348        Some(s) => s
349            .parse::<SocketAddr>()
350            .map_err(|e| format!("bind {s:?}: {e}"))?,
351        None => default.bind,
352    };
353
354    let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
355
356    Ok(ResolvedConfig {
357        bind,
358        enable_enhance_flow: cli
359            .enable_enhance_flow
360            .or(file.enable_enhance_flow)
361            .unwrap_or(default.enable_enhance_flow),
362        legacy_worker_binding_policy: cli
363            .legacy_worker_binding_policy
364            .or(file.legacy_worker_binding_policy)
365            .unwrap_or(default.legacy_worker_binding_policy),
366        blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
367        blueprint_ref_includes: {
368            let mut merged = cli.blueprint_ref_includes;
369            merged.extend(file.blueprint_ref_includes.unwrap_or_default());
370            merged
371        },
372        blueprint_strict_embed: cli
373            .blueprint_strict_embed
374            .or(file.blueprint_strict_embed)
375            .unwrap_or(default.blueprint_strict_embed),
376        git_store_path: cli
377            .git_store_path
378            .or(file.git_store_path)
379            .unwrap_or_else(default_store_path),
380        issue_store_path: cli.issue_store_path.or(file.issue_store_path),
381        enhance_setting_store_path: cli
382            .enhance_setting_store_path
383            .or(file.enhance_setting_store_path),
384        enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
385        output_store_path: cli.output_store_path.or(file.output_store_path),
386        task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
387            if ephemeral {
388                None
389            } else {
390                Some(default_task_store_path())
391            }
392        }),
393        run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
394            if ephemeral {
395                None
396            } else {
397                Some(default_run_store_path())
398            }
399        }),
400        replay_store_path: cli
401            .replay_store_path
402            .or(file.replay_store_path)
403            .or_else(|| {
404                if ephemeral {
405                    None
406                } else {
407                    Some(default_replay_store_path())
408                }
409            }),
410        seed_blueprint_id: cli
411            .seed_blueprint_id
412            .or(file.seed_blueprint_id)
413            .unwrap_or(default.seed_blueprint_id),
414        default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
415        token_secret: cli.token_secret.or(file.token_secret),
416        sync_timeout_secs: cli
417            .sync_timeout_secs
418            .or(file.sync_timeout_secs)
419            .unwrap_or_else(default_sync_timeout_secs),
420        check_policy: cli
421            .check_policy
422            .or(file.check_policy)
423            .unwrap_or(default.check_policy),
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn resolve_cli_flag_wins_over_file_and_default() {
433        let cli = CliOverrides {
434            bind: Some("127.0.0.1:9999".into()),
435            ..Default::default()
436        };
437        let file = FileConfig {
438            bind: Some("127.0.0.1:8888".into()),
439            ..Default::default()
440        };
441        let resolved = resolve(cli, file).expect("resolve");
442        assert_eq!(
443            resolved.bind,
444            "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
445        );
446    }
447
448    #[test]
449    fn resolve_file_wins_over_built_in_default_when_cli_absent() {
450        let cli = CliOverrides::default();
451        let file = FileConfig {
452            seed_blueprint_id: Some("from-file".into()),
453            enable_enhance_flow: Some(true),
454            ..Default::default()
455        };
456        let resolved = resolve(cli, file).expect("resolve");
457        assert_eq!(resolved.seed_blueprint_id, "from-file");
458        assert!(resolved.enable_enhance_flow);
459    }
460
461    #[test]
462    fn resolve_legacy_worker_binding_policy_uses_cli_file_default_precedence() {
463        let resolved = resolve(CliOverrides::default(), FileConfig::default()).unwrap();
464        assert_eq!(
465            resolved.legacy_worker_binding_policy,
466            LegacyWorkerBindingPolicy::Allow
467        );
468
469        let file = FileConfig {
470            legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Reject),
471            ..Default::default()
472        };
473        let resolved = resolve(CliOverrides::default(), file.clone()).unwrap();
474        assert_eq!(
475            resolved.legacy_worker_binding_policy,
476            LegacyWorkerBindingPolicy::Reject
477        );
478
479        let cli = CliOverrides {
480            legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Allow),
481            ..Default::default()
482        };
483        let resolved = resolve(cli, file).unwrap();
484        assert_eq!(
485            resolved.legacy_worker_binding_policy,
486            LegacyWorkerBindingPolicy::Allow
487        );
488    }
489
490    #[test]
491    fn resolve_built_in_default_when_cli_and_file_absent() {
492        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
493        assert_eq!(resolved.bind, default_bind());
494        assert_eq!(resolved.seed_blueprint_id, "main");
495        assert!(!resolved.enable_enhance_flow);
496        assert_eq!(resolved.git_store_path, default_store_path());
497    }
498
499    #[test]
500    fn resolve_git_store_path_file_overrides_default_location() {
501        let file = FileConfig {
502            git_store_path: Some(PathBuf::from("/tmp/custom-store")),
503            ..Default::default()
504        };
505        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
506        assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
507    }
508
509    #[test]
510    fn resolve_bind_parse_error_is_propagated() {
511        let cli = CliOverrides {
512            bind: Some("not-a-valid-addr".into()),
513            ..Default::default()
514        };
515        let err = resolve(cli, FileConfig::default()).unwrap_err();
516        assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
517    }
518
519    #[test]
520    fn load_file_config_rejects_unknown_fields() {
521        let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
522        let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
523        let msg = err.to_string();
524        assert!(
525            msg.contains("typo_field") || msg.contains("unknown field"),
526            "unexpected error message: {msg}"
527        );
528    }
529
530    #[test]
531    fn load_file_config_missing_file_falls_back_to_default() {
532        let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
533        let cfg = load_file_config(path).expect("missing file should not error");
534        assert_eq!(cfg, FileConfig::default());
535    }
536
537    #[test]
538    fn load_file_config_parses_valid_toml() {
539        let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
540        std::fs::create_dir_all(&dir).expect("create tmp dir");
541        let path = dir.join("config.toml");
542        std::fs::write(
543            &path,
544            "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
545        )
546        .expect("write tmp config");
547        let cfg = load_file_config(&path).expect("parse tmp config");
548        assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
549        assert_eq!(cfg.enable_enhance_flow, Some(true));
550        let _ = std::fs::remove_dir_all(&dir);
551    }
552
553    #[test]
554    fn resolve_task_and_run_store_path_cli_wins_over_file() {
555        let cli = CliOverrides {
556            task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
557            ..Default::default()
558        };
559        let file = FileConfig {
560            task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
561            run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
562            ..Default::default()
563        };
564        let resolved = resolve(cli, file).expect("resolve");
565        assert_eq!(
566            resolved.task_store_path,
567            Some(PathBuf::from("/tmp/cli-tasks.db")),
568            "cli task_store_path must win over file"
569        );
570        assert_eq!(
571            resolved.run_store_path,
572            Some(PathBuf::from("/tmp/file-runs.db")),
573            "run_store_path falls back to file when cli is absent"
574        );
575    }
576
577    #[test]
578    fn resolve_task_and_run_store_path_default_none() {
579        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
580        assert_eq!(
581            resolved.task_store_path,
582            Some(default_task_store_path()),
583            "issue #35 ST1: task_store_path now persists by default"
584        );
585        assert_eq!(
586            resolved.run_store_path,
587            Some(default_run_store_path()),
588            "issue #35 ST1: run_store_path now persists by default"
589        );
590    }
591
592    #[test]
593    fn resolve_ephemeral_true_restores_in_memory_default() {
594        let cli = CliOverrides {
595            ephemeral: Some(true),
596            ..Default::default()
597        };
598        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
599        assert_eq!(resolved.task_store_path, None);
600        assert_eq!(resolved.run_store_path, None);
601    }
602
603    #[test]
604    fn resolve_explicit_path_wins_over_ephemeral() {
605        let cli = CliOverrides {
606            task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
607            ephemeral: Some(true),
608            ..Default::default()
609        };
610        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
611        assert_eq!(
612            resolved.task_store_path,
613            Some(PathBuf::from("/tmp/explicit-tasks.db")),
614            "explicit path must win over ephemeral"
615        );
616    }
617
618    #[test]
619    fn resolve_ephemeral_from_file_config() {
620        let file = FileConfig {
621            ephemeral: Some(true),
622            ..Default::default()
623        };
624        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
625        assert_eq!(resolved.task_store_path, None);
626        assert_eq!(resolved.run_store_path, None);
627        assert_eq!(resolved.replay_store_path, None);
628    }
629
630    // ──────────────────────────────────────────────────────────────────
631    // `replay_store_path` resolution cascade (sibling of run_store_path)
632    // ──────────────────────────────────────────────────────────────────
633
634    #[test]
635    fn resolve_replay_store_path_cli_wins_over_file() {
636        let cli = CliOverrides {
637            replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
638            ..Default::default()
639        };
640        let file = FileConfig {
641            replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
642            ..Default::default()
643        };
644        let resolved = resolve(cli, file).expect("resolve");
645        assert_eq!(
646            resolved.replay_store_path,
647            Some(PathBuf::from("/tmp/cli-replay.db")),
648            "cli replay_store_path must win over file"
649        );
650    }
651
652    #[test]
653    fn resolve_replay_store_path_file_wins_over_default() {
654        let file = FileConfig {
655            replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
656            ..Default::default()
657        };
658        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
659        assert_eq!(
660            resolved.replay_store_path,
661            Some(PathBuf::from("/tmp/file-replay.db")),
662            "file replay_store_path must win over built-in default"
663        );
664    }
665
666    #[test]
667    fn resolve_replay_store_path_default_persists() {
668        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
669        assert_eq!(
670            resolved.replay_store_path,
671            Some(default_replay_store_path()),
672            "replay_store_path persists by default (sibling of run_store_path)"
673        );
674    }
675
676    #[test]
677    fn resolve_replay_store_path_ephemeral_restores_in_memory() {
678        let cli = CliOverrides {
679            ephemeral: Some(true),
680            ..Default::default()
681        };
682        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
683        assert_eq!(resolved.replay_store_path, None);
684    }
685
686    #[test]
687    fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
688        let cli = CliOverrides {
689            replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
690            ephemeral: Some(true),
691            ..Default::default()
692        };
693        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
694        assert_eq!(
695            resolved.replay_store_path,
696            Some(PathBuf::from("/tmp/explicit-replay.db")),
697            "explicit replay path must win over ephemeral"
698        );
699    }
700
701    // ──────────────────────────────────────────────────────────────────
702    // GH #33 Guard 2: `sync_timeout_secs` resolution cascade
703    // ──────────────────────────────────────────────────────────────────
704
705    #[test]
706    fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
707        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
708        assert_eq!(resolved.sync_timeout_secs, 3600);
709        assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
710    }
711
712    #[test]
713    fn resolve_sync_timeout_secs_file_wins_over_default() {
714        let file = FileConfig {
715            sync_timeout_secs: Some(120),
716            ..Default::default()
717        };
718        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
719        assert_eq!(resolved.sync_timeout_secs, 120);
720    }
721
722    #[test]
723    fn resolve_sync_timeout_secs_cli_wins_over_file() {
724        let cli = CliOverrides {
725            sync_timeout_secs: Some(45),
726            ..Default::default()
727        };
728        let file = FileConfig {
729            sync_timeout_secs: Some(120),
730            ..Default::default()
731        };
732        let resolved = resolve(cli, file).expect("resolve");
733        assert_eq!(
734            resolved.sync_timeout_secs, 45,
735            "cli sync_timeout_secs must win over file"
736        );
737    }
738
739    // ──────────────────────────────────────────────────────────────────
740    // ST1c-2a: `check_policy` resolution cascade
741    // ──────────────────────────────────────────────────────────────────
742
743    #[test]
744    fn resolve_check_policy_default_when_cli_and_file_absent() {
745        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
746        assert_eq!(
747            resolved.check_policy,
748            CheckPolicy::Warn,
749            "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
750        );
751        assert_eq!(resolved.check_policy, CheckPolicy::default());
752    }
753
754    #[test]
755    fn resolve_check_policy_file_wins_over_default() {
756        let file = FileConfig {
757            check_policy: Some(CheckPolicy::Strict),
758            ..Default::default()
759        };
760        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
761        assert_eq!(
762            resolved.check_policy,
763            CheckPolicy::Strict,
764            "file check_policy must win over built-in default"
765        );
766    }
767
768    #[test]
769    fn resolve_check_policy_cli_wins_over_file() {
770        let cli = CliOverrides {
771            check_policy: Some(CheckPolicy::Silent),
772            ..Default::default()
773        };
774        let file = FileConfig {
775            check_policy: Some(CheckPolicy::Strict),
776            ..Default::default()
777        };
778        let resolved = resolve(cli, file).expect("resolve");
779        assert_eq!(
780            resolved.check_policy,
781            CheckPolicy::Silent,
782            "cli check_policy must win over file"
783        );
784    }
785
786    // ──────────────────────────────────────────────────────────────────
787    // Phase 6 (issue 4c4e3eb8): `blueprint_strict_embed` resolution cascade
788    // ──────────────────────────────────────────────────────────────────
789
790    #[test]
791    fn resolve_blueprint_strict_embed_default_false_when_cli_and_file_absent() {
792        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
793        assert!(
794            !resolved.blueprint_strict_embed,
795            "default blueprint_strict_embed = false (backward-compat: linker runs server-side)"
796        );
797    }
798
799    #[test]
800    fn resolve_blueprint_strict_embed_file_wins_over_default() {
801        let file = FileConfig {
802            blueprint_strict_embed: Some(true),
803            ..Default::default()
804        };
805        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
806        assert!(resolved.blueprint_strict_embed);
807    }
808
809    #[test]
810    fn resolve_blueprint_strict_embed_cli_wins_over_file() {
811        let cli = CliOverrides {
812            blueprint_strict_embed: Some(false),
813            ..Default::default()
814        };
815        let file = FileConfig {
816            blueprint_strict_embed: Some(true),
817            ..Default::default()
818        };
819        let resolved = resolve(cli, file).expect("resolve");
820        assert!(
821            !resolved.blueprint_strict_embed,
822            "cli blueprint_strict_embed=false must win over file=true"
823        );
824    }
825
826    #[test]
827    fn file_config_deserializes_blueprint_strict_embed() {
828        let toml_text = "blueprint_strict_embed = true\n";
829        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
830        assert_eq!(cfg.blueprint_strict_embed, Some(true));
831    }
832
833    #[test]
834    fn file_config_deserializes_check_policy_snake_case_literals() {
835        let toml_text = "check_policy = \"strict\"\n";
836        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
837        assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
838
839        let toml_text = "check_policy = \"silent\"\n";
840        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
841        assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
842
843        let toml_text = "check_policy = \"warn\"\n";
844        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
845        assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
846    }
847}