1use mlua_swarm::core::config::CheckPolicy;
14use mlua_swarm::LegacyWorkerBindingPolicy;
15use serde::Deserialize;
16use std::net::SocketAddr;
17use std::path::{Path, PathBuf};
18
19pub fn default_config_path() -> PathBuf {
22 match std::env::var("HOME") {
23 Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
24 Err(_) => PathBuf::from(".mse/config.toml"),
25 }
26}
27
28pub fn default_store_path() -> PathBuf {
33 match std::env::var("HOME") {
34 Ok(home) => PathBuf::from(home).join(".mse").join("store"),
35 Err(_) => PathBuf::from(".mse/store"),
36 }
37}
38
39pub fn default_task_store_path() -> PathBuf {
43 match std::env::var("HOME") {
44 Ok(home) => PathBuf::from(home)
45 .join(".mse")
46 .join("store")
47 .join("task.sqlite"),
48 Err(_) => PathBuf::from(".mse/store/task.sqlite"),
49 }
50}
51
52pub fn default_run_store_path() -> PathBuf {
55 match std::env::var("HOME") {
56 Ok(home) => PathBuf::from(home)
57 .join(".mse")
58 .join("store")
59 .join("run.sqlite"),
60 Err(_) => PathBuf::from(".mse/store/run.sqlite"),
61 }
62}
63
64pub fn default_replay_store_path() -> PathBuf {
68 match std::env::var("HOME") {
69 Ok(home) => PathBuf::from(home)
70 .join(".mse")
71 .join("store")
72 .join("replay.sqlite"),
73 Err(_) => PathBuf::from(".mse/store/replay.sqlite"),
74 }
75}
76
77#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct FileConfig {
83 pub bind: Option<String>,
85 pub enable_enhance_flow: Option<bool>,
87 pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
89 pub blueprint_ref_base: Option<PathBuf>,
91 pub blueprint_ref_includes: Option<Vec<PathBuf>>,
96 pub blueprint_strict_embed: Option<bool>,
105 pub inject_endpoint_for_worker: Option<bool>,
114 pub long_hold_warn_ms: Option<u64>,
124 pub git_store_path: Option<PathBuf>,
126 pub issue_store_path: Option<PathBuf>,
129 pub enhance_setting_store_path: Option<PathBuf>,
132 pub enhance_log_store_path: Option<PathBuf>,
135 pub output_store_path: Option<PathBuf>,
138 pub task_store_path: Option<PathBuf>,
142 pub run_store_path: Option<PathBuf>,
145 pub replay_store_path: Option<PathBuf>,
151 pub ephemeral: Option<bool>,
157 pub seed_blueprint_id: Option<String>,
159 pub default_agent_kind: Option<String>,
162 pub token_secret: Option<String>,
164 pub sync_timeout_secs: Option<u64>,
170 pub stale_run_sweep_secs: Option<u64>,
177 pub engine_max_hold_ms: Option<u64>,
183 pub check_policy: Option<CheckPolicy>,
196}
197
198#[derive(Debug, Default, Clone)]
202pub struct CliOverrides {
203 pub bind: Option<String>,
205 pub enable_enhance_flow: Option<bool>,
207 pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
209 pub blueprint_ref_base: Option<PathBuf>,
211 pub blueprint_ref_includes: Vec<PathBuf>,
214 pub blueprint_strict_embed: Option<bool>,
217 pub inject_endpoint_for_worker: Option<bool>,
220 pub long_hold_warn_ms: Option<u64>,
223 pub git_store_path: Option<PathBuf>,
225 pub issue_store_path: Option<PathBuf>,
227 pub enhance_setting_store_path: Option<PathBuf>,
229 pub enhance_log_store_path: Option<PathBuf>,
231 pub output_store_path: Option<PathBuf>,
233 pub task_store_path: Option<PathBuf>,
235 pub run_store_path: Option<PathBuf>,
237 pub replay_store_path: Option<PathBuf>,
239 pub ephemeral: Option<bool>,
241 pub seed_blueprint_id: Option<String>,
243 pub default_agent_kind: Option<String>,
245 pub token_secret: Option<String>,
247 pub sync_timeout_secs: Option<u64>,
249 pub stale_run_sweep_secs: Option<u64>,
252 pub engine_max_hold_ms: Option<u64>,
255 pub check_policy: Option<CheckPolicy>,
259}
260
261#[derive(Debug, Clone, PartialEq)]
263pub struct ResolvedConfig {
264 pub bind: SocketAddr,
266 pub enable_enhance_flow: bool,
268 pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
270 pub blueprint_ref_base: Option<PathBuf>,
272 pub blueprint_ref_includes: Vec<PathBuf>,
276 pub blueprint_strict_embed: bool,
282 pub git_store_path: PathBuf,
286 pub issue_store_path: Option<PathBuf>,
289 pub enhance_setting_store_path: Option<PathBuf>,
292 pub enhance_log_store_path: Option<PathBuf>,
295 pub output_store_path: Option<PathBuf>,
298 pub task_store_path: Option<PathBuf>,
301 pub run_store_path: Option<PathBuf>,
304 pub replay_store_path: Option<PathBuf>,
307 pub seed_blueprint_id: String,
309 pub default_agent_kind: Option<String>,
312 pub token_secret: Option<String>,
314 pub sync_timeout_secs: u64,
320 pub stale_run_sweep_secs: u64,
325 pub engine_max_hold_ms: Option<u64>,
329 pub inject_endpoint_for_worker: bool,
335 pub long_hold_warn_ms: Option<u64>,
338 pub check_policy: CheckPolicy,
345}
346
347impl Default for ResolvedConfig {
348 fn default() -> Self {
349 Self {
350 bind: default_bind(),
351 enable_enhance_flow: false,
352 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
353 blueprint_ref_base: None,
354 blueprint_ref_includes: Vec::new(),
355 blueprint_strict_embed: false,
356 git_store_path: default_store_path(),
357 issue_store_path: None,
358 enhance_setting_store_path: None,
359 enhance_log_store_path: None,
360 output_store_path: None,
361 task_store_path: None,
362 run_store_path: None,
363 replay_store_path: None,
364 seed_blueprint_id: "main".into(),
365 default_agent_kind: None,
366 token_secret: None,
367 sync_timeout_secs: default_sync_timeout_secs(),
368 stale_run_sweep_secs: default_stale_run_sweep_secs(default_sync_timeout_secs()),
369 engine_max_hold_ms: None,
370 inject_endpoint_for_worker: false,
371 long_hold_warn_ms: None,
372 check_policy: CheckPolicy::default(),
373 }
374 }
375}
376
377pub fn default_sync_timeout_secs() -> u64 {
386 3600
387}
388
389pub fn default_stale_run_sweep_secs(sync_timeout_secs: u64) -> u64 {
401 sync_timeout_secs.max(crate::default_run_ttl()) + 300
402}
403
404fn default_bind() -> SocketAddr {
405 "127.0.0.1:7777"
406 .parse()
407 .expect("literal default bind must parse")
408}
409
410pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
415 match std::fs::read_to_string(path) {
416 Ok(text) => toml::from_str(&text)
417 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
418 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
419 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
420 }
421}
422
423pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
426 let default = ResolvedConfig::default();
427
428 let bind = match cli.bind.or(file.bind) {
429 Some(s) => s
430 .parse::<SocketAddr>()
431 .map_err(|e| format!("bind {s:?}: {e}"))?,
432 None => default.bind,
433 };
434
435 let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
436
437 let sync_timeout_secs = cli
440 .sync_timeout_secs
441 .or(file.sync_timeout_secs)
442 .unwrap_or_else(default_sync_timeout_secs);
443
444 Ok(ResolvedConfig {
445 bind,
446 enable_enhance_flow: cli
447 .enable_enhance_flow
448 .or(file.enable_enhance_flow)
449 .unwrap_or(default.enable_enhance_flow),
450 legacy_worker_binding_policy: cli
451 .legacy_worker_binding_policy
452 .or(file.legacy_worker_binding_policy)
453 .unwrap_or(default.legacy_worker_binding_policy),
454 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
455 blueprint_ref_includes: {
456 let mut merged = cli.blueprint_ref_includes;
457 merged.extend(file.blueprint_ref_includes.unwrap_or_default());
458 merged
459 },
460 blueprint_strict_embed: cli
461 .blueprint_strict_embed
462 .or(file.blueprint_strict_embed)
463 .unwrap_or(default.blueprint_strict_embed),
464 inject_endpoint_for_worker: cli
465 .inject_endpoint_for_worker
466 .or(file.inject_endpoint_for_worker)
467 .unwrap_or(default.inject_endpoint_for_worker),
468 long_hold_warn_ms: cli.long_hold_warn_ms.or(file.long_hold_warn_ms),
469 git_store_path: cli
470 .git_store_path
471 .or(file.git_store_path)
472 .unwrap_or_else(default_store_path),
473 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
474 enhance_setting_store_path: cli
475 .enhance_setting_store_path
476 .or(file.enhance_setting_store_path),
477 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
478 output_store_path: cli.output_store_path.or(file.output_store_path),
479 task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
480 if ephemeral {
481 None
482 } else {
483 Some(default_task_store_path())
484 }
485 }),
486 run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
487 if ephemeral {
488 None
489 } else {
490 Some(default_run_store_path())
491 }
492 }),
493 replay_store_path: cli
494 .replay_store_path
495 .or(file.replay_store_path)
496 .or_else(|| {
497 if ephemeral {
498 None
499 } else {
500 Some(default_replay_store_path())
501 }
502 }),
503 seed_blueprint_id: cli
504 .seed_blueprint_id
505 .or(file.seed_blueprint_id)
506 .unwrap_or(default.seed_blueprint_id),
507 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
508 token_secret: cli.token_secret.or(file.token_secret),
509 sync_timeout_secs,
510 stale_run_sweep_secs: cli
511 .stale_run_sweep_secs
512 .or(file.stale_run_sweep_secs)
513 .unwrap_or_else(|| default_stale_run_sweep_secs(sync_timeout_secs)),
514 engine_max_hold_ms: cli.engine_max_hold_ms.or(file.engine_max_hold_ms),
515 check_policy: cli
516 .check_policy
517 .or(file.check_policy)
518 .unwrap_or(default.check_policy),
519 })
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn resolve_cli_flag_wins_over_file_and_default() {
528 let cli = CliOverrides {
529 bind: Some("127.0.0.1:9999".into()),
530 ..Default::default()
531 };
532 let file = FileConfig {
533 bind: Some("127.0.0.1:8888".into()),
534 ..Default::default()
535 };
536 let resolved = resolve(cli, file).expect("resolve");
537 assert_eq!(
538 resolved.bind,
539 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
540 );
541 }
542
543 #[test]
544 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
545 let cli = CliOverrides::default();
546 let file = FileConfig {
547 seed_blueprint_id: Some("from-file".into()),
548 enable_enhance_flow: Some(true),
549 ..Default::default()
550 };
551 let resolved = resolve(cli, file).expect("resolve");
552 assert_eq!(resolved.seed_blueprint_id, "from-file");
553 assert!(resolved.enable_enhance_flow);
554 }
555
556 #[test]
557 fn resolve_legacy_worker_binding_policy_uses_cli_file_default_precedence() {
558 let resolved = resolve(CliOverrides::default(), FileConfig::default()).unwrap();
559 assert_eq!(
560 resolved.legacy_worker_binding_policy,
561 LegacyWorkerBindingPolicy::Allow
562 );
563
564 let file = FileConfig {
565 legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Reject),
566 ..Default::default()
567 };
568 let resolved = resolve(CliOverrides::default(), file.clone()).unwrap();
569 assert_eq!(
570 resolved.legacy_worker_binding_policy,
571 LegacyWorkerBindingPolicy::Reject
572 );
573
574 let cli = CliOverrides {
575 legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Allow),
576 ..Default::default()
577 };
578 let resolved = resolve(cli, file).unwrap();
579 assert_eq!(
580 resolved.legacy_worker_binding_policy,
581 LegacyWorkerBindingPolicy::Allow
582 );
583 }
584
585 #[test]
586 fn resolve_built_in_default_when_cli_and_file_absent() {
587 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
588 assert_eq!(resolved.bind, default_bind());
589 assert_eq!(resolved.seed_blueprint_id, "main");
590 assert!(!resolved.enable_enhance_flow);
591 assert_eq!(resolved.git_store_path, default_store_path());
592 }
593
594 #[test]
595 fn resolve_git_store_path_file_overrides_default_location() {
596 let file = FileConfig {
597 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
598 ..Default::default()
599 };
600 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
601 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
602 }
603
604 #[test]
605 fn resolve_bind_parse_error_is_propagated() {
606 let cli = CliOverrides {
607 bind: Some("not-a-valid-addr".into()),
608 ..Default::default()
609 };
610 let err = resolve(cli, FileConfig::default()).unwrap_err();
611 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
612 }
613
614 #[test]
615 fn load_file_config_rejects_unknown_fields() {
616 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
617 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
618 let msg = err.to_string();
619 assert!(
620 msg.contains("typo_field") || msg.contains("unknown field"),
621 "unexpected error message: {msg}"
622 );
623 }
624
625 #[test]
626 fn load_file_config_missing_file_falls_back_to_default() {
627 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
628 let cfg = load_file_config(path).expect("missing file should not error");
629 assert_eq!(cfg, FileConfig::default());
630 }
631
632 #[test]
633 fn load_file_config_parses_valid_toml() {
634 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
635 std::fs::create_dir_all(&dir).expect("create tmp dir");
636 let path = dir.join("config.toml");
637 std::fs::write(
638 &path,
639 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
640 )
641 .expect("write tmp config");
642 let cfg = load_file_config(&path).expect("parse tmp config");
643 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
644 assert_eq!(cfg.enable_enhance_flow, Some(true));
645 let _ = std::fs::remove_dir_all(&dir);
646 }
647
648 #[test]
649 fn resolve_task_and_run_store_path_cli_wins_over_file() {
650 let cli = CliOverrides {
651 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
652 ..Default::default()
653 };
654 let file = FileConfig {
655 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
656 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
657 ..Default::default()
658 };
659 let resolved = resolve(cli, file).expect("resolve");
660 assert_eq!(
661 resolved.task_store_path,
662 Some(PathBuf::from("/tmp/cli-tasks.db")),
663 "cli task_store_path must win over file"
664 );
665 assert_eq!(
666 resolved.run_store_path,
667 Some(PathBuf::from("/tmp/file-runs.db")),
668 "run_store_path falls back to file when cli is absent"
669 );
670 }
671
672 #[test]
673 fn resolve_task_and_run_store_path_default_none() {
674 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
675 assert_eq!(
676 resolved.task_store_path,
677 Some(default_task_store_path()),
678 "issue #35 ST1: task_store_path now persists by default"
679 );
680 assert_eq!(
681 resolved.run_store_path,
682 Some(default_run_store_path()),
683 "issue #35 ST1: run_store_path now persists by default"
684 );
685 }
686
687 #[test]
688 fn resolve_ephemeral_true_restores_in_memory_default() {
689 let cli = CliOverrides {
690 ephemeral: Some(true),
691 ..Default::default()
692 };
693 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
694 assert_eq!(resolved.task_store_path, None);
695 assert_eq!(resolved.run_store_path, None);
696 }
697
698 #[test]
699 fn resolve_explicit_path_wins_over_ephemeral() {
700 let cli = CliOverrides {
701 task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
702 ephemeral: Some(true),
703 ..Default::default()
704 };
705 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
706 assert_eq!(
707 resolved.task_store_path,
708 Some(PathBuf::from("/tmp/explicit-tasks.db")),
709 "explicit path must win over ephemeral"
710 );
711 }
712
713 #[test]
714 fn resolve_ephemeral_from_file_config() {
715 let file = FileConfig {
716 ephemeral: Some(true),
717 ..Default::default()
718 };
719 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
720 assert_eq!(resolved.task_store_path, None);
721 assert_eq!(resolved.run_store_path, None);
722 assert_eq!(resolved.replay_store_path, None);
723 }
724
725 #[test]
730 fn resolve_replay_store_path_cli_wins_over_file() {
731 let cli = CliOverrides {
732 replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
733 ..Default::default()
734 };
735 let file = FileConfig {
736 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
737 ..Default::default()
738 };
739 let resolved = resolve(cli, file).expect("resolve");
740 assert_eq!(
741 resolved.replay_store_path,
742 Some(PathBuf::from("/tmp/cli-replay.db")),
743 "cli replay_store_path must win over file"
744 );
745 }
746
747 #[test]
748 fn resolve_replay_store_path_file_wins_over_default() {
749 let file = FileConfig {
750 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
751 ..Default::default()
752 };
753 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
754 assert_eq!(
755 resolved.replay_store_path,
756 Some(PathBuf::from("/tmp/file-replay.db")),
757 "file replay_store_path must win over built-in default"
758 );
759 }
760
761 #[test]
762 fn resolve_replay_store_path_default_persists() {
763 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
764 assert_eq!(
765 resolved.replay_store_path,
766 Some(default_replay_store_path()),
767 "replay_store_path persists by default (sibling of run_store_path)"
768 );
769 }
770
771 #[test]
772 fn resolve_replay_store_path_ephemeral_restores_in_memory() {
773 let cli = CliOverrides {
774 ephemeral: Some(true),
775 ..Default::default()
776 };
777 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
778 assert_eq!(resolved.replay_store_path, None);
779 }
780
781 #[test]
782 fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
783 let cli = CliOverrides {
784 replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
785 ephemeral: Some(true),
786 ..Default::default()
787 };
788 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
789 assert_eq!(
790 resolved.replay_store_path,
791 Some(PathBuf::from("/tmp/explicit-replay.db")),
792 "explicit replay path must win over ephemeral"
793 );
794 }
795
796 #[test]
801 fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
802 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
803 assert_eq!(resolved.sync_timeout_secs, 3600);
804 assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
805 }
806
807 #[test]
808 fn resolve_sync_timeout_secs_file_wins_over_default() {
809 let file = FileConfig {
810 sync_timeout_secs: Some(120),
811 ..Default::default()
812 };
813 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
814 assert_eq!(resolved.sync_timeout_secs, 120);
815 }
816
817 #[test]
818 fn resolve_sync_timeout_secs_cli_wins_over_file() {
819 let cli = CliOverrides {
820 sync_timeout_secs: Some(45),
821 ..Default::default()
822 };
823 let file = FileConfig {
824 sync_timeout_secs: Some(120),
825 ..Default::default()
826 };
827 let resolved = resolve(cli, file).expect("resolve");
828 assert_eq!(
829 resolved.sync_timeout_secs, 45,
830 "cli sync_timeout_secs must win over file"
831 );
832 }
833
834 #[test]
839 fn resolve_stale_run_sweep_secs_default_derives_from_sync_timeout() {
840 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
841 assert_eq!(
842 resolved.stale_run_sweep_secs,
843 default_stale_run_sweep_secs(default_sync_timeout_secs())
844 );
845 assert_eq!(
846 resolved.stale_run_sweep_secs, 3900,
847 "max(3600 sync timeout, 1800 run ttl) + 300 margin"
848 );
849 }
850
851 #[test]
855 fn resolve_stale_run_sweep_secs_default_tracks_the_resolved_sync_timeout() {
856 let file = FileConfig {
857 sync_timeout_secs: Some(7200),
858 ..Default::default()
859 };
860 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
861 assert_eq!(resolved.stale_run_sweep_secs, 7500);
862
863 let cli = CliOverrides {
864 sync_timeout_secs: Some(60),
865 ..Default::default()
866 };
867 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
868 assert_eq!(
869 resolved.stale_run_sweep_secs, 2100,
870 "a short sync timeout must not shrink the threshold below the run TTL + margin"
871 );
872 }
873
874 #[test]
875 fn resolve_stale_run_sweep_secs_cli_wins_over_file() {
876 let cli = CliOverrides {
877 stale_run_sweep_secs: Some(600),
878 ..Default::default()
879 };
880 let file = FileConfig {
881 stale_run_sweep_secs: Some(1200),
882 ..Default::default()
883 };
884 let resolved = resolve(cli, file).expect("resolve");
885 assert_eq!(resolved.stale_run_sweep_secs, 600);
886 }
887
888 #[test]
889 fn resolve_stale_run_sweep_secs_zero_is_kept_as_the_disable_switch() {
890 let file = FileConfig {
891 stale_run_sweep_secs: Some(0),
892 ..Default::default()
893 };
894 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
895 assert_eq!(
896 resolved.stale_run_sweep_secs, 0,
897 "an explicit 0 disables the sweep and must not fall through to the default"
898 );
899 }
900
901 #[test]
906 fn resolve_engine_max_hold_ms_absent_leaves_the_engine_default() {
907 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
908 assert_eq!(
909 resolved.engine_max_hold_ms, None,
910 "None keeps the engine's built-in max_hold_ms"
911 );
912 }
913
914 #[test]
915 fn resolve_engine_max_hold_ms_file_wins_over_default() {
916 let file = FileConfig {
917 engine_max_hold_ms: Some(200),
918 ..Default::default()
919 };
920 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
921 assert_eq!(resolved.engine_max_hold_ms, Some(200));
922 }
923
924 #[test]
925 fn resolve_engine_max_hold_ms_cli_wins_over_file() {
926 let cli = CliOverrides {
927 engine_max_hold_ms: Some(500),
928 ..Default::default()
929 };
930 let file = FileConfig {
931 engine_max_hold_ms: Some(200),
932 ..Default::default()
933 };
934 let resolved = resolve(cli, file).expect("resolve");
935 assert_eq!(resolved.engine_max_hold_ms, Some(500));
936 }
937
938 #[test]
939 fn file_config_deserializes_the_new_keys() {
940 let toml_text = "stale_run_sweep_secs = 900\nengine_max_hold_ms = 200\n";
941 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
942 assert_eq!(cfg.stale_run_sweep_secs, Some(900));
943 assert_eq!(cfg.engine_max_hold_ms, Some(200));
944 }
945
946 #[test]
951 fn resolve_check_policy_default_when_cli_and_file_absent() {
952 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
953 assert_eq!(
954 resolved.check_policy,
955 CheckPolicy::Warn,
956 "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
957 );
958 assert_eq!(resolved.check_policy, CheckPolicy::default());
959 }
960
961 #[test]
962 fn resolve_check_policy_file_wins_over_default() {
963 let file = FileConfig {
964 check_policy: Some(CheckPolicy::Strict),
965 ..Default::default()
966 };
967 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
968 assert_eq!(
969 resolved.check_policy,
970 CheckPolicy::Strict,
971 "file check_policy must win over built-in default"
972 );
973 }
974
975 #[test]
976 fn resolve_check_policy_cli_wins_over_file() {
977 let cli = CliOverrides {
978 check_policy: Some(CheckPolicy::Silent),
979 ..Default::default()
980 };
981 let file = FileConfig {
982 check_policy: Some(CheckPolicy::Strict),
983 ..Default::default()
984 };
985 let resolved = resolve(cli, file).expect("resolve");
986 assert_eq!(
987 resolved.check_policy,
988 CheckPolicy::Silent,
989 "cli check_policy must win over file"
990 );
991 }
992
993 #[test]
998 fn resolve_blueprint_strict_embed_default_false_when_cli_and_file_absent() {
999 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
1000 assert!(
1001 !resolved.blueprint_strict_embed,
1002 "default blueprint_strict_embed = false (backward-compat: linker runs server-side)"
1003 );
1004 }
1005
1006 #[test]
1007 fn resolve_blueprint_strict_embed_file_wins_over_default() {
1008 let file = FileConfig {
1009 blueprint_strict_embed: Some(true),
1010 ..Default::default()
1011 };
1012 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
1013 assert!(resolved.blueprint_strict_embed);
1014 }
1015
1016 #[test]
1017 fn resolve_blueprint_strict_embed_cli_wins_over_file() {
1018 let cli = CliOverrides {
1019 blueprint_strict_embed: Some(false),
1020 ..Default::default()
1021 };
1022 let file = FileConfig {
1023 blueprint_strict_embed: Some(true),
1024 ..Default::default()
1025 };
1026 let resolved = resolve(cli, file).expect("resolve");
1027 assert!(
1028 !resolved.blueprint_strict_embed,
1029 "cli blueprint_strict_embed=false must win over file=true"
1030 );
1031 }
1032
1033 #[test]
1034 fn file_config_deserializes_blueprint_strict_embed() {
1035 let toml_text = "blueprint_strict_embed = true\n";
1036 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1037 assert_eq!(cfg.blueprint_strict_embed, Some(true));
1038 }
1039
1040 #[test]
1041 fn file_config_deserializes_check_policy_snake_case_literals() {
1042 let toml_text = "check_policy = \"strict\"\n";
1043 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1044 assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
1045
1046 let toml_text = "check_policy = \"silent\"\n";
1047 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1048 assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
1049
1050 let toml_text = "check_policy = \"warn\"\n";
1051 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1052 assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
1053 }
1054}