mlua_swarm_server/
config.rs1use serde::Deserialize;
14use std::net::SocketAddr;
15use std::path::{Path, PathBuf};
16
17pub fn default_config_path() -> PathBuf {
20 match std::env::var("HOME") {
21 Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
22 Err(_) => PathBuf::from(".mse/config.toml"),
23 }
24}
25
26#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct FileConfig {
32 pub bind: Option<String>,
34 pub enable_enhance_flow: Option<bool>,
36 pub blueprint_ref_base: Option<PathBuf>,
38 pub git_store_path: Option<PathBuf>,
40 pub seed_blueprint_id: Option<String>,
42 pub default_agent_kind: Option<String>,
45 pub token_secret: Option<String>,
47}
48
49#[derive(Debug, Default, Clone)]
53pub struct CliOverrides {
54 pub bind: Option<String>,
56 pub enable_enhance_flow: Option<bool>,
58 pub blueprint_ref_base: Option<PathBuf>,
60 pub git_store_path: Option<PathBuf>,
62 pub seed_blueprint_id: Option<String>,
64 pub default_agent_kind: Option<String>,
66 pub token_secret: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub struct ResolvedConfig {
73 pub bind: SocketAddr,
75 pub enable_enhance_flow: bool,
77 pub blueprint_ref_base: Option<PathBuf>,
79 pub git_store_path: Option<PathBuf>,
81 pub seed_blueprint_id: String,
83 pub default_agent_kind: Option<String>,
86 pub token_secret: Option<String>,
88}
89
90impl Default for ResolvedConfig {
91 fn default() -> Self {
92 Self {
93 bind: default_bind(),
94 enable_enhance_flow: false,
95 blueprint_ref_base: None,
96 git_store_path: None,
97 seed_blueprint_id: "main".into(),
98 default_agent_kind: None,
99 token_secret: None,
100 }
101 }
102}
103
104fn default_bind() -> SocketAddr {
105 "127.0.0.1:7777"
106 .parse()
107 .expect("literal default bind must parse")
108}
109
110pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
115 match std::fs::read_to_string(path) {
116 Ok(text) => toml::from_str(&text)
117 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
118 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
119 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
120 }
121}
122
123pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
126 let default = ResolvedConfig::default();
127
128 let bind = match cli.bind.or(file.bind) {
129 Some(s) => s
130 .parse::<SocketAddr>()
131 .map_err(|e| format!("bind {s:?}: {e}"))?,
132 None => default.bind,
133 };
134
135 Ok(ResolvedConfig {
136 bind,
137 enable_enhance_flow: cli
138 .enable_enhance_flow
139 .or(file.enable_enhance_flow)
140 .unwrap_or(default.enable_enhance_flow),
141 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
142 git_store_path: cli.git_store_path.or(file.git_store_path),
143 seed_blueprint_id: cli
144 .seed_blueprint_id
145 .or(file.seed_blueprint_id)
146 .unwrap_or(default.seed_blueprint_id),
147 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
148 token_secret: cli.token_secret.or(file.token_secret),
149 })
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn resolve_cli_flag_wins_over_file_and_default() {
158 let cli = CliOverrides {
159 bind: Some("127.0.0.1:9999".into()),
160 ..Default::default()
161 };
162 let file = FileConfig {
163 bind: Some("127.0.0.1:8888".into()),
164 ..Default::default()
165 };
166 let resolved = resolve(cli, file).expect("resolve");
167 assert_eq!(
168 resolved.bind,
169 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
170 );
171 }
172
173 #[test]
174 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
175 let cli = CliOverrides::default();
176 let file = FileConfig {
177 seed_blueprint_id: Some("from-file".into()),
178 enable_enhance_flow: Some(true),
179 ..Default::default()
180 };
181 let resolved = resolve(cli, file).expect("resolve");
182 assert_eq!(resolved.seed_blueprint_id, "from-file");
183 assert!(resolved.enable_enhance_flow);
184 }
185
186 #[test]
187 fn resolve_built_in_default_when_cli_and_file_absent() {
188 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
189 assert_eq!(resolved.bind, default_bind());
190 assert_eq!(resolved.seed_blueprint_id, "main");
191 assert!(!resolved.enable_enhance_flow);
192 assert_eq!(resolved.git_store_path, None);
193 }
194
195 #[test]
196 fn resolve_bind_parse_error_is_propagated() {
197 let cli = CliOverrides {
198 bind: Some("not-a-valid-addr".into()),
199 ..Default::default()
200 };
201 let err = resolve(cli, FileConfig::default()).unwrap_err();
202 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
203 }
204
205 #[test]
206 fn load_file_config_rejects_unknown_fields() {
207 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
208 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
209 let msg = err.to_string();
210 assert!(
211 msg.contains("typo_field") || msg.contains("unknown field"),
212 "unexpected error message: {msg}"
213 );
214 }
215
216 #[test]
217 fn load_file_config_missing_file_falls_back_to_default() {
218 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
219 let cfg = load_file_config(path).expect("missing file should not error");
220 assert_eq!(cfg, FileConfig::default());
221 }
222
223 #[test]
224 fn load_file_config_parses_valid_toml() {
225 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
226 std::fs::create_dir_all(&dir).expect("create tmp dir");
227 let path = dir.join("config.toml");
228 std::fs::write(
229 &path,
230 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
231 )
232 .expect("write tmp config");
233 let cfg = load_file_config(&path).expect("parse tmp config");
234 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
235 assert_eq!(cfg.enable_enhance_flow, Some(true));
236 let _ = std::fs::remove_dir_all(&dir);
237 }
238}