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 seed_blueprint_id: Option<String>,
65 pub default_agent_kind: Option<String>,
68 pub token_secret: Option<String>,
70}
71
72#[derive(Debug, Default, Clone)]
76pub struct CliOverrides {
77 pub bind: Option<String>,
79 pub enable_enhance_flow: Option<bool>,
81 pub blueprint_ref_base: Option<PathBuf>,
83 pub git_store_path: Option<PathBuf>,
85 pub issue_store_path: Option<PathBuf>,
87 pub enhance_setting_store_path: Option<PathBuf>,
89 pub enhance_log_store_path: Option<PathBuf>,
91 pub output_store_path: Option<PathBuf>,
93 pub seed_blueprint_id: Option<String>,
95 pub default_agent_kind: Option<String>,
97 pub token_secret: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
103pub struct ResolvedConfig {
104 pub bind: SocketAddr,
106 pub enable_enhance_flow: bool,
108 pub blueprint_ref_base: Option<PathBuf>,
110 pub git_store_path: PathBuf,
114 pub issue_store_path: Option<PathBuf>,
117 pub enhance_setting_store_path: Option<PathBuf>,
120 pub enhance_log_store_path: Option<PathBuf>,
123 pub output_store_path: Option<PathBuf>,
126 pub seed_blueprint_id: String,
128 pub default_agent_kind: Option<String>,
131 pub token_secret: Option<String>,
133}
134
135impl Default for ResolvedConfig {
136 fn default() -> Self {
137 Self {
138 bind: default_bind(),
139 enable_enhance_flow: false,
140 blueprint_ref_base: None,
141 git_store_path: default_store_path(),
142 issue_store_path: None,
143 enhance_setting_store_path: None,
144 enhance_log_store_path: None,
145 output_store_path: None,
146 seed_blueprint_id: "main".into(),
147 default_agent_kind: None,
148 token_secret: None,
149 }
150 }
151}
152
153fn default_bind() -> SocketAddr {
154 "127.0.0.1:7777"
155 .parse()
156 .expect("literal default bind must parse")
157}
158
159pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
164 match std::fs::read_to_string(path) {
165 Ok(text) => toml::from_str(&text)
166 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
168 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
169 }
170}
171
172pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
175 let default = ResolvedConfig::default();
176
177 let bind = match cli.bind.or(file.bind) {
178 Some(s) => s
179 .parse::<SocketAddr>()
180 .map_err(|e| format!("bind {s:?}: {e}"))?,
181 None => default.bind,
182 };
183
184 Ok(ResolvedConfig {
185 bind,
186 enable_enhance_flow: cli
187 .enable_enhance_flow
188 .or(file.enable_enhance_flow)
189 .unwrap_or(default.enable_enhance_flow),
190 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
191 git_store_path: cli
192 .git_store_path
193 .or(file.git_store_path)
194 .unwrap_or_else(default_store_path),
195 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
196 enhance_setting_store_path: cli
197 .enhance_setting_store_path
198 .or(file.enhance_setting_store_path),
199 enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
200 output_store_path: cli.output_store_path.or(file.output_store_path),
201 seed_blueprint_id: cli
202 .seed_blueprint_id
203 .or(file.seed_blueprint_id)
204 .unwrap_or(default.seed_blueprint_id),
205 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
206 token_secret: cli.token_secret.or(file.token_secret),
207 })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn resolve_cli_flag_wins_over_file_and_default() {
216 let cli = CliOverrides {
217 bind: Some("127.0.0.1:9999".into()),
218 ..Default::default()
219 };
220 let file = FileConfig {
221 bind: Some("127.0.0.1:8888".into()),
222 ..Default::default()
223 };
224 let resolved = resolve(cli, file).expect("resolve");
225 assert_eq!(
226 resolved.bind,
227 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
228 );
229 }
230
231 #[test]
232 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
233 let cli = CliOverrides::default();
234 let file = FileConfig {
235 seed_blueprint_id: Some("from-file".into()),
236 enable_enhance_flow: Some(true),
237 ..Default::default()
238 };
239 let resolved = resolve(cli, file).expect("resolve");
240 assert_eq!(resolved.seed_blueprint_id, "from-file");
241 assert!(resolved.enable_enhance_flow);
242 }
243
244 #[test]
245 fn resolve_built_in_default_when_cli_and_file_absent() {
246 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
247 assert_eq!(resolved.bind, default_bind());
248 assert_eq!(resolved.seed_blueprint_id, "main");
249 assert!(!resolved.enable_enhance_flow);
250 assert_eq!(resolved.git_store_path, default_store_path());
251 }
252
253 #[test]
254 fn resolve_git_store_path_file_overrides_default_location() {
255 let file = FileConfig {
256 git_store_path: Some(PathBuf::from("/tmp/custom-store")),
257 ..Default::default()
258 };
259 let resolved = resolve(CliOverrides::default(), file).expect("resolve");
260 assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
261 }
262
263 #[test]
264 fn resolve_bind_parse_error_is_propagated() {
265 let cli = CliOverrides {
266 bind: Some("not-a-valid-addr".into()),
267 ..Default::default()
268 };
269 let err = resolve(cli, FileConfig::default()).unwrap_err();
270 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
271 }
272
273 #[test]
274 fn load_file_config_rejects_unknown_fields() {
275 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
276 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
277 let msg = err.to_string();
278 assert!(
279 msg.contains("typo_field") || msg.contains("unknown field"),
280 "unexpected error message: {msg}"
281 );
282 }
283
284 #[test]
285 fn load_file_config_missing_file_falls_back_to_default() {
286 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
287 let cfg = load_file_config(path).expect("missing file should not error");
288 assert_eq!(cfg, FileConfig::default());
289 }
290
291 #[test]
292 fn load_file_config_parses_valid_toml() {
293 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
294 std::fs::create_dir_all(&dir).expect("create tmp dir");
295 let path = dir.join("config.toml");
296 std::fs::write(
297 &path,
298 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
299 )
300 .expect("write tmp config");
301 let cfg = load_file_config(&path).expect("parse tmp config");
302 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
303 assert_eq!(cfg.enable_enhance_flow, Some(true));
304 let _ = std::fs::remove_dir_all(&dir);
305 }
306}