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