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
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 issue_store_path: Option<PathBuf>,
43 pub enhance_setting_store_path: Option<PathBuf>,
46 pub enhance_log_store_path: Option<PathBuf>,
49 pub output_store_path: Option<PathBuf>,
52 pub seed_blueprint_id: Option<String>,
54 pub default_agent_kind: Option<String>,
57 pub token_secret: Option<String>,
59}
60
61#[derive(Debug, Default, Clone)]
65pub struct CliOverrides {
66 pub bind: Option<String>,
68 pub enable_enhance_flow: Option<bool>,
70 pub blueprint_ref_base: Option<PathBuf>,
72 pub git_store_path: Option<PathBuf>,
74 pub issue_store_path: Option<PathBuf>,
76 pub enhance_setting_store_path: Option<PathBuf>,
78 pub enhance_log_store_path: Option<PathBuf>,
80 pub output_store_path: Option<PathBuf>,
82 pub seed_blueprint_id: Option<String>,
84 pub default_agent_kind: Option<String>,
86 pub token_secret: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq)]
92pub struct ResolvedConfig {
93 pub bind: SocketAddr,
95 pub enable_enhance_flow: bool,
97 pub blueprint_ref_base: Option<PathBuf>,
99 pub git_store_path: Option<PathBuf>,
101 pub issue_store_path: Option<PathBuf>,
104 pub enhance_setting_store_path: Option<PathBuf>,
107 pub enhance_log_store_path: Option<PathBuf>,
110 pub output_store_path: Option<PathBuf>,
113 pub seed_blueprint_id: String,
115 pub default_agent_kind: Option<String>,
118 pub token_secret: Option<String>,
120}
121
122impl Default for ResolvedConfig {
123 fn default() -> Self {
124 Self {
125 bind: default_bind(),
126 enable_enhance_flow: false,
127 blueprint_ref_base: None,
128 git_store_path: None,
129 issue_store_path: None,
130 enhance_setting_store_path: None,
131 enhance_log_store_path: None,
132 output_store_path: None,
133 seed_blueprint_id: "main".into(),
134 default_agent_kind: None,
135 token_secret: None,
136 }
137 }
138}
139
140fn default_bind() -> SocketAddr {
141 "127.0.0.1:7777"
142 .parse()
143 .expect("literal default bind must parse")
144}
145
146pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
151 match std::fs::read_to_string(path) {
152 Ok(text) => toml::from_str(&text)
153 .map_err(|e| format!("config file {} parse error: {e}", path.display())),
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
155 Err(e) => Err(format!("config file {} read error: {e}", path.display())),
156 }
157}
158
159pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
162 let default = ResolvedConfig::default();
163
164 let bind = match cli.bind.or(file.bind) {
165 Some(s) => s
166 .parse::<SocketAddr>()
167 .map_err(|e| format!("bind {s:?}: {e}"))?,
168 None => default.bind,
169 };
170
171 Ok(ResolvedConfig {
172 bind,
173 enable_enhance_flow: cli
174 .enable_enhance_flow
175 .or(file.enable_enhance_flow)
176 .unwrap_or(default.enable_enhance_flow),
177 blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
178 git_store_path: cli.git_store_path.or(file.git_store_path),
179 issue_store_path: cli.issue_store_path.or(file.issue_store_path),
180 enhance_setting_store_path: cli
181 .enhance_setting_store_path
182 .or(file.enhance_setting_store_path),
183 enhance_log_store_path: cli
184 .enhance_log_store_path
185 .or(file.enhance_log_store_path),
186 output_store_path: cli.output_store_path.or(file.output_store_path),
187 seed_blueprint_id: cli
188 .seed_blueprint_id
189 .or(file.seed_blueprint_id)
190 .unwrap_or(default.seed_blueprint_id),
191 default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
192 token_secret: cli.token_secret.or(file.token_secret),
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn resolve_cli_flag_wins_over_file_and_default() {
202 let cli = CliOverrides {
203 bind: Some("127.0.0.1:9999".into()),
204 ..Default::default()
205 };
206 let file = FileConfig {
207 bind: Some("127.0.0.1:8888".into()),
208 ..Default::default()
209 };
210 let resolved = resolve(cli, file).expect("resolve");
211 assert_eq!(
212 resolved.bind,
213 "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
214 );
215 }
216
217 #[test]
218 fn resolve_file_wins_over_built_in_default_when_cli_absent() {
219 let cli = CliOverrides::default();
220 let file = FileConfig {
221 seed_blueprint_id: Some("from-file".into()),
222 enable_enhance_flow: Some(true),
223 ..Default::default()
224 };
225 let resolved = resolve(cli, file).expect("resolve");
226 assert_eq!(resolved.seed_blueprint_id, "from-file");
227 assert!(resolved.enable_enhance_flow);
228 }
229
230 #[test]
231 fn resolve_built_in_default_when_cli_and_file_absent() {
232 let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
233 assert_eq!(resolved.bind, default_bind());
234 assert_eq!(resolved.seed_blueprint_id, "main");
235 assert!(!resolved.enable_enhance_flow);
236 assert_eq!(resolved.git_store_path, None);
237 }
238
239 #[test]
240 fn resolve_bind_parse_error_is_propagated() {
241 let cli = CliOverrides {
242 bind: Some("not-a-valid-addr".into()),
243 ..Default::default()
244 };
245 let err = resolve(cli, FileConfig::default()).unwrap_err();
246 assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
247 }
248
249 #[test]
250 fn load_file_config_rejects_unknown_fields() {
251 let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
252 let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
253 let msg = err.to_string();
254 assert!(
255 msg.contains("typo_field") || msg.contains("unknown field"),
256 "unexpected error message: {msg}"
257 );
258 }
259
260 #[test]
261 fn load_file_config_missing_file_falls_back_to_default() {
262 let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
263 let cfg = load_file_config(path).expect("missing file should not error");
264 assert_eq!(cfg, FileConfig::default());
265 }
266
267 #[test]
268 fn load_file_config_parses_valid_toml() {
269 let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
270 std::fs::create_dir_all(&dir).expect("create tmp dir");
271 let path = dir.join("config.toml");
272 std::fs::write(
273 &path,
274 "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
275 )
276 .expect("write tmp config");
277 let cfg = load_file_config(&path).expect("parse tmp config");
278 assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
279 assert_eq!(cfg.enable_enhance_flow, Some(true));
280 let _ = std::fs::remove_dir_all(&dir);
281 }
282}