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"),
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
pub bind: Option<String>,
pub enable_enhance_flow: Option<bool>,
pub blueprint_ref_base: Option<PathBuf>,
pub git_store_path: Option<PathBuf>,
pub seed_blueprint_id: Option<String>,
pub default_agent_kind: Option<String>,
pub token_secret: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct CliOverrides {
pub bind: Option<String>,
pub enable_enhance_flow: Option<bool>,
pub blueprint_ref_base: Option<PathBuf>,
pub git_store_path: Option<PathBuf>,
pub seed_blueprint_id: Option<String>,
pub default_agent_kind: Option<String>,
pub token_secret: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfig {
pub bind: SocketAddr,
pub enable_enhance_flow: bool,
pub blueprint_ref_base: Option<PathBuf>,
pub git_store_path: Option<PathBuf>,
pub seed_blueprint_id: String,
pub default_agent_kind: Option<String>,
pub token_secret: Option<String>,
}
impl Default for ResolvedConfig {
fn default() -> Self {
Self {
bind: default_bind(),
enable_enhance_flow: false,
blueprint_ref_base: None,
git_store_path: None,
seed_blueprint_id: "main".into(),
default_agent_kind: None,
token_secret: None,
}
}
}
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,
};
Ok(ResolvedConfig {
bind,
enable_enhance_flow: cli
.enable_enhance_flow
.or(file.enable_enhance_flow)
.unwrap_or(default.enable_enhance_flow),
blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
git_store_path: cli.git_store_path.or(file.git_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),
})
}
#[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_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, None);
}
#[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);
}
}