1use 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
26pub fn default_store_path() -> PathBuf {
31 match std::env::var("HOME") {
32 Ok(home) => PathBuf::from(home).join(".mse").join("store"),
33 Err(_) => PathBuf::from(".mse/store"),
34 }
35}
36
37#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct FileConfig {
43 pub bind: Option<String>,
45 pub enable_enhance_flow: Option<bool>,
47 pub blueprint_ref_base: Option<PathBuf>,
49 pub git_store_path: Option<PathBuf>,
51 pub issue_store_path: Option<PathBuf>,
54 pub enhance_setting_store_path: Option<PathBuf>,
57 pub enhance_log_store_path: Option<PathBuf>,
60 pub output_store_path: Option<PathBuf>,
63 pub task_store_path: Option<PathBuf>,
67 pub run_store_path: Option<PathBuf>,
70 pub seed_blueprint_id: Option<String>,
72 pub default_agent_kind: Option<String>,
75 pub token_secret: Option<String>,
77}
78
79#[derive(Debug, Default, Clone)]
83pub struct CliOverrides {
84 pub bind: Option<String>,
86 pub enable_enhance_flow: Option<bool>,
88 pub blueprint_ref_base: Option<PathBuf>,
90 pub git_store_path: Option<PathBuf>,
92 pub issue_store_path: Option<PathBuf>,
94 pub enhance_setting_store_path: Option<PathBuf>,
96 pub enhance_log_store_path: Option<PathBuf>,
98 pub output_store_path: Option<PathBuf>,
100 pub task_store_path: Option<PathBuf>,
102 pub run_store_path: Option<PathBuf>,
104 pub seed_blueprint_id: Option<String>,
106 pub default_agent_kind: Option<String>,
108 pub token_secret: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq)]
114pub struct ResolvedConfig {
115 pub bind: SocketAddr,
117 pub enable_enhance_flow: bool,
119 pub blueprint_ref_base: Option<PathBuf>,
121 pub git_store_path: PathBuf,
125 pub issue_store_path: Option<PathBuf>,
128 pub enhance_setting_store_path: Option<PathBuf>,
131 pub enhance_log_store_path: Option<PathBuf>,
134 pub output_store_path: Option<PathBuf>,
137 pub task_store_path: Option<PathBuf>,
140 pub run_store_path: Option<PathBuf>,
143 pub seed_blueprint_id: String,
145 pub default_agent_kind: Option<String>,
148 pub token_secret: Option<String>,
150}
151
152impl Default for ResolvedConfig {
153 fn default() -> Self {
154 Self {
155 bind: default_bind(),
156 enable_enhance_flow: false,
157 blueprint_ref_base: None,
158 git_store_path: default_store_path(),
159 issue_store_path: None,
160 enhance_setting_store_path: None,
161 enhance_log_store_path: None,
162 output_store_path: None,
163 task_store_path: None,
164 run_store_path: None,
165 seed_blueprint_id: "main".into(),
166 default_agent_kind: None,
167 token_secret: None,
168 }
169 }
170}
171
172fn default_bind() -> SocketAddr {
173 "127.0.0.1:7777"
174 .parse()
175 .expect("literal default bind must parse")
176}
177
178pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
183 match std::fs::read_to_string(path) {
184 Ok(text) => toml::from_str(&text)
185 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
186 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
187 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
188 }
189}
190
191pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
194 let default = ResolvedConfig::default();
195
196 let bind = match cli.bind.or(file.bind) {
197 Some(s) => s
198 .parse::<SocketAddr>()
199 .map_err(|e| format!("bind {s:?}: {e}"))?,
200 None => default.bind,
201 };
202
203 Ok(ResolvedConfig {
204 bind,
205 enable_enhance_flow: cli
206 .enable_enhance_flow
207 .or(file.enable_enhance_flow)
208 .unwrap_or(default.enable_enhance_flow),
209 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
210 git_store_path: cli
211 .git_store_path
212 .or(file.git_store_path)
213 .unwrap_or_else(default_store_path),
214 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
215 enhance_setting_store_path: cli
216 .enhance_setting_store_path
217 .or(file.enhance_setting_store_path),
218 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
219 output_store_path: cli.output_store_path.or(file.output_store_path),
220 task_store_path: cli.task_store_path.or(file.task_store_path),
221 run_store_path: cli.run_store_path.or(file.run_store_path),
222 seed_blueprint_id: cli
223 .seed_blueprint_id
224 .or(file.seed_blueprint_id)
225 .unwrap_or(default.seed_blueprint_id),
226 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
227 token_secret: cli.token_secret.or(file.token_secret),
228 })
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn resolve_cli_flag_wins_over_file_and_default() {
237 let cli = CliOverrides {
238 bind: Some("127.0.0.1:9999".into()),
239 ..Default::default()
240 };
241 let file = FileConfig {
242 bind: Some("127.0.0.1:8888".into()),
243 ..Default::default()
244 };
245 let resolved = resolve(cli, file).expect("resolve");
246 assert_eq!(
247 resolved.bind,
248 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
249 );
250 }
251
252 #[test]
253 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
254 let cli = CliOverrides::default();
255 let file = FileConfig {
256 seed_blueprint_id: Some("from-file".into()),
257 enable_enhance_flow: Some(true),
258 ..Default::default()
259 };
260 let resolved = resolve(cli, file).expect("resolve");
261 assert_eq!(resolved.seed_blueprint_id, "from-file");
262 assert!(resolved.enable_enhance_flow);
263 }
264
265 #[test]
266 fn resolve_built_in_default_when_cli_and_file_absent() {
267 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
268 assert_eq!(resolved.bind, default_bind());
269 assert_eq!(resolved.seed_blueprint_id, "main");
270 assert!(!resolved.enable_enhance_flow);
271 assert_eq!(resolved.git_store_path, default_store_path());
272 }
273
274 #[test]
275 fn resolve_git_store_path_file_overrides_default_location() {
276 let file = FileConfig {
277 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
278 ..Default::default()
279 };
280 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
281 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
282 }
283
284 #[test]
285 fn resolve_bind_parse_error_is_propagated() {
286 let cli = CliOverrides {
287 bind: Some("not-a-valid-addr".into()),
288 ..Default::default()
289 };
290 let err = resolve(cli, FileConfig::default()).unwrap_err();
291 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
292 }
293
294 #[test]
295 fn load_file_config_rejects_unknown_fields() {
296 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
297 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
298 let msg = err.to_string();
299 assert!(
300 msg.contains("typo_field") || msg.contains("unknown field"),
301 "unexpected error message: {msg}"
302 );
303 }
304
305 #[test]
306 fn load_file_config_missing_file_falls_back_to_default() {
307 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
308 let cfg = load_file_config(path).expect("missing file should not error");
309 assert_eq!(cfg, FileConfig::default());
310 }
311
312 #[test]
313 fn load_file_config_parses_valid_toml() {
314 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
315 std::fs::create_dir_all(&dir).expect("create tmp dir");
316 let path = dir.join("config.toml");
317 std::fs::write(
318 &path,
319 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
320 )
321 .expect("write tmp config");
322 let cfg = load_file_config(&path).expect("parse tmp config");
323 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
324 assert_eq!(cfg.enable_enhance_flow, Some(true));
325 let _ = std::fs::remove_dir_all(&dir);
326 }
327
328 #[test]
329 fn resolve_task_and_run_store_path_cli_wins_over_file() {
330 let cli = CliOverrides {
331 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
332 ..Default::default()
333 };
334 let file = FileConfig {
335 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
336 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
337 ..Default::default()
338 };
339 let resolved = resolve(cli, file).expect("resolve");
340 assert_eq!(
341 resolved.task_store_path,
342 Some(PathBuf::from("/tmp/cli-tasks.db")),
343 "cli task_store_path must win over file"
344 );
345 assert_eq!(
346 resolved.run_store_path,
347 Some(PathBuf::from("/tmp/file-runs.db")),
348 "run_store_path falls back to file when cli is absent"
349 );
350 }
351
352 #[test]
353 fn resolve_task_and_run_store_path_default_none() {
354 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
355 assert_eq!(resolved.task_store_path, None);
356 assert_eq!(resolved.run_store_path, None);
357 }
358}