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 git_store_path: Option<PathBuf>,
107 pub issue_store_path: Option<PathBuf>,
110 pub enhance_setting_store_path: Option<PathBuf>,
113 pub enhance_log_store_path: Option<PathBuf>,
116 pub output_store_path: Option<PathBuf>,
119 pub task_store_path: Option<PathBuf>,
123 pub run_store_path: Option<PathBuf>,
126 pub replay_store_path: Option<PathBuf>,
132 pub ephemeral: Option<bool>,
138 pub seed_blueprint_id: Option<String>,
140 pub default_agent_kind: Option<String>,
143 pub token_secret: Option<String>,
145 pub sync_timeout_secs: Option<u64>,
151 pub check_policy: Option<CheckPolicy>,
164}
165
166#[derive(Debug, Default, Clone)]
170pub struct CliOverrides {
171 pub bind: Option<String>,
173 pub enable_enhance_flow: Option<bool>,
175 pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
177 pub blueprint_ref_base: Option<PathBuf>,
179 pub blueprint_ref_includes: Vec<PathBuf>,
182 pub blueprint_strict_embed: Option<bool>,
185 pub git_store_path: Option<PathBuf>,
187 pub issue_store_path: Option<PathBuf>,
189 pub enhance_setting_store_path: Option<PathBuf>,
191 pub enhance_log_store_path: Option<PathBuf>,
193 pub output_store_path: Option<PathBuf>,
195 pub task_store_path: Option<PathBuf>,
197 pub run_store_path: Option<PathBuf>,
199 pub replay_store_path: Option<PathBuf>,
201 pub ephemeral: Option<bool>,
203 pub seed_blueprint_id: Option<String>,
205 pub default_agent_kind: Option<String>,
207 pub token_secret: Option<String>,
209 pub sync_timeout_secs: Option<u64>,
211 pub check_policy: Option<CheckPolicy>,
215}
216
217#[derive(Debug, Clone, PartialEq)]
219pub struct ResolvedConfig {
220 pub bind: SocketAddr,
222 pub enable_enhance_flow: bool,
224 pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
226 pub blueprint_ref_base: Option<PathBuf>,
228 pub blueprint_ref_includes: Vec<PathBuf>,
232 pub blueprint_strict_embed: bool,
238 pub git_store_path: PathBuf,
242 pub issue_store_path: Option<PathBuf>,
245 pub enhance_setting_store_path: Option<PathBuf>,
248 pub enhance_log_store_path: Option<PathBuf>,
251 pub output_store_path: Option<PathBuf>,
254 pub task_store_path: Option<PathBuf>,
257 pub run_store_path: Option<PathBuf>,
260 pub replay_store_path: Option<PathBuf>,
263 pub seed_blueprint_id: String,
265 pub default_agent_kind: Option<String>,
268 pub token_secret: Option<String>,
270 pub sync_timeout_secs: u64,
276 pub check_policy: CheckPolicy,
283}
284
285impl Default for ResolvedConfig {
286 fn default() -> Self {
287 Self {
288 bind: default_bind(),
289 enable_enhance_flow: false,
290 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
291 blueprint_ref_base: None,
292 blueprint_ref_includes: Vec::new(),
293 blueprint_strict_embed: false,
294 git_store_path: default_store_path(),
295 issue_store_path: None,
296 enhance_setting_store_path: None,
297 enhance_log_store_path: None,
298 output_store_path: None,
299 task_store_path: None,
300 run_store_path: None,
301 replay_store_path: None,
302 seed_blueprint_id: "main".into(),
303 default_agent_kind: None,
304 token_secret: None,
305 sync_timeout_secs: default_sync_timeout_secs(),
306 check_policy: CheckPolicy::default(),
307 }
308 }
309}
310
311pub fn default_sync_timeout_secs() -> u64 {
320 3600
321}
322
323fn default_bind() -> SocketAddr {
324 "127.0.0.1:7777"
325 .parse()
326 .expect("literal default bind must parse")
327}
328
329pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
334 match std::fs::read_to_string(path) {
335 Ok(text) => toml::from_str(&text)
336 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
337 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
338 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
339 }
340}
341
342pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
345 let default = ResolvedConfig::default();
346
347 let bind = match cli.bind.or(file.bind) {
348 Some(s) => s
349 .parse::<SocketAddr>()
350 .map_err(|e| format!("bind {s:?}: {e}"))?,
351 None => default.bind,
352 };
353
354 let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
355
356 Ok(ResolvedConfig {
357 bind,
358 enable_enhance_flow: cli
359 .enable_enhance_flow
360 .or(file.enable_enhance_flow)
361 .unwrap_or(default.enable_enhance_flow),
362 legacy_worker_binding_policy: cli
363 .legacy_worker_binding_policy
364 .or(file.legacy_worker_binding_policy)
365 .unwrap_or(default.legacy_worker_binding_policy),
366 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
367 blueprint_ref_includes: {
368 let mut merged = cli.blueprint_ref_includes;
369 merged.extend(file.blueprint_ref_includes.unwrap_or_default());
370 merged
371 },
372 blueprint_strict_embed: cli
373 .blueprint_strict_embed
374 .or(file.blueprint_strict_embed)
375 .unwrap_or(default.blueprint_strict_embed),
376 git_store_path: cli
377 .git_store_path
378 .or(file.git_store_path)
379 .unwrap_or_else(default_store_path),
380 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
381 enhance_setting_store_path: cli
382 .enhance_setting_store_path
383 .or(file.enhance_setting_store_path),
384 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
385 output_store_path: cli.output_store_path.or(file.output_store_path),
386 task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
387 if ephemeral {
388 None
389 } else {
390 Some(default_task_store_path())
391 }
392 }),
393 run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
394 if ephemeral {
395 None
396 } else {
397 Some(default_run_store_path())
398 }
399 }),
400 replay_store_path: cli
401 .replay_store_path
402 .or(file.replay_store_path)
403 .or_else(|| {
404 if ephemeral {
405 None
406 } else {
407 Some(default_replay_store_path())
408 }
409 }),
410 seed_blueprint_id: cli
411 .seed_blueprint_id
412 .or(file.seed_blueprint_id)
413 .unwrap_or(default.seed_blueprint_id),
414 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
415 token_secret: cli.token_secret.or(file.token_secret),
416 sync_timeout_secs: cli
417 .sync_timeout_secs
418 .or(file.sync_timeout_secs)
419 .unwrap_or_else(default_sync_timeout_secs),
420 check_policy: cli
421 .check_policy
422 .or(file.check_policy)
423 .unwrap_or(default.check_policy),
424 })
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn resolve_cli_flag_wins_over_file_and_default() {
433 let cli = CliOverrides {
434 bind: Some("127.0.0.1:9999".into()),
435 ..Default::default()
436 };
437 let file = FileConfig {
438 bind: Some("127.0.0.1:8888".into()),
439 ..Default::default()
440 };
441 let resolved = resolve(cli, file).expect("resolve");
442 assert_eq!(
443 resolved.bind,
444 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
445 );
446 }
447
448 #[test]
449 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
450 let cli = CliOverrides::default();
451 let file = FileConfig {
452 seed_blueprint_id: Some("from-file".into()),
453 enable_enhance_flow: Some(true),
454 ..Default::default()
455 };
456 let resolved = resolve(cli, file).expect("resolve");
457 assert_eq!(resolved.seed_blueprint_id, "from-file");
458 assert!(resolved.enable_enhance_flow);
459 }
460
461 #[test]
462 fn resolve_legacy_worker_binding_policy_uses_cli_file_default_precedence() {
463 let resolved = resolve(CliOverrides::default(), FileConfig::default()).unwrap();
464 assert_eq!(
465 resolved.legacy_worker_binding_policy,
466 LegacyWorkerBindingPolicy::Allow
467 );
468
469 let file = FileConfig {
470 legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Reject),
471 ..Default::default()
472 };
473 let resolved = resolve(CliOverrides::default(), file.clone()).unwrap();
474 assert_eq!(
475 resolved.legacy_worker_binding_policy,
476 LegacyWorkerBindingPolicy::Reject
477 );
478
479 let cli = CliOverrides {
480 legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Allow),
481 ..Default::default()
482 };
483 let resolved = resolve(cli, file).unwrap();
484 assert_eq!(
485 resolved.legacy_worker_binding_policy,
486 LegacyWorkerBindingPolicy::Allow
487 );
488 }
489
490 #[test]
491 fn resolve_built_in_default_when_cli_and_file_absent() {
492 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
493 assert_eq!(resolved.bind, default_bind());
494 assert_eq!(resolved.seed_blueprint_id, "main");
495 assert!(!resolved.enable_enhance_flow);
496 assert_eq!(resolved.git_store_path, default_store_path());
497 }
498
499 #[test]
500 fn resolve_git_store_path_file_overrides_default_location() {
501 let file = FileConfig {
502 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
503 ..Default::default()
504 };
505 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
506 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
507 }
508
509 #[test]
510 fn resolve_bind_parse_error_is_propagated() {
511 let cli = CliOverrides {
512 bind: Some("not-a-valid-addr".into()),
513 ..Default::default()
514 };
515 let err = resolve(cli, FileConfig::default()).unwrap_err();
516 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
517 }
518
519 #[test]
520 fn load_file_config_rejects_unknown_fields() {
521 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
522 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
523 let msg = err.to_string();
524 assert!(
525 msg.contains("typo_field") || msg.contains("unknown field"),
526 "unexpected error message: {msg}"
527 );
528 }
529
530 #[test]
531 fn load_file_config_missing_file_falls_back_to_default() {
532 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
533 let cfg = load_file_config(path).expect("missing file should not error");
534 assert_eq!(cfg, FileConfig::default());
535 }
536
537 #[test]
538 fn load_file_config_parses_valid_toml() {
539 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
540 std::fs::create_dir_all(&dir).expect("create tmp dir");
541 let path = dir.join("config.toml");
542 std::fs::write(
543 &path,
544 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
545 )
546 .expect("write tmp config");
547 let cfg = load_file_config(&path).expect("parse tmp config");
548 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
549 assert_eq!(cfg.enable_enhance_flow, Some(true));
550 let _ = std::fs::remove_dir_all(&dir);
551 }
552
553 #[test]
554 fn resolve_task_and_run_store_path_cli_wins_over_file() {
555 let cli = CliOverrides {
556 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
557 ..Default::default()
558 };
559 let file = FileConfig {
560 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
561 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
562 ..Default::default()
563 };
564 let resolved = resolve(cli, file).expect("resolve");
565 assert_eq!(
566 resolved.task_store_path,
567 Some(PathBuf::from("/tmp/cli-tasks.db")),
568 "cli task_store_path must win over file"
569 );
570 assert_eq!(
571 resolved.run_store_path,
572 Some(PathBuf::from("/tmp/file-runs.db")),
573 "run_store_path falls back to file when cli is absent"
574 );
575 }
576
577 #[test]
578 fn resolve_task_and_run_store_path_default_none() {
579 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
580 assert_eq!(
581 resolved.task_store_path,
582 Some(default_task_store_path()),
583 "issue #35 ST1: task_store_path now persists by default"
584 );
585 assert_eq!(
586 resolved.run_store_path,
587 Some(default_run_store_path()),
588 "issue #35 ST1: run_store_path now persists by default"
589 );
590 }
591
592 #[test]
593 fn resolve_ephemeral_true_restores_in_memory_default() {
594 let cli = CliOverrides {
595 ephemeral: Some(true),
596 ..Default::default()
597 };
598 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
599 assert_eq!(resolved.task_store_path, None);
600 assert_eq!(resolved.run_store_path, None);
601 }
602
603 #[test]
604 fn resolve_explicit_path_wins_over_ephemeral() {
605 let cli = CliOverrides {
606 task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
607 ephemeral: Some(true),
608 ..Default::default()
609 };
610 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
611 assert_eq!(
612 resolved.task_store_path,
613 Some(PathBuf::from("/tmp/explicit-tasks.db")),
614 "explicit path must win over ephemeral"
615 );
616 }
617
618 #[test]
619 fn resolve_ephemeral_from_file_config() {
620 let file = FileConfig {
621 ephemeral: Some(true),
622 ..Default::default()
623 };
624 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
625 assert_eq!(resolved.task_store_path, None);
626 assert_eq!(resolved.run_store_path, None);
627 assert_eq!(resolved.replay_store_path, None);
628 }
629
630 #[test]
635 fn resolve_replay_store_path_cli_wins_over_file() {
636 let cli = CliOverrides {
637 replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
638 ..Default::default()
639 };
640 let file = FileConfig {
641 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
642 ..Default::default()
643 };
644 let resolved = resolve(cli, file).expect("resolve");
645 assert_eq!(
646 resolved.replay_store_path,
647 Some(PathBuf::from("/tmp/cli-replay.db")),
648 "cli replay_store_path must win over file"
649 );
650 }
651
652 #[test]
653 fn resolve_replay_store_path_file_wins_over_default() {
654 let file = FileConfig {
655 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
656 ..Default::default()
657 };
658 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
659 assert_eq!(
660 resolved.replay_store_path,
661 Some(PathBuf::from("/tmp/file-replay.db")),
662 "file replay_store_path must win over built-in default"
663 );
664 }
665
666 #[test]
667 fn resolve_replay_store_path_default_persists() {
668 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
669 assert_eq!(
670 resolved.replay_store_path,
671 Some(default_replay_store_path()),
672 "replay_store_path persists by default (sibling of run_store_path)"
673 );
674 }
675
676 #[test]
677 fn resolve_replay_store_path_ephemeral_restores_in_memory() {
678 let cli = CliOverrides {
679 ephemeral: Some(true),
680 ..Default::default()
681 };
682 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
683 assert_eq!(resolved.replay_store_path, None);
684 }
685
686 #[test]
687 fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
688 let cli = CliOverrides {
689 replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
690 ephemeral: Some(true),
691 ..Default::default()
692 };
693 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
694 assert_eq!(
695 resolved.replay_store_path,
696 Some(PathBuf::from("/tmp/explicit-replay.db")),
697 "explicit replay path must win over ephemeral"
698 );
699 }
700
701 #[test]
706 fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
707 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
708 assert_eq!(resolved.sync_timeout_secs, 3600);
709 assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
710 }
711
712 #[test]
713 fn resolve_sync_timeout_secs_file_wins_over_default() {
714 let file = FileConfig {
715 sync_timeout_secs: Some(120),
716 ..Default::default()
717 };
718 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
719 assert_eq!(resolved.sync_timeout_secs, 120);
720 }
721
722 #[test]
723 fn resolve_sync_timeout_secs_cli_wins_over_file() {
724 let cli = CliOverrides {
725 sync_timeout_secs: Some(45),
726 ..Default::default()
727 };
728 let file = FileConfig {
729 sync_timeout_secs: Some(120),
730 ..Default::default()
731 };
732 let resolved = resolve(cli, file).expect("resolve");
733 assert_eq!(
734 resolved.sync_timeout_secs, 45,
735 "cli sync_timeout_secs must win over file"
736 );
737 }
738
739 #[test]
744 fn resolve_check_policy_default_when_cli_and_file_absent() {
745 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
746 assert_eq!(
747 resolved.check_policy,
748 CheckPolicy::Warn,
749 "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
750 );
751 assert_eq!(resolved.check_policy, CheckPolicy::default());
752 }
753
754 #[test]
755 fn resolve_check_policy_file_wins_over_default() {
756 let file = FileConfig {
757 check_policy: Some(CheckPolicy::Strict),
758 ..Default::default()
759 };
760 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
761 assert_eq!(
762 resolved.check_policy,
763 CheckPolicy::Strict,
764 "file check_policy must win over built-in default"
765 );
766 }
767
768 #[test]
769 fn resolve_check_policy_cli_wins_over_file() {
770 let cli = CliOverrides {
771 check_policy: Some(CheckPolicy::Silent),
772 ..Default::default()
773 };
774 let file = FileConfig {
775 check_policy: Some(CheckPolicy::Strict),
776 ..Default::default()
777 };
778 let resolved = resolve(cli, file).expect("resolve");
779 assert_eq!(
780 resolved.check_policy,
781 CheckPolicy::Silent,
782 "cli check_policy must win over file"
783 );
784 }
785
786 #[test]
791 fn resolve_blueprint_strict_embed_default_false_when_cli_and_file_absent() {
792 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
793 assert!(
794 !resolved.blueprint_strict_embed,
795 "default blueprint_strict_embed = false (backward-compat: linker runs server-side)"
796 );
797 }
798
799 #[test]
800 fn resolve_blueprint_strict_embed_file_wins_over_default() {
801 let file = FileConfig {
802 blueprint_strict_embed: Some(true),
803 ..Default::default()
804 };
805 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
806 assert!(resolved.blueprint_strict_embed);
807 }
808
809 #[test]
810 fn resolve_blueprint_strict_embed_cli_wins_over_file() {
811 let cli = CliOverrides {
812 blueprint_strict_embed: Some(false),
813 ..Default::default()
814 };
815 let file = FileConfig {
816 blueprint_strict_embed: Some(true),
817 ..Default::default()
818 };
819 let resolved = resolve(cli, file).expect("resolve");
820 assert!(
821 !resolved.blueprint_strict_embed,
822 "cli blueprint_strict_embed=false must win over file=true"
823 );
824 }
825
826 #[test]
827 fn file_config_deserializes_blueprint_strict_embed() {
828 let toml_text = "blueprint_strict_embed = true\n";
829 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
830 assert_eq!(cfg.blueprint_strict_embed, Some(true));
831 }
832
833 #[test]
834 fn file_config_deserializes_check_policy_snake_case_literals() {
835 let toml_text = "check_policy = \"strict\"\n";
836 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
837 assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
838
839 let toml_text = "check_policy = \"silent\"\n";
840 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
841 assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
842
843 let toml_text = "check_policy = \"warn\"\n";
844 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
845 assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
846 }
847}