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