1use mlua_swarm::core::config::CheckPolicy;
14use serde::Deserialize;
15use std::net::SocketAddr;
16use std::path::{Path, PathBuf};
17
18pub 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
27pub 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
38pub 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
51pub 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
63pub 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#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct FileConfig {
82 pub bind: Option<String>,
84 pub enable_enhance_flow: Option<bool>,
86 pub blueprint_ref_base: Option<PathBuf>,
88 pub blueprint_ref_includes: Option<Vec<PathBuf>>,
93 pub blueprint_strict_embed: Option<bool>,
102 pub git_store_path: Option<PathBuf>,
104 pub issue_store_path: Option<PathBuf>,
107 pub enhance_setting_store_path: Option<PathBuf>,
110 pub enhance_log_store_path: Option<PathBuf>,
113 pub output_store_path: Option<PathBuf>,
116 pub task_store_path: Option<PathBuf>,
120 pub run_store_path: Option<PathBuf>,
123 pub replay_store_path: Option<PathBuf>,
129 pub ephemeral: Option<bool>,
135 pub seed_blueprint_id: Option<String>,
137 pub default_agent_kind: Option<String>,
140 pub token_secret: Option<String>,
142 pub sync_timeout_secs: Option<u64>,
148 pub check_policy: Option<CheckPolicy>,
161}
162
163#[derive(Debug, Default, Clone)]
167pub struct CliOverrides {
168 pub bind: Option<String>,
170 pub enable_enhance_flow: Option<bool>,
172 pub blueprint_ref_base: Option<PathBuf>,
174 pub blueprint_ref_includes: Vec<PathBuf>,
177 pub blueprint_strict_embed: Option<bool>,
180 pub git_store_path: Option<PathBuf>,
182 pub issue_store_path: Option<PathBuf>,
184 pub enhance_setting_store_path: Option<PathBuf>,
186 pub enhance_log_store_path: Option<PathBuf>,
188 pub output_store_path: Option<PathBuf>,
190 pub task_store_path: Option<PathBuf>,
192 pub run_store_path: Option<PathBuf>,
194 pub replay_store_path: Option<PathBuf>,
196 pub ephemeral: Option<bool>,
198 pub seed_blueprint_id: Option<String>,
200 pub default_agent_kind: Option<String>,
202 pub token_secret: Option<String>,
204 pub sync_timeout_secs: Option<u64>,
206 pub check_policy: Option<CheckPolicy>,
210}
211
212#[derive(Debug, Clone, PartialEq)]
214pub struct ResolvedConfig {
215 pub bind: SocketAddr,
217 pub enable_enhance_flow: bool,
219 pub blueprint_ref_base: Option<PathBuf>,
221 pub blueprint_ref_includes: Vec<PathBuf>,
225 pub blueprint_strict_embed: bool,
231 pub git_store_path: PathBuf,
235 pub issue_store_path: Option<PathBuf>,
238 pub enhance_setting_store_path: Option<PathBuf>,
241 pub enhance_log_store_path: Option<PathBuf>,
244 pub output_store_path: Option<PathBuf>,
247 pub task_store_path: Option<PathBuf>,
250 pub run_store_path: Option<PathBuf>,
253 pub replay_store_path: Option<PathBuf>,
256 pub seed_blueprint_id: String,
258 pub default_agent_kind: Option<String>,
261 pub token_secret: Option<String>,
263 pub sync_timeout_secs: u64,
269 pub check_policy: CheckPolicy,
276}
277
278impl Default for ResolvedConfig {
279 fn default() -> Self {
280 Self {
281 bind: default_bind(),
282 enable_enhance_flow: false,
283 blueprint_ref_base: None,
284 blueprint_ref_includes: Vec::new(),
285 blueprint_strict_embed: false,
286 git_store_path: default_store_path(),
287 issue_store_path: None,
288 enhance_setting_store_path: None,
289 enhance_log_store_path: None,
290 output_store_path: None,
291 task_store_path: None,
292 run_store_path: None,
293 replay_store_path: None,
294 seed_blueprint_id: "main".into(),
295 default_agent_kind: None,
296 token_secret: None,
297 sync_timeout_secs: default_sync_timeout_secs(),
298 check_policy: CheckPolicy::default(),
299 }
300 }
301}
302
303pub fn default_sync_timeout_secs() -> u64 {
312 3600
313}
314
315fn default_bind() -> SocketAddr {
316 "127.0.0.1:7777"
317 .parse()
318 .expect("literal default bind must parse")
319}
320
321pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
326 match std::fs::read_to_string(path) {
327 Ok(text) => toml::from_str(&text)
328 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
329 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
330 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
331 }
332}
333
334pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
337 let default = ResolvedConfig::default();
338
339 let bind = match cli.bind.or(file.bind) {
340 Some(s) => s
341 .parse::<SocketAddr>()
342 .map_err(|e| format!("bind {s:?}: {e}"))?,
343 None => default.bind,
344 };
345
346 let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
347
348 Ok(ResolvedConfig {
349 bind,
350 enable_enhance_flow: cli
351 .enable_enhance_flow
352 .or(file.enable_enhance_flow)
353 .unwrap_or(default.enable_enhance_flow),
354 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
355 blueprint_ref_includes: {
356 let mut merged = cli.blueprint_ref_includes;
357 merged.extend(file.blueprint_ref_includes.unwrap_or_default());
358 merged
359 },
360 blueprint_strict_embed: cli
361 .blueprint_strict_embed
362 .or(file.blueprint_strict_embed)
363 .unwrap_or(default.blueprint_strict_embed),
364 git_store_path: cli
365 .git_store_path
366 .or(file.git_store_path)
367 .unwrap_or_else(default_store_path),
368 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
369 enhance_setting_store_path: cli
370 .enhance_setting_store_path
371 .or(file.enhance_setting_store_path),
372 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
373 output_store_path: cli.output_store_path.or(file.output_store_path),
374 task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
375 if ephemeral {
376 None
377 } else {
378 Some(default_task_store_path())
379 }
380 }),
381 run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
382 if ephemeral {
383 None
384 } else {
385 Some(default_run_store_path())
386 }
387 }),
388 replay_store_path: cli
389 .replay_store_path
390 .or(file.replay_store_path)
391 .or_else(|| {
392 if ephemeral {
393 None
394 } else {
395 Some(default_replay_store_path())
396 }
397 }),
398 seed_blueprint_id: cli
399 .seed_blueprint_id
400 .or(file.seed_blueprint_id)
401 .unwrap_or(default.seed_blueprint_id),
402 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
403 token_secret: cli.token_secret.or(file.token_secret),
404 sync_timeout_secs: cli
405 .sync_timeout_secs
406 .or(file.sync_timeout_secs)
407 .unwrap_or_else(default_sync_timeout_secs),
408 check_policy: cli
409 .check_policy
410 .or(file.check_policy)
411 .unwrap_or(default.check_policy),
412 })
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn resolve_cli_flag_wins_over_file_and_default() {
421 let cli = CliOverrides {
422 bind: Some("127.0.0.1:9999".into()),
423 ..Default::default()
424 };
425 let file = FileConfig {
426 bind: Some("127.0.0.1:8888".into()),
427 ..Default::default()
428 };
429 let resolved = resolve(cli, file).expect("resolve");
430 assert_eq!(
431 resolved.bind,
432 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
433 );
434 }
435
436 #[test]
437 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
438 let cli = CliOverrides::default();
439 let file = FileConfig {
440 seed_blueprint_id: Some("from-file".into()),
441 enable_enhance_flow: Some(true),
442 ..Default::default()
443 };
444 let resolved = resolve(cli, file).expect("resolve");
445 assert_eq!(resolved.seed_blueprint_id, "from-file");
446 assert!(resolved.enable_enhance_flow);
447 }
448
449 #[test]
450 fn resolve_built_in_default_when_cli_and_file_absent() {
451 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
452 assert_eq!(resolved.bind, default_bind());
453 assert_eq!(resolved.seed_blueprint_id, "main");
454 assert!(!resolved.enable_enhance_flow);
455 assert_eq!(resolved.git_store_path, default_store_path());
456 }
457
458 #[test]
459 fn resolve_git_store_path_file_overrides_default_location() {
460 let file = FileConfig {
461 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
462 ..Default::default()
463 };
464 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
465 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
466 }
467
468 #[test]
469 fn resolve_bind_parse_error_is_propagated() {
470 let cli = CliOverrides {
471 bind: Some("not-a-valid-addr".into()),
472 ..Default::default()
473 };
474 let err = resolve(cli, FileConfig::default()).unwrap_err();
475 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
476 }
477
478 #[test]
479 fn load_file_config_rejects_unknown_fields() {
480 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
481 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
482 let msg = err.to_string();
483 assert!(
484 msg.contains("typo_field") || msg.contains("unknown field"),
485 "unexpected error message: {msg}"
486 );
487 }
488
489 #[test]
490 fn load_file_config_missing_file_falls_back_to_default() {
491 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
492 let cfg = load_file_config(path).expect("missing file should not error");
493 assert_eq!(cfg, FileConfig::default());
494 }
495
496 #[test]
497 fn load_file_config_parses_valid_toml() {
498 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
499 std::fs::create_dir_all(&dir).expect("create tmp dir");
500 let path = dir.join("config.toml");
501 std::fs::write(
502 &path,
503 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
504 )
505 .expect("write tmp config");
506 let cfg = load_file_config(&path).expect("parse tmp config");
507 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
508 assert_eq!(cfg.enable_enhance_flow, Some(true));
509 let _ = std::fs::remove_dir_all(&dir);
510 }
511
512 #[test]
513 fn resolve_task_and_run_store_path_cli_wins_over_file() {
514 let cli = CliOverrides {
515 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
516 ..Default::default()
517 };
518 let file = FileConfig {
519 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
520 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
521 ..Default::default()
522 };
523 let resolved = resolve(cli, file).expect("resolve");
524 assert_eq!(
525 resolved.task_store_path,
526 Some(PathBuf::from("/tmp/cli-tasks.db")),
527 "cli task_store_path must win over file"
528 );
529 assert_eq!(
530 resolved.run_store_path,
531 Some(PathBuf::from("/tmp/file-runs.db")),
532 "run_store_path falls back to file when cli is absent"
533 );
534 }
535
536 #[test]
537 fn resolve_task_and_run_store_path_default_none() {
538 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
539 assert_eq!(
540 resolved.task_store_path,
541 Some(default_task_store_path()),
542 "issue #35 ST1: task_store_path now persists by default"
543 );
544 assert_eq!(
545 resolved.run_store_path,
546 Some(default_run_store_path()),
547 "issue #35 ST1: run_store_path now persists by default"
548 );
549 }
550
551 #[test]
552 fn resolve_ephemeral_true_restores_in_memory_default() {
553 let cli = CliOverrides {
554 ephemeral: Some(true),
555 ..Default::default()
556 };
557 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
558 assert_eq!(resolved.task_store_path, None);
559 assert_eq!(resolved.run_store_path, None);
560 }
561
562 #[test]
563 fn resolve_explicit_path_wins_over_ephemeral() {
564 let cli = CliOverrides {
565 task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
566 ephemeral: Some(true),
567 ..Default::default()
568 };
569 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
570 assert_eq!(
571 resolved.task_store_path,
572 Some(PathBuf::from("/tmp/explicit-tasks.db")),
573 "explicit path must win over ephemeral"
574 );
575 }
576
577 #[test]
578 fn resolve_ephemeral_from_file_config() {
579 let file = FileConfig {
580 ephemeral: Some(true),
581 ..Default::default()
582 };
583 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
584 assert_eq!(resolved.task_store_path, None);
585 assert_eq!(resolved.run_store_path, None);
586 assert_eq!(resolved.replay_store_path, None);
587 }
588
589 #[test]
594 fn resolve_replay_store_path_cli_wins_over_file() {
595 let cli = CliOverrides {
596 replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
597 ..Default::default()
598 };
599 let file = FileConfig {
600 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
601 ..Default::default()
602 };
603 let resolved = resolve(cli, file).expect("resolve");
604 assert_eq!(
605 resolved.replay_store_path,
606 Some(PathBuf::from("/tmp/cli-replay.db")),
607 "cli replay_store_path must win over file"
608 );
609 }
610
611 #[test]
612 fn resolve_replay_store_path_file_wins_over_default() {
613 let file = FileConfig {
614 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
615 ..Default::default()
616 };
617 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
618 assert_eq!(
619 resolved.replay_store_path,
620 Some(PathBuf::from("/tmp/file-replay.db")),
621 "file replay_store_path must win over built-in default"
622 );
623 }
624
625 #[test]
626 fn resolve_replay_store_path_default_persists() {
627 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
628 assert_eq!(
629 resolved.replay_store_path,
630 Some(default_replay_store_path()),
631 "replay_store_path persists by default (sibling of run_store_path)"
632 );
633 }
634
635 #[test]
636 fn resolve_replay_store_path_ephemeral_restores_in_memory() {
637 let cli = CliOverrides {
638 ephemeral: Some(true),
639 ..Default::default()
640 };
641 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
642 assert_eq!(resolved.replay_store_path, None);
643 }
644
645 #[test]
646 fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
647 let cli = CliOverrides {
648 replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
649 ephemeral: Some(true),
650 ..Default::default()
651 };
652 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
653 assert_eq!(
654 resolved.replay_store_path,
655 Some(PathBuf::from("/tmp/explicit-replay.db")),
656 "explicit replay path must win over ephemeral"
657 );
658 }
659
660 #[test]
665 fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
666 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
667 assert_eq!(resolved.sync_timeout_secs, 3600);
668 assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
669 }
670
671 #[test]
672 fn resolve_sync_timeout_secs_file_wins_over_default() {
673 let file = FileConfig {
674 sync_timeout_secs: Some(120),
675 ..Default::default()
676 };
677 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
678 assert_eq!(resolved.sync_timeout_secs, 120);
679 }
680
681 #[test]
682 fn resolve_sync_timeout_secs_cli_wins_over_file() {
683 let cli = CliOverrides {
684 sync_timeout_secs: Some(45),
685 ..Default::default()
686 };
687 let file = FileConfig {
688 sync_timeout_secs: Some(120),
689 ..Default::default()
690 };
691 let resolved = resolve(cli, file).expect("resolve");
692 assert_eq!(
693 resolved.sync_timeout_secs, 45,
694 "cli sync_timeout_secs must win over file"
695 );
696 }
697
698 #[test]
703 fn resolve_check_policy_default_when_cli_and_file_absent() {
704 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
705 assert_eq!(
706 resolved.check_policy,
707 CheckPolicy::Warn,
708 "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
709 );
710 assert_eq!(resolved.check_policy, CheckPolicy::default());
711 }
712
713 #[test]
714 fn resolve_check_policy_file_wins_over_default() {
715 let file = FileConfig {
716 check_policy: Some(CheckPolicy::Strict),
717 ..Default::default()
718 };
719 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
720 assert_eq!(
721 resolved.check_policy,
722 CheckPolicy::Strict,
723 "file check_policy must win over built-in default"
724 );
725 }
726
727 #[test]
728 fn resolve_check_policy_cli_wins_over_file() {
729 let cli = CliOverrides {
730 check_policy: Some(CheckPolicy::Silent),
731 ..Default::default()
732 };
733 let file = FileConfig {
734 check_policy: Some(CheckPolicy::Strict),
735 ..Default::default()
736 };
737 let resolved = resolve(cli, file).expect("resolve");
738 assert_eq!(
739 resolved.check_policy,
740 CheckPolicy::Silent,
741 "cli check_policy must win over file"
742 );
743 }
744
745 #[test]
750 fn resolve_blueprint_strict_embed_default_false_when_cli_and_file_absent() {
751 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
752 assert!(
753 !resolved.blueprint_strict_embed,
754 "default blueprint_strict_embed = false (backward-compat: linker runs server-side)"
755 );
756 }
757
758 #[test]
759 fn resolve_blueprint_strict_embed_file_wins_over_default() {
760 let file = FileConfig {
761 blueprint_strict_embed: Some(true),
762 ..Default::default()
763 };
764 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
765 assert!(resolved.blueprint_strict_embed);
766 }
767
768 #[test]
769 fn resolve_blueprint_strict_embed_cli_wins_over_file() {
770 let cli = CliOverrides {
771 blueprint_strict_embed: Some(false),
772 ..Default::default()
773 };
774 let file = FileConfig {
775 blueprint_strict_embed: Some(true),
776 ..Default::default()
777 };
778 let resolved = resolve(cli, file).expect("resolve");
779 assert!(
780 !resolved.blueprint_strict_embed,
781 "cli blueprint_strict_embed=false must win over file=true"
782 );
783 }
784
785 #[test]
786 fn file_config_deserializes_blueprint_strict_embed() {
787 let toml_text = "blueprint_strict_embed = true\n";
788 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
789 assert_eq!(cfg.blueprint_strict_embed, Some(true));
790 }
791
792 #[test]
793 fn file_config_deserializes_check_policy_snake_case_literals() {
794 let toml_text = "check_policy = \"strict\"\n";
795 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
796 assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
797
798 let toml_text = "check_policy = \"silent\"\n";
799 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
800 assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
801
802 let toml_text = "check_policy = \"warn\"\n";
803 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
804 assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
805 }
806}