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