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