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
37pub fn default_task_store_path() -> PathBuf {
41 match std::env::var("HOME") {
42 Ok(home) => PathBuf::from(home)
43 .join(".mse")
44 .join("store")
45 .join("task.sqlite"),
46 Err(_) => PathBuf::from(".mse/store/task.sqlite"),
47 }
48}
49
50pub fn default_run_store_path() -> PathBuf {
53 match std::env::var("HOME") {
54 Ok(home) => PathBuf::from(home)
55 .join(".mse")
56 .join("store")
57 .join("run.sqlite"),
58 Err(_) => PathBuf::from(".mse/store/run.sqlite"),
59 }
60}
61
62#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct FileConfig {
68 pub bind: Option<String>,
70 pub enable_enhance_flow: Option<bool>,
72 pub blueprint_ref_base: Option<PathBuf>,
74 pub git_store_path: Option<PathBuf>,
76 pub issue_store_path: Option<PathBuf>,
79 pub enhance_setting_store_path: Option<PathBuf>,
82 pub enhance_log_store_path: Option<PathBuf>,
85 pub output_store_path: Option<PathBuf>,
88 pub task_store_path: Option<PathBuf>,
92 pub run_store_path: Option<PathBuf>,
95 pub ephemeral: Option<bool>,
101 pub seed_blueprint_id: Option<String>,
103 pub default_agent_kind: Option<String>,
106 pub token_secret: Option<String>,
108 pub sync_timeout_secs: Option<u64>,
114}
115
116#[derive(Debug, Default, Clone)]
120pub struct CliOverrides {
121 pub bind: Option<String>,
123 pub enable_enhance_flow: Option<bool>,
125 pub blueprint_ref_base: Option<PathBuf>,
127 pub git_store_path: Option<PathBuf>,
129 pub issue_store_path: Option<PathBuf>,
131 pub enhance_setting_store_path: Option<PathBuf>,
133 pub enhance_log_store_path: Option<PathBuf>,
135 pub output_store_path: Option<PathBuf>,
137 pub task_store_path: Option<PathBuf>,
139 pub run_store_path: Option<PathBuf>,
141 pub ephemeral: Option<bool>,
143 pub seed_blueprint_id: Option<String>,
145 pub default_agent_kind: Option<String>,
147 pub token_secret: Option<String>,
149 pub sync_timeout_secs: Option<u64>,
151}
152
153#[derive(Debug, Clone, PartialEq)]
155pub struct ResolvedConfig {
156 pub bind: SocketAddr,
158 pub enable_enhance_flow: bool,
160 pub blueprint_ref_base: Option<PathBuf>,
162 pub git_store_path: PathBuf,
166 pub issue_store_path: Option<PathBuf>,
169 pub enhance_setting_store_path: Option<PathBuf>,
172 pub enhance_log_store_path: Option<PathBuf>,
175 pub output_store_path: Option<PathBuf>,
178 pub task_store_path: Option<PathBuf>,
181 pub run_store_path: Option<PathBuf>,
184 pub seed_blueprint_id: String,
186 pub default_agent_kind: Option<String>,
189 pub token_secret: Option<String>,
191 pub sync_timeout_secs: u64,
197}
198
199impl Default for ResolvedConfig {
200 fn default() -> Self {
201 Self {
202 bind: default_bind(),
203 enable_enhance_flow: false,
204 blueprint_ref_base: None,
205 git_store_path: default_store_path(),
206 issue_store_path: None,
207 enhance_setting_store_path: None,
208 enhance_log_store_path: None,
209 output_store_path: None,
210 task_store_path: None,
211 run_store_path: None,
212 seed_blueprint_id: "main".into(),
213 default_agent_kind: None,
214 token_secret: None,
215 sync_timeout_secs: default_sync_timeout_secs(),
216 }
217 }
218}
219
220pub fn default_sync_timeout_secs() -> u64 {
229 3600
230}
231
232fn default_bind() -> SocketAddr {
233 "127.0.0.1:7777"
234 .parse()
235 .expect("literal default bind must parse")
236}
237
238pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
243 match std::fs::read_to_string(path) {
244 Ok(text) => toml::from_str(&text)
245 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
246 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
247 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
248 }
249}
250
251pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
254 let default = ResolvedConfig::default();
255
256 let bind = match cli.bind.or(file.bind) {
257 Some(s) => s
258 .parse::<SocketAddr>()
259 .map_err(|e| format!("bind {s:?}: {e}"))?,
260 None => default.bind,
261 };
262
263 let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
264
265 Ok(ResolvedConfig {
266 bind,
267 enable_enhance_flow: cli
268 .enable_enhance_flow
269 .or(file.enable_enhance_flow)
270 .unwrap_or(default.enable_enhance_flow),
271 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
272 git_store_path: cli
273 .git_store_path
274 .or(file.git_store_path)
275 .unwrap_or_else(default_store_path),
276 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
277 enhance_setting_store_path: cli
278 .enhance_setting_store_path
279 .or(file.enhance_setting_store_path),
280 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
281 output_store_path: cli.output_store_path.or(file.output_store_path),
282 task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
283 if ephemeral {
284 None
285 } else {
286 Some(default_task_store_path())
287 }
288 }),
289 run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
290 if ephemeral {
291 None
292 } else {
293 Some(default_run_store_path())
294 }
295 }),
296 seed_blueprint_id: cli
297 .seed_blueprint_id
298 .or(file.seed_blueprint_id)
299 .unwrap_or(default.seed_blueprint_id),
300 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
301 token_secret: cli.token_secret.or(file.token_secret),
302 sync_timeout_secs: cli
303 .sync_timeout_secs
304 .or(file.sync_timeout_secs)
305 .unwrap_or_else(default_sync_timeout_secs),
306 })
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn resolve_cli_flag_wins_over_file_and_default() {
315 let cli = CliOverrides {
316 bind: Some("127.0.0.1:9999".into()),
317 ..Default::default()
318 };
319 let file = FileConfig {
320 bind: Some("127.0.0.1:8888".into()),
321 ..Default::default()
322 };
323 let resolved = resolve(cli, file).expect("resolve");
324 assert_eq!(
325 resolved.bind,
326 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
327 );
328 }
329
330 #[test]
331 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
332 let cli = CliOverrides::default();
333 let file = FileConfig {
334 seed_blueprint_id: Some("from-file".into()),
335 enable_enhance_flow: Some(true),
336 ..Default::default()
337 };
338 let resolved = resolve(cli, file).expect("resolve");
339 assert_eq!(resolved.seed_blueprint_id, "from-file");
340 assert!(resolved.enable_enhance_flow);
341 }
342
343 #[test]
344 fn resolve_built_in_default_when_cli_and_file_absent() {
345 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
346 assert_eq!(resolved.bind, default_bind());
347 assert_eq!(resolved.seed_blueprint_id, "main");
348 assert!(!resolved.enable_enhance_flow);
349 assert_eq!(resolved.git_store_path, default_store_path());
350 }
351
352 #[test]
353 fn resolve_git_store_path_file_overrides_default_location() {
354 let file = FileConfig {
355 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
356 ..Default::default()
357 };
358 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
359 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
360 }
361
362 #[test]
363 fn resolve_bind_parse_error_is_propagated() {
364 let cli = CliOverrides {
365 bind: Some("not-a-valid-addr".into()),
366 ..Default::default()
367 };
368 let err = resolve(cli, FileConfig::default()).unwrap_err();
369 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
370 }
371
372 #[test]
373 fn load_file_config_rejects_unknown_fields() {
374 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
375 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
376 let msg = err.to_string();
377 assert!(
378 msg.contains("typo_field") || msg.contains("unknown field"),
379 "unexpected error message: {msg}"
380 );
381 }
382
383 #[test]
384 fn load_file_config_missing_file_falls_back_to_default() {
385 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
386 let cfg = load_file_config(path).expect("missing file should not error");
387 assert_eq!(cfg, FileConfig::default());
388 }
389
390 #[test]
391 fn load_file_config_parses_valid_toml() {
392 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
393 std::fs::create_dir_all(&dir).expect("create tmp dir");
394 let path = dir.join("config.toml");
395 std::fs::write(
396 &path,
397 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
398 )
399 .expect("write tmp config");
400 let cfg = load_file_config(&path).expect("parse tmp config");
401 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
402 assert_eq!(cfg.enable_enhance_flow, Some(true));
403 let _ = std::fs::remove_dir_all(&dir);
404 }
405
406 #[test]
407 fn resolve_task_and_run_store_path_cli_wins_over_file() {
408 let cli = CliOverrides {
409 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
410 ..Default::default()
411 };
412 let file = FileConfig {
413 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
414 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
415 ..Default::default()
416 };
417 let resolved = resolve(cli, file).expect("resolve");
418 assert_eq!(
419 resolved.task_store_path,
420 Some(PathBuf::from("/tmp/cli-tasks.db")),
421 "cli task_store_path must win over file"
422 );
423 assert_eq!(
424 resolved.run_store_path,
425 Some(PathBuf::from("/tmp/file-runs.db")),
426 "run_store_path falls back to file when cli is absent"
427 );
428 }
429
430 #[test]
431 fn resolve_task_and_run_store_path_default_none() {
432 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
433 assert_eq!(
434 resolved.task_store_path,
435 Some(default_task_store_path()),
436 "issue #35 ST1: task_store_path now persists by default"
437 );
438 assert_eq!(
439 resolved.run_store_path,
440 Some(default_run_store_path()),
441 "issue #35 ST1: run_store_path now persists by default"
442 );
443 }
444
445 #[test]
446 fn resolve_ephemeral_true_restores_in_memory_default() {
447 let cli = CliOverrides {
448 ephemeral: Some(true),
449 ..Default::default()
450 };
451 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
452 assert_eq!(resolved.task_store_path, None);
453 assert_eq!(resolved.run_store_path, None);
454 }
455
456 #[test]
457 fn resolve_explicit_path_wins_over_ephemeral() {
458 let cli = CliOverrides {
459 task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
460 ephemeral: Some(true),
461 ..Default::default()
462 };
463 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
464 assert_eq!(
465 resolved.task_store_path,
466 Some(PathBuf::from("/tmp/explicit-tasks.db")),
467 "explicit path must win over ephemeral"
468 );
469 }
470
471 #[test]
472 fn resolve_ephemeral_from_file_config() {
473 let file = FileConfig {
474 ephemeral: Some(true),
475 ..Default::default()
476 };
477 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
478 assert_eq!(resolved.task_store_path, None);
479 assert_eq!(resolved.run_store_path, None);
480 }
481
482 #[test]
487 fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
488 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
489 assert_eq!(resolved.sync_timeout_secs, 3600);
490 assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
491 }
492
493 #[test]
494 fn resolve_sync_timeout_secs_file_wins_over_default() {
495 let file = FileConfig {
496 sync_timeout_secs: Some(120),
497 ..Default::default()
498 };
499 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
500 assert_eq!(resolved.sync_timeout_secs, 120);
501 }
502
503 #[test]
504 fn resolve_sync_timeout_secs_cli_wins_over_file() {
505 let cli = CliOverrides {
506 sync_timeout_secs: Some(45),
507 ..Default::default()
508 };
509 let file = FileConfig {
510 sync_timeout_secs: Some(120),
511 ..Default::default()
512 };
513 let resolved = resolve(cli, file).expect("resolve");
514 assert_eq!(
515 resolved.sync_timeout_secs, 45,
516 "cli sync_timeout_secs must win over file"
517 );
518 }
519}