1use mlua_swarm::core::config::CheckPolicy;
14use serde::Deserialize;
15use std::net::SocketAddr;
16use std::path::{Path, PathBuf};
17
18pub fn default_config_path() -> PathBuf {
21 match std::env::var("HOME") {
22 Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
23 Err(_) => PathBuf::from(".mse/config.toml"),
24 }
25}
26
27pub fn default_store_path() -> PathBuf {
32 match std::env::var("HOME") {
33 Ok(home) => PathBuf::from(home).join(".mse").join("store"),
34 Err(_) => PathBuf::from(".mse/store"),
35 }
36}
37
38pub fn default_task_store_path() -> PathBuf {
42 match std::env::var("HOME") {
43 Ok(home) => PathBuf::from(home)
44 .join(".mse")
45 .join("store")
46 .join("task.sqlite"),
47 Err(_) => PathBuf::from(".mse/store/task.sqlite"),
48 }
49}
50
51pub fn default_run_store_path() -> PathBuf {
54 match std::env::var("HOME") {
55 Ok(home) => PathBuf::from(home)
56 .join(".mse")
57 .join("store")
58 .join("run.sqlite"),
59 Err(_) => PathBuf::from(".mse/store/run.sqlite"),
60 }
61}
62
63#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct FileConfig {
69 pub bind: Option<String>,
71 pub enable_enhance_flow: Option<bool>,
73 pub blueprint_ref_base: Option<PathBuf>,
75 pub git_store_path: Option<PathBuf>,
77 pub issue_store_path: Option<PathBuf>,
80 pub enhance_setting_store_path: Option<PathBuf>,
83 pub enhance_log_store_path: Option<PathBuf>,
86 pub output_store_path: Option<PathBuf>,
89 pub task_store_path: Option<PathBuf>,
93 pub run_store_path: Option<PathBuf>,
96 pub ephemeral: Option<bool>,
102 pub seed_blueprint_id: Option<String>,
104 pub default_agent_kind: Option<String>,
107 pub token_secret: Option<String>,
109 pub sync_timeout_secs: Option<u64>,
115 pub check_policy: Option<CheckPolicy>,
128}
129
130#[derive(Debug, Default, Clone)]
134pub struct CliOverrides {
135 pub bind: Option<String>,
137 pub enable_enhance_flow: Option<bool>,
139 pub blueprint_ref_base: Option<PathBuf>,
141 pub git_store_path: Option<PathBuf>,
143 pub issue_store_path: Option<PathBuf>,
145 pub enhance_setting_store_path: Option<PathBuf>,
147 pub enhance_log_store_path: Option<PathBuf>,
149 pub output_store_path: Option<PathBuf>,
151 pub task_store_path: Option<PathBuf>,
153 pub run_store_path: Option<PathBuf>,
155 pub ephemeral: Option<bool>,
157 pub seed_blueprint_id: Option<String>,
159 pub default_agent_kind: Option<String>,
161 pub token_secret: Option<String>,
163 pub sync_timeout_secs: Option<u64>,
165 pub check_policy: Option<CheckPolicy>,
169}
170
171#[derive(Debug, Clone, PartialEq)]
173pub struct ResolvedConfig {
174 pub bind: SocketAddr,
176 pub enable_enhance_flow: bool,
178 pub blueprint_ref_base: Option<PathBuf>,
180 pub git_store_path: PathBuf,
184 pub issue_store_path: Option<PathBuf>,
187 pub enhance_setting_store_path: Option<PathBuf>,
190 pub enhance_log_store_path: Option<PathBuf>,
193 pub output_store_path: Option<PathBuf>,
196 pub task_store_path: Option<PathBuf>,
199 pub run_store_path: Option<PathBuf>,
202 pub seed_blueprint_id: String,
204 pub default_agent_kind: Option<String>,
207 pub token_secret: Option<String>,
209 pub sync_timeout_secs: u64,
215 pub check_policy: CheckPolicy,
222}
223
224impl Default for ResolvedConfig {
225 fn default() -> Self {
226 Self {
227 bind: default_bind(),
228 enable_enhance_flow: false,
229 blueprint_ref_base: None,
230 git_store_path: default_store_path(),
231 issue_store_path: None,
232 enhance_setting_store_path: None,
233 enhance_log_store_path: None,
234 output_store_path: None,
235 task_store_path: None,
236 run_store_path: None,
237 seed_blueprint_id: "main".into(),
238 default_agent_kind: None,
239 token_secret: None,
240 sync_timeout_secs: default_sync_timeout_secs(),
241 check_policy: CheckPolicy::default(),
242 }
243 }
244}
245
246pub fn default_sync_timeout_secs() -> u64 {
255 3600
256}
257
258fn default_bind() -> SocketAddr {
259 "127.0.0.1:7777"
260 .parse()
261 .expect("literal default bind must parse")
262}
263
264pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
269 match std::fs::read_to_string(path) {
270 Ok(text) => toml::from_str(&text)
271 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
272 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
273 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
274 }
275}
276
277pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
280 let default = ResolvedConfig::default();
281
282 let bind = match cli.bind.or(file.bind) {
283 Some(s) => s
284 .parse::<SocketAddr>()
285 .map_err(|e| format!("bind {s:?}: {e}"))?,
286 None => default.bind,
287 };
288
289 let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
290
291 Ok(ResolvedConfig {
292 bind,
293 enable_enhance_flow: cli
294 .enable_enhance_flow
295 .or(file.enable_enhance_flow)
296 .unwrap_or(default.enable_enhance_flow),
297 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
298 git_store_path: cli
299 .git_store_path
300 .or(file.git_store_path)
301 .unwrap_or_else(default_store_path),
302 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
303 enhance_setting_store_path: cli
304 .enhance_setting_store_path
305 .or(file.enhance_setting_store_path),
306 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
307 output_store_path: cli.output_store_path.or(file.output_store_path),
308 task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
309 if ephemeral {
310 None
311 } else {
312 Some(default_task_store_path())
313 }
314 }),
315 run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
316 if ephemeral {
317 None
318 } else {
319 Some(default_run_store_path())
320 }
321 }),
322 seed_blueprint_id: cli
323 .seed_blueprint_id
324 .or(file.seed_blueprint_id)
325 .unwrap_or(default.seed_blueprint_id),
326 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
327 token_secret: cli.token_secret.or(file.token_secret),
328 sync_timeout_secs: cli
329 .sync_timeout_secs
330 .or(file.sync_timeout_secs)
331 .unwrap_or_else(default_sync_timeout_secs),
332 check_policy: cli
333 .check_policy
334 .or(file.check_policy)
335 .unwrap_or(default.check_policy),
336 })
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn resolve_cli_flag_wins_over_file_and_default() {
345 let cli = CliOverrides {
346 bind: Some("127.0.0.1:9999".into()),
347 ..Default::default()
348 };
349 let file = FileConfig {
350 bind: Some("127.0.0.1:8888".into()),
351 ..Default::default()
352 };
353 let resolved = resolve(cli, file).expect("resolve");
354 assert_eq!(
355 resolved.bind,
356 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
357 );
358 }
359
360 #[test]
361 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
362 let cli = CliOverrides::default();
363 let file = FileConfig {
364 seed_blueprint_id: Some("from-file".into()),
365 enable_enhance_flow: Some(true),
366 ..Default::default()
367 };
368 let resolved = resolve(cli, file).expect("resolve");
369 assert_eq!(resolved.seed_blueprint_id, "from-file");
370 assert!(resolved.enable_enhance_flow);
371 }
372
373 #[test]
374 fn resolve_built_in_default_when_cli_and_file_absent() {
375 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
376 assert_eq!(resolved.bind, default_bind());
377 assert_eq!(resolved.seed_blueprint_id, "main");
378 assert!(!resolved.enable_enhance_flow);
379 assert_eq!(resolved.git_store_path, default_store_path());
380 }
381
382 #[test]
383 fn resolve_git_store_path_file_overrides_default_location() {
384 let file = FileConfig {
385 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
386 ..Default::default()
387 };
388 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
389 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
390 }
391
392 #[test]
393 fn resolve_bind_parse_error_is_propagated() {
394 let cli = CliOverrides {
395 bind: Some("not-a-valid-addr".into()),
396 ..Default::default()
397 };
398 let err = resolve(cli, FileConfig::default()).unwrap_err();
399 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
400 }
401
402 #[test]
403 fn load_file_config_rejects_unknown_fields() {
404 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
405 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
406 let msg = err.to_string();
407 assert!(
408 msg.contains("typo_field") || msg.contains("unknown field"),
409 "unexpected error message: {msg}"
410 );
411 }
412
413 #[test]
414 fn load_file_config_missing_file_falls_back_to_default() {
415 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
416 let cfg = load_file_config(path).expect("missing file should not error");
417 assert_eq!(cfg, FileConfig::default());
418 }
419
420 #[test]
421 fn load_file_config_parses_valid_toml() {
422 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
423 std::fs::create_dir_all(&dir).expect("create tmp dir");
424 let path = dir.join("config.toml");
425 std::fs::write(
426 &path,
427 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
428 )
429 .expect("write tmp config");
430 let cfg = load_file_config(&path).expect("parse tmp config");
431 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
432 assert_eq!(cfg.enable_enhance_flow, Some(true));
433 let _ = std::fs::remove_dir_all(&dir);
434 }
435
436 #[test]
437 fn resolve_task_and_run_store_path_cli_wins_over_file() {
438 let cli = CliOverrides {
439 task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
440 ..Default::default()
441 };
442 let file = FileConfig {
443 task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
444 run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
445 ..Default::default()
446 };
447 let resolved = resolve(cli, file).expect("resolve");
448 assert_eq!(
449 resolved.task_store_path,
450 Some(PathBuf::from("/tmp/cli-tasks.db")),
451 "cli task_store_path must win over file"
452 );
453 assert_eq!(
454 resolved.run_store_path,
455 Some(PathBuf::from("/tmp/file-runs.db")),
456 "run_store_path falls back to file when cli is absent"
457 );
458 }
459
460 #[test]
461 fn resolve_task_and_run_store_path_default_none() {
462 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
463 assert_eq!(
464 resolved.task_store_path,
465 Some(default_task_store_path()),
466 "issue #35 ST1: task_store_path now persists by default"
467 );
468 assert_eq!(
469 resolved.run_store_path,
470 Some(default_run_store_path()),
471 "issue #35 ST1: run_store_path now persists by default"
472 );
473 }
474
475 #[test]
476 fn resolve_ephemeral_true_restores_in_memory_default() {
477 let cli = CliOverrides {
478 ephemeral: Some(true),
479 ..Default::default()
480 };
481 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
482 assert_eq!(resolved.task_store_path, None);
483 assert_eq!(resolved.run_store_path, None);
484 }
485
486 #[test]
487 fn resolve_explicit_path_wins_over_ephemeral() {
488 let cli = CliOverrides {
489 task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
490 ephemeral: Some(true),
491 ..Default::default()
492 };
493 let resolved = resolve(cli, FileConfig::default()).expect("resolve");
494 assert_eq!(
495 resolved.task_store_path,
496 Some(PathBuf::from("/tmp/explicit-tasks.db")),
497 "explicit path must win over ephemeral"
498 );
499 }
500
501 #[test]
502 fn resolve_ephemeral_from_file_config() {
503 let file = FileConfig {
504 ephemeral: Some(true),
505 ..Default::default()
506 };
507 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
508 assert_eq!(resolved.task_store_path, None);
509 assert_eq!(resolved.run_store_path, None);
510 }
511
512 #[test]
517 fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
518 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
519 assert_eq!(resolved.sync_timeout_secs, 3600);
520 assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
521 }
522
523 #[test]
524 fn resolve_sync_timeout_secs_file_wins_over_default() {
525 let file = FileConfig {
526 sync_timeout_secs: Some(120),
527 ..Default::default()
528 };
529 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
530 assert_eq!(resolved.sync_timeout_secs, 120);
531 }
532
533 #[test]
534 fn resolve_sync_timeout_secs_cli_wins_over_file() {
535 let cli = CliOverrides {
536 sync_timeout_secs: Some(45),
537 ..Default::default()
538 };
539 let file = FileConfig {
540 sync_timeout_secs: Some(120),
541 ..Default::default()
542 };
543 let resolved = resolve(cli, file).expect("resolve");
544 assert_eq!(
545 resolved.sync_timeout_secs, 45,
546 "cli sync_timeout_secs must win over file"
547 );
548 }
549
550 #[test]
555 fn resolve_check_policy_default_when_cli_and_file_absent() {
556 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
557 assert_eq!(
558 resolved.check_policy,
559 CheckPolicy::Warn,
560 "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
561 );
562 assert_eq!(resolved.check_policy, CheckPolicy::default());
563 }
564
565 #[test]
566 fn resolve_check_policy_file_wins_over_default() {
567 let file = FileConfig {
568 check_policy: Some(CheckPolicy::Strict),
569 ..Default::default()
570 };
571 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
572 assert_eq!(
573 resolved.check_policy,
574 CheckPolicy::Strict,
575 "file check_policy must win over built-in default"
576 );
577 }
578
579 #[test]
580 fn resolve_check_policy_cli_wins_over_file() {
581 let cli = CliOverrides {
582 check_policy: Some(CheckPolicy::Silent),
583 ..Default::default()
584 };
585 let file = FileConfig {
586 check_policy: Some(CheckPolicy::Strict),
587 ..Default::default()
588 };
589 let resolved = resolve(cli, file).expect("resolve");
590 assert_eq!(
591 resolved.check_policy,
592 CheckPolicy::Silent,
593 "cli check_policy must win over file"
594 );
595 }
596
597 #[test]
598 fn file_config_deserializes_check_policy_snake_case_literals() {
599 let toml_text = "check_policy = \"strict\"\n";
600 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
601 assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
602
603 let toml_text = "check_policy = \"silent\"\n";
604 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
605 assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
606
607 let toml_text = "check_policy = \"warn\"\n";
608 let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
609 assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
610 }
611}