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 git_store_path: Option<PathBuf>,
90 pub issue_store_path: Option<PathBuf>,
93 pub enhance_setting_store_path: Option<PathBuf>,
96 pub enhance_log_store_path: Option<PathBuf>,
99 pub output_store_path: Option<PathBuf>,
102 pub task_store_path: Option<PathBuf>,
106 pub run_store_path: Option<PathBuf>,
109 pub replay_store_path: Option<PathBuf>,
115 pub ephemeral: Option<bool>,
121 pub seed_blueprint_id: Option<String>,
123 pub default_agent_kind: Option<String>,
126 pub token_secret: Option<String>,
128 pub sync_timeout_secs: Option<u64>,
134 pub check_policy: Option<CheckPolicy>,
147}
148
149#[derive(Debug, Default, Clone)]
153pub struct CliOverrides {
154 pub bind: Option<String>,
156 pub enable_enhance_flow: Option<bool>,
158 pub blueprint_ref_base: Option<PathBuf>,
160 pub git_store_path: Option<PathBuf>,
162 pub issue_store_path: Option<PathBuf>,
164 pub enhance_setting_store_path: Option<PathBuf>,
166 pub enhance_log_store_path: Option<PathBuf>,
168 pub output_store_path: Option<PathBuf>,
170 pub task_store_path: Option<PathBuf>,
172 pub run_store_path: Option<PathBuf>,
174 pub replay_store_path: Option<PathBuf>,
176 pub ephemeral: Option<bool>,
178 pub seed_blueprint_id: Option<String>,
180 pub default_agent_kind: Option<String>,
182 pub token_secret: Option<String>,
184 pub sync_timeout_secs: Option<u64>,
186 pub check_policy: Option<CheckPolicy>,
190}
191
192#[derive(Debug, Clone, PartialEq)]
194pub struct ResolvedConfig {
195 pub bind: SocketAddr,
197 pub enable_enhance_flow: bool,
199 pub blueprint_ref_base: Option<PathBuf>,
201 pub git_store_path: PathBuf,
205 pub issue_store_path: Option<PathBuf>,
208 pub enhance_setting_store_path: Option<PathBuf>,
211 pub enhance_log_store_path: Option<PathBuf>,
214 pub output_store_path: Option<PathBuf>,
217 pub task_store_path: Option<PathBuf>,
220 pub run_store_path: Option<PathBuf>,
223 pub replay_store_path: Option<PathBuf>,
226 pub seed_blueprint_id: String,
228 pub default_agent_kind: Option<String>,
231 pub token_secret: Option<String>,
233 pub sync_timeout_secs: u64,
239 pub check_policy: CheckPolicy,
246}
247
248impl Default for ResolvedConfig {
249 fn default() -> Self {
250 Self {
251 bind: default_bind(),
252 enable_enhance_flow: false,
253 blueprint_ref_base: None,
254 git_store_path: default_store_path(),
255 issue_store_path: None,
256 enhance_setting_store_path: None,
257 enhance_log_store_path: None,
258 output_store_path: None,
259 task_store_path: None,
260 run_store_path: None,
261 replay_store_path: None,
262 seed_blueprint_id: "main".into(),
263 default_agent_kind: None,
264 token_secret: None,
265 sync_timeout_secs: default_sync_timeout_secs(),
266 check_policy: CheckPolicy::default(),
267 }
268 }
269}
270
271pub fn default_sync_timeout_secs() -> u64 {
280 3600
281}
282
283fn default_bind() -> SocketAddr {
284 "127.0.0.1:7777"
285 .parse()
286 .expect("literal default bind must parse")
287}
288
289pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
294 match std::fs::read_to_string(path) {
295 Ok(text) => toml::from_str(&text)
296 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
297 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
298 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
299 }
300}
301
302pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
305 let default = ResolvedConfig::default();
306
307 let bind = match cli.bind.or(file.bind) {
308 Some(s) => s
309 .parse::<SocketAddr>()
310 .map_err(|e| format!("bind {s:?}: {e}"))?,
311 None => default.bind,
312 };
313
314 let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
315
316 Ok(ResolvedConfig {
317 bind,
318 enable_enhance_flow: cli
319 .enable_enhance_flow
320 .or(file.enable_enhance_flow)
321 .unwrap_or(default.enable_enhance_flow),
322 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
323 git_store_path: cli
324 .git_store_path
325 .or(file.git_store_path)
326 .unwrap_or_else(default_store_path),
327 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
328 enhance_setting_store_path: cli
329 .enhance_setting_store_path
330 .or(file.enhance_setting_store_path),
331 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
332 output_store_path: cli.output_store_path.or(file.output_store_path),
333 task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
334 if ephemeral {
335 None
336 } else {
337 Some(default_task_store_path())
338 }
339 }),
340 run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
341 if ephemeral {
342 None
343 } else {
344 Some(default_run_store_path())
345 }
346 }),
347 replay_store_path: cli
348 .replay_store_path
349 .or(file.replay_store_path)
350 .or_else(|| {
351 if ephemeral {
352 None
353 } else {
354 Some(default_replay_store_path())
355 }
356 }),
357 seed_blueprint_id: cli
358 .seed_blueprint_id
359 .or(file.seed_blueprint_id)
360 .unwrap_or(default.seed_blueprint_id),
361 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
362 token_secret: cli.token_secret.or(file.token_secret),
363 sync_timeout_secs: cli
364 .sync_timeout_secs
365 .or(file.sync_timeout_secs)
366 .unwrap_or_else(default_sync_timeout_secs),
367 check_policy: cli
368 .check_policy
369 .or(file.check_policy)
370 .unwrap_or(default.check_policy),
371 })
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn resolve_cli_flag_wins_over_file_and_default() {
380 let cli = CliOverrides {
381 bind: Some("127.0.0.1:9999".into()),
382 ..Default::default()
383 };
384 let file = FileConfig {
385 bind: Some("127.0.0.1:8888".into()),
386 ..Default::default()
387 };
388 let resolved = resolve(cli, file).expect("resolve");
389 assert_eq!(
390 resolved.bind,
391 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
392 );
393 }
394
395 #[test]
396 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
397 let cli = CliOverrides::default();
398 let file = FileConfig {
399 seed_blueprint_id: Some("from-file".into()),
400 enable_enhance_flow: Some(true),
401 ..Default::default()
402 };
403 let resolved = resolve(cli, file).expect("resolve");
404 assert_eq!(resolved.seed_blueprint_id, "from-file");
405 assert!(resolved.enable_enhance_flow);
406 }
407
408 #[test]
409 fn resolve_built_in_default_when_cli_and_file_absent() {
410 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
411 assert_eq!(resolved.bind, default_bind());
412 assert_eq!(resolved.seed_blueprint_id, "main");
413 assert!(!resolved.enable_enhance_flow);
414 assert_eq!(resolved.git_store_path, default_store_path());
415 }
416
417 #[test]
418 fn resolve_git_store_path_file_overrides_default_location() {
419 let file = FileConfig {
420 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
421 ..Default::default()
422 };
423 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
424 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
425 }
426
427 #[test]
428 fn resolve_bind_parse_error_is_propagated() {
429 let cli = CliOverrides {
430 bind: Some("not-a-valid-addr".into()),
431 ..Default::default()
432 };
433 let err = resolve(cli, FileConfig::default()).unwrap_err();
434 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
435 }
436
437 #[test]
438 fn load_file_config_rejects_unknown_fields() {
439 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
440 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
441 let msg = err.to_string();
442 assert!(
443 msg.contains("typo_field") || msg.contains("unknown field"),
444 "unexpected error message: {msg}"
445 );
446 }
447
448 #[test]
449 fn load_file_config_missing_file_falls_back_to_default() {
450 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
451 let cfg = load_file_config(path).expect("missing file should not error");
452 assert_eq!(cfg, FileConfig::default());
453 }
454
455 #[test]
456 fn load_file_config_parses_valid_toml() {
457 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
458 std::fs::create_dir_all(&dir).expect("create tmp dir");
459 let path = dir.join("config.toml");
460 std::fs::write(
461 &path,
462 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
463 )
464 .expect("write tmp config");
465 let cfg = load_file_config(&path).expect("parse tmp config");
466 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
467 assert_eq!(cfg.enable_enhance_flow, Some(true));
468 let _ = std::fs::remove_dir_all(&dir);
469 }
470
471 #[test]
472 fn resolve_task_and_run_store_path_cli_wins_over_file() {
473 let cli = CliOverrides {
474 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
475 ..Default::default()
476 };
477 let file = FileConfig {
478 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
479 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
480 ..Default::default()
481 };
482 let resolved = resolve(cli, file).expect("resolve");
483 assert_eq!(
484 resolved.task_store_path,
485 Some(PathBuf::from("/tmp/cli-tasks.db")),
486 "cli task_store_path must win over file"
487 );
488 assert_eq!(
489 resolved.run_store_path,
490 Some(PathBuf::from("/tmp/file-runs.db")),
491 "run_store_path falls back to file when cli is absent"
492 );
493 }
494
495 #[test]
496 fn resolve_task_and_run_store_path_default_none() {
497 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
498 assert_eq!(
499 resolved.task_store_path,
500 Some(default_task_store_path()),
501 "issue #35 ST1: task_store_path now persists by default"
502 );
503 assert_eq!(
504 resolved.run_store_path,
505 Some(default_run_store_path()),
506 "issue #35 ST1: run_store_path now persists by default"
507 );
508 }
509
510 #[test]
511 fn resolve_ephemeral_true_restores_in_memory_default() {
512 let cli = CliOverrides {
513 ephemeral: Some(true),
514 ..Default::default()
515 };
516 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
517 assert_eq!(resolved.task_store_path, None);
518 assert_eq!(resolved.run_store_path, None);
519 }
520
521 #[test]
522 fn resolve_explicit_path_wins_over_ephemeral() {
523 let cli = CliOverrides {
524 task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
525 ephemeral: Some(true),
526 ..Default::default()
527 };
528 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
529 assert_eq!(
530 resolved.task_store_path,
531 Some(PathBuf::from("/tmp/explicit-tasks.db")),
532 "explicit path must win over ephemeral"
533 );
534 }
535
536 #[test]
537 fn resolve_ephemeral_from_file_config() {
538 let file = FileConfig {
539 ephemeral: Some(true),
540 ..Default::default()
541 };
542 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
543 assert_eq!(resolved.task_store_path, None);
544 assert_eq!(resolved.run_store_path, None);
545 assert_eq!(resolved.replay_store_path, None);
546 }
547
548 #[test]
553 fn resolve_replay_store_path_cli_wins_over_file() {
554 let cli = CliOverrides {
555 replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
556 ..Default::default()
557 };
558 let file = FileConfig {
559 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
560 ..Default::default()
561 };
562 let resolved = resolve(cli, file).expect("resolve");
563 assert_eq!(
564 resolved.replay_store_path,
565 Some(PathBuf::from("/tmp/cli-replay.db")),
566 "cli replay_store_path must win over file"
567 );
568 }
569
570 #[test]
571 fn resolve_replay_store_path_file_wins_over_default() {
572 let file = FileConfig {
573 replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
574 ..Default::default()
575 };
576 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
577 assert_eq!(
578 resolved.replay_store_path,
579 Some(PathBuf::from("/tmp/file-replay.db")),
580 "file replay_store_path must win over built-in default"
581 );
582 }
583
584 #[test]
585 fn resolve_replay_store_path_default_persists() {
586 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
587 assert_eq!(
588 resolved.replay_store_path,
589 Some(default_replay_store_path()),
590 "replay_store_path persists by default (sibling of run_store_path)"
591 );
592 }
593
594 #[test]
595 fn resolve_replay_store_path_ephemeral_restores_in_memory() {
596 let cli = CliOverrides {
597 ephemeral: Some(true),
598 ..Default::default()
599 };
600 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
601 assert_eq!(resolved.replay_store_path, None);
602 }
603
604 #[test]
605 fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
606 let cli = CliOverrides {
607 replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
608 ephemeral: Some(true),
609 ..Default::default()
610 };
611 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
612 assert_eq!(
613 resolved.replay_store_path,
614 Some(PathBuf::from("/tmp/explicit-replay.db")),
615 "explicit replay path must win over ephemeral"
616 );
617 }
618
619 #[test]
624 fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
625 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
626 assert_eq!(resolved.sync_timeout_secs, 3600);
627 assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
628 }
629
630 #[test]
631 fn resolve_sync_timeout_secs_file_wins_over_default() {
632 let file = FileConfig {
633 sync_timeout_secs: Some(120),
634 ..Default::default()
635 };
636 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
637 assert_eq!(resolved.sync_timeout_secs, 120);
638 }
639
640 #[test]
641 fn resolve_sync_timeout_secs_cli_wins_over_file() {
642 let cli = CliOverrides {
643 sync_timeout_secs: Some(45),
644 ..Default::default()
645 };
646 let file = FileConfig {
647 sync_timeout_secs: Some(120),
648 ..Default::default()
649 };
650 let resolved = resolve(cli, file).expect("resolve");
651 assert_eq!(
652 resolved.sync_timeout_secs, 45,
653 "cli sync_timeout_secs must win over file"
654 );
655 }
656
657 #[test]
662 fn resolve_check_policy_default_when_cli_and_file_absent() {
663 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
664 assert_eq!(
665 resolved.check_policy,
666 CheckPolicy::Warn,
667 "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
668 );
669 assert_eq!(resolved.check_policy, CheckPolicy::default());
670 }
671
672 #[test]
673 fn resolve_check_policy_file_wins_over_default() {
674 let file = FileConfig {
675 check_policy: Some(CheckPolicy::Strict),
676 ..Default::default()
677 };
678 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
679 assert_eq!(
680 resolved.check_policy,
681 CheckPolicy::Strict,
682 "file check_policy must win over built-in default"
683 );
684 }
685
686 #[test]
687 fn resolve_check_policy_cli_wins_over_file() {
688 let cli = CliOverrides {
689 check_policy: Some(CheckPolicy::Silent),
690 ..Default::default()
691 };
692 let file = FileConfig {
693 check_policy: Some(CheckPolicy::Strict),
694 ..Default::default()
695 };
696 let resolved = resolve(cli, file).expect("resolve");
697 assert_eq!(
698 resolved.check_policy,
699 CheckPolicy::Silent,
700 "cli check_policy must win over file"
701 );
702 }
703
704 #[test]
705 fn file_config_deserializes_check_policy_snake_case_literals() {
706 let toml_text = "check_policy = \"strict\"\n";
707 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
708 assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
709
710 let toml_text = "check_policy = \"silent\"\n";
711 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
712 assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
713
714 let toml_text = "check_policy = \"warn\"\n";
715 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
716 assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
717 }
718}