use mlua_swarm::core::config::CheckPolicy;
use mlua_swarm::LegacyWorkerBindingPolicy;
use serde::Deserialize;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
pub fn default_config_path() -> PathBuf {
match std::env::var("HOME") {
Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
Err(_) => PathBuf::from(".mse/config.toml"),
}
}
pub fn default_store_path() -> PathBuf {
match std::env::var("HOME") {
Ok(home) => PathBuf::from(home).join(".mse").join("store"),
Err(_) => PathBuf::from(".mse/store"),
}
}
pub fn default_task_store_path() -> PathBuf {
match std::env::var("HOME") {
Ok(home) => PathBuf::from(home)
.join(".mse")
.join("store")
.join("task.sqlite"),
Err(_) => PathBuf::from(".mse/store/task.sqlite"),
}
}
pub fn default_run_store_path() -> PathBuf {
match std::env::var("HOME") {
Ok(home) => PathBuf::from(home)
.join(".mse")
.join("store")
.join("run.sqlite"),
Err(_) => PathBuf::from(".mse/store/run.sqlite"),
}
}
pub fn default_replay_store_path() -> PathBuf {
match std::env::var("HOME") {
Ok(home) => PathBuf::from(home)
.join(".mse")
.join("store")
.join("replay.sqlite"),
Err(_) => PathBuf::from(".mse/store/replay.sqlite"),
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
pub bind: Option<String>,
pub enable_enhance_flow: Option<bool>,
pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
pub blueprint_ref_base: Option<PathBuf>,
pub blueprint_ref_includes: Option<Vec<PathBuf>>,
pub blueprint_strict_embed: Option<bool>,
pub git_store_path: Option<PathBuf>,
pub issue_store_path: Option<PathBuf>,
pub enhance_setting_store_path: Option<PathBuf>,
pub enhance_log_store_path: Option<PathBuf>,
pub output_store_path: Option<PathBuf>,
pub task_store_path: Option<PathBuf>,
pub run_store_path: Option<PathBuf>,
pub replay_store_path: Option<PathBuf>,
pub ephemeral: Option<bool>,
pub seed_blueprint_id: Option<String>,
pub default_agent_kind: Option<String>,
pub token_secret: Option<String>,
pub sync_timeout_secs: Option<u64>,
pub check_policy: Option<CheckPolicy>,
}
#[derive(Debug, Default, Clone)]
pub struct CliOverrides {
pub bind: Option<String>,
pub enable_enhance_flow: Option<bool>,
pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
pub blueprint_ref_base: Option<PathBuf>,
pub blueprint_ref_includes: Vec<PathBuf>,
pub blueprint_strict_embed: Option<bool>,
pub git_store_path: Option<PathBuf>,
pub issue_store_path: Option<PathBuf>,
pub enhance_setting_store_path: Option<PathBuf>,
pub enhance_log_store_path: Option<PathBuf>,
pub output_store_path: Option<PathBuf>,
pub task_store_path: Option<PathBuf>,
pub run_store_path: Option<PathBuf>,
pub replay_store_path: Option<PathBuf>,
pub ephemeral: Option<bool>,
pub seed_blueprint_id: Option<String>,
pub default_agent_kind: Option<String>,
pub token_secret: Option<String>,
pub sync_timeout_secs: Option<u64>,
pub check_policy: Option<CheckPolicy>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfig {
pub bind: SocketAddr,
pub enable_enhance_flow: bool,
pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
pub blueprint_ref_base: Option<PathBuf>,
pub blueprint_ref_includes: Vec<PathBuf>,
pub blueprint_strict_embed: bool,
pub git_store_path: PathBuf,
pub issue_store_path: Option<PathBuf>,
pub enhance_setting_store_path: Option<PathBuf>,
pub enhance_log_store_path: Option<PathBuf>,
pub output_store_path: Option<PathBuf>,
pub task_store_path: Option<PathBuf>,
pub run_store_path: Option<PathBuf>,
pub replay_store_path: Option<PathBuf>,
pub seed_blueprint_id: String,
pub default_agent_kind: Option<String>,
pub token_secret: Option<String>,
pub sync_timeout_secs: u64,
pub check_policy: CheckPolicy,
}
impl Default for ResolvedConfig {
fn default() -> Self {
Self {
bind: default_bind(),
enable_enhance_flow: false,
legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
blueprint_ref_base: None,
blueprint_ref_includes: Vec::new(),
blueprint_strict_embed: false,
git_store_path: default_store_path(),
issue_store_path: None,
enhance_setting_store_path: None,
enhance_log_store_path: None,
output_store_path: None,
task_store_path: None,
run_store_path: None,
replay_store_path: None,
seed_blueprint_id: "main".into(),
default_agent_kind: None,
token_secret: None,
sync_timeout_secs: default_sync_timeout_secs(),
check_policy: CheckPolicy::default(),
}
}
}
pub fn default_sync_timeout_secs() -> u64 {
3600
}
fn default_bind() -> SocketAddr {
"127.0.0.1:7777"
.parse()
.expect("literal default bind must parse")
}
pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text)
.map_err(|e| format!("config file {} parse error: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
Err(e) => Err(format!("config file {} read error: {e}", path.display())),
}
}
pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
let default = ResolvedConfig::default();
let bind = match cli.bind.or(file.bind) {
Some(s) => s
.parse::<SocketAddr>()
.map_err(|e| format!("bind {s:?}: {e}"))?,
None => default.bind,
};
let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
Ok(ResolvedConfig {
bind,
enable_enhance_flow: cli
.enable_enhance_flow
.or(file.enable_enhance_flow)
.unwrap_or(default.enable_enhance_flow),
legacy_worker_binding_policy: cli
.legacy_worker_binding_policy
.or(file.legacy_worker_binding_policy)
.unwrap_or(default.legacy_worker_binding_policy),
blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
blueprint_ref_includes: {
let mut merged = cli.blueprint_ref_includes;
merged.extend(file.blueprint_ref_includes.unwrap_or_default());
merged
},
blueprint_strict_embed: cli
.blueprint_strict_embed
.or(file.blueprint_strict_embed)
.unwrap_or(default.blueprint_strict_embed),
git_store_path: cli
.git_store_path
.or(file.git_store_path)
.unwrap_or_else(default_store_path),
issue_store_path: cli.issue_store_path.or(file.issue_store_path),
enhance_setting_store_path: cli
.enhance_setting_store_path
.or(file.enhance_setting_store_path),
enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
output_store_path: cli.output_store_path.or(file.output_store_path),
task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
if ephemeral {
None
} else {
Some(default_task_store_path())
}
}),
run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
if ephemeral {
None
} else {
Some(default_run_store_path())
}
}),
replay_store_path: cli
.replay_store_path
.or(file.replay_store_path)
.or_else(|| {
if ephemeral {
None
} else {
Some(default_replay_store_path())
}
}),
seed_blueprint_id: cli
.seed_blueprint_id
.or(file.seed_blueprint_id)
.unwrap_or(default.seed_blueprint_id),
default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
token_secret: cli.token_secret.or(file.token_secret),
sync_timeout_secs: cli
.sync_timeout_secs
.or(file.sync_timeout_secs)
.unwrap_or_else(default_sync_timeout_secs),
check_policy: cli
.check_policy
.or(file.check_policy)
.unwrap_or(default.check_policy),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_cli_flag_wins_over_file_and_default() {
let cli = CliOverrides {
bind: Some("127.0.0.1:9999".into()),
..Default::default()
};
let file = FileConfig {
bind: Some("127.0.0.1:8888".into()),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert_eq!(
resolved.bind,
"127.0.0.1:9999".parse::<SocketAddr>().unwrap()
);
}
#[test]
fn resolve_file_wins_over_built_in_default_when_cli_absent() {
let cli = CliOverrides::default();
let file = FileConfig {
seed_blueprint_id: Some("from-file".into()),
enable_enhance_flow: Some(true),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert_eq!(resolved.seed_blueprint_id, "from-file");
assert!(resolved.enable_enhance_flow);
}
#[test]
fn resolve_legacy_worker_binding_policy_uses_cli_file_default_precedence() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).unwrap();
assert_eq!(
resolved.legacy_worker_binding_policy,
LegacyWorkerBindingPolicy::Allow
);
let file = FileConfig {
legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Reject),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file.clone()).unwrap();
assert_eq!(
resolved.legacy_worker_binding_policy,
LegacyWorkerBindingPolicy::Reject
);
let cli = CliOverrides {
legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Allow),
..Default::default()
};
let resolved = resolve(cli, file).unwrap();
assert_eq!(
resolved.legacy_worker_binding_policy,
LegacyWorkerBindingPolicy::Allow
);
}
#[test]
fn resolve_built_in_default_when_cli_and_file_absent() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
assert_eq!(resolved.bind, default_bind());
assert_eq!(resolved.seed_blueprint_id, "main");
assert!(!resolved.enable_enhance_flow);
assert_eq!(resolved.git_store_path, default_store_path());
}
#[test]
fn resolve_git_store_path_file_overrides_default_location() {
let file = FileConfig {
git_store_path: Some(PathBuf::from("/tmp/custom-store")),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file).expect("resolve");
assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
}
#[test]
fn resolve_bind_parse_error_is_propagated() {
let cli = CliOverrides {
bind: Some("not-a-valid-addr".into()),
..Default::default()
};
let err = resolve(cli, FileConfig::default()).unwrap_err();
assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
}
#[test]
fn load_file_config_rejects_unknown_fields() {
let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("typo_field") || msg.contains("unknown field"),
"unexpected error message: {msg}"
);
}
#[test]
fn load_file_config_missing_file_falls_back_to_default() {
let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
let cfg = load_file_config(path).expect("missing file should not error");
assert_eq!(cfg, FileConfig::default());
}
#[test]
fn load_file_config_parses_valid_toml() {
let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create tmp dir");
let path = dir.join("config.toml");
std::fs::write(
&path,
"bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
)
.expect("write tmp config");
let cfg = load_file_config(&path).expect("parse tmp config");
assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
assert_eq!(cfg.enable_enhance_flow, Some(true));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_and_run_store_path_cli_wins_over_file() {
let cli = CliOverrides {
task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
..Default::default()
};
let file = FileConfig {
task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert_eq!(
resolved.task_store_path,
Some(PathBuf::from("/tmp/cli-tasks.db")),
"cli task_store_path must win over file"
);
assert_eq!(
resolved.run_store_path,
Some(PathBuf::from("/tmp/file-runs.db")),
"run_store_path falls back to file when cli is absent"
);
}
#[test]
fn resolve_task_and_run_store_path_default_none() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
assert_eq!(
resolved.task_store_path,
Some(default_task_store_path()),
"issue #35 ST1: task_store_path now persists by default"
);
assert_eq!(
resolved.run_store_path,
Some(default_run_store_path()),
"issue #35 ST1: run_store_path now persists by default"
);
}
#[test]
fn resolve_ephemeral_true_restores_in_memory_default() {
let cli = CliOverrides {
ephemeral: Some(true),
..Default::default()
};
let resolved = resolve(cli, FileConfig::default()).expect("resolve");
assert_eq!(resolved.task_store_path, None);
assert_eq!(resolved.run_store_path, None);
}
#[test]
fn resolve_explicit_path_wins_over_ephemeral() {
let cli = CliOverrides {
task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
ephemeral: Some(true),
..Default::default()
};
let resolved = resolve(cli, FileConfig::default()).expect("resolve");
assert_eq!(
resolved.task_store_path,
Some(PathBuf::from("/tmp/explicit-tasks.db")),
"explicit path must win over ephemeral"
);
}
#[test]
fn resolve_ephemeral_from_file_config() {
let file = FileConfig {
ephemeral: Some(true),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file).expect("resolve");
assert_eq!(resolved.task_store_path, None);
assert_eq!(resolved.run_store_path, None);
assert_eq!(resolved.replay_store_path, None);
}
#[test]
fn resolve_replay_store_path_cli_wins_over_file() {
let cli = CliOverrides {
replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
..Default::default()
};
let file = FileConfig {
replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert_eq!(
resolved.replay_store_path,
Some(PathBuf::from("/tmp/cli-replay.db")),
"cli replay_store_path must win over file"
);
}
#[test]
fn resolve_replay_store_path_file_wins_over_default() {
let file = FileConfig {
replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file).expect("resolve");
assert_eq!(
resolved.replay_store_path,
Some(PathBuf::from("/tmp/file-replay.db")),
"file replay_store_path must win over built-in default"
);
}
#[test]
fn resolve_replay_store_path_default_persists() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
assert_eq!(
resolved.replay_store_path,
Some(default_replay_store_path()),
"replay_store_path persists by default (sibling of run_store_path)"
);
}
#[test]
fn resolve_replay_store_path_ephemeral_restores_in_memory() {
let cli = CliOverrides {
ephemeral: Some(true),
..Default::default()
};
let resolved = resolve(cli, FileConfig::default()).expect("resolve");
assert_eq!(resolved.replay_store_path, None);
}
#[test]
fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
let cli = CliOverrides {
replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
ephemeral: Some(true),
..Default::default()
};
let resolved = resolve(cli, FileConfig::default()).expect("resolve");
assert_eq!(
resolved.replay_store_path,
Some(PathBuf::from("/tmp/explicit-replay.db")),
"explicit replay path must win over ephemeral"
);
}
#[test]
fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
assert_eq!(resolved.sync_timeout_secs, 3600);
assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
}
#[test]
fn resolve_sync_timeout_secs_file_wins_over_default() {
let file = FileConfig {
sync_timeout_secs: Some(120),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file).expect("resolve");
assert_eq!(resolved.sync_timeout_secs, 120);
}
#[test]
fn resolve_sync_timeout_secs_cli_wins_over_file() {
let cli = CliOverrides {
sync_timeout_secs: Some(45),
..Default::default()
};
let file = FileConfig {
sync_timeout_secs: Some(120),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert_eq!(
resolved.sync_timeout_secs, 45,
"cli sync_timeout_secs must win over file"
);
}
#[test]
fn resolve_check_policy_default_when_cli_and_file_absent() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
assert_eq!(
resolved.check_policy,
CheckPolicy::Warn,
"default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
);
assert_eq!(resolved.check_policy, CheckPolicy::default());
}
#[test]
fn resolve_check_policy_file_wins_over_default() {
let file = FileConfig {
check_policy: Some(CheckPolicy::Strict),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file).expect("resolve");
assert_eq!(
resolved.check_policy,
CheckPolicy::Strict,
"file check_policy must win over built-in default"
);
}
#[test]
fn resolve_check_policy_cli_wins_over_file() {
let cli = CliOverrides {
check_policy: Some(CheckPolicy::Silent),
..Default::default()
};
let file = FileConfig {
check_policy: Some(CheckPolicy::Strict),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert_eq!(
resolved.check_policy,
CheckPolicy::Silent,
"cli check_policy must win over file"
);
}
#[test]
fn resolve_blueprint_strict_embed_default_false_when_cli_and_file_absent() {
let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
assert!(
!resolved.blueprint_strict_embed,
"default blueprint_strict_embed = false (backward-compat: linker runs server-side)"
);
}
#[test]
fn resolve_blueprint_strict_embed_file_wins_over_default() {
let file = FileConfig {
blueprint_strict_embed: Some(true),
..Default::default()
};
let resolved = resolve(CliOverrides::default(), file).expect("resolve");
assert!(resolved.blueprint_strict_embed);
}
#[test]
fn resolve_blueprint_strict_embed_cli_wins_over_file() {
let cli = CliOverrides {
blueprint_strict_embed: Some(false),
..Default::default()
};
let file = FileConfig {
blueprint_strict_embed: Some(true),
..Default::default()
};
let resolved = resolve(cli, file).expect("resolve");
assert!(
!resolved.blueprint_strict_embed,
"cli blueprint_strict_embed=false must win over file=true"
);
}
#[test]
fn file_config_deserializes_blueprint_strict_embed() {
let toml_text = "blueprint_strict_embed = true\n";
let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
assert_eq!(cfg.blueprint_strict_embed, Some(true));
}
#[test]
fn file_config_deserializes_check_policy_snake_case_literals() {
let toml_text = "check_policy = \"strict\"\n";
let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
let toml_text = "check_policy = \"silent\"\n";
let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
let toml_text = "check_policy = \"warn\"\n";
let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
}
}