1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12 pub node: NodeConfig,
14 pub cli: CliConfig,
16 pub daemon: DaemonConfig,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct NodeConfig {
22 pub id: Option<String>,
24 pub role: String,
26 pub listen_address: String,
28 pub bootstrap_nodes: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CliConfig {
34 pub default_output_format: String,
36 pub enable_colors: bool,
38 pub command_timeout_secs: u64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DaemonConfig {
44 pub enable_mdns: bool,
46 pub enable_gossip: bool,
48 pub enable_registry: bool,
50 pub enable_migration: bool,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 node: NodeConfig {
58 id: None,
59 role: "edge".to_string(),
60 listen_address: "0.0.0.0:8080".to_string(),
61 bootstrap_nodes: Vec::new(),
62 },
63 cli: CliConfig {
64 default_output_format: "table".to_string(),
65 enable_colors: true,
66 command_timeout_secs: 30,
67 },
68 daemon: DaemonConfig {
69 enable_mdns: true,
70 enable_gossip: true,
71 enable_registry: true,
72 enable_migration: true,
73 },
74 }
75 }
76}
77
78impl Config {
79 pub fn default_path() -> PathBuf {
81 if let Some(config_dir) = dirs::config_dir() {
82 config_dir.join("mielin").join("config.toml")
83 } else {
84 PathBuf::from(".mielin").join("config.toml")
85 }
86 }
87
88 pub fn config_path() -> Result<PathBuf> {
90 let config_dir = dirs::config_dir()
91 .context("Failed to determine config directory")?
92 .join("mielin");
93
94 fs::create_dir_all(&config_dir).context("Failed to create config directory")?;
95
96 Ok(config_dir.join("config.toml"))
97 }
98
99 pub fn load() -> Result<Self> {
102 let config_path = Self::config_path()?;
103 Self::load_from_path(&config_path)
104 }
105
106 pub fn load_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
108 let path = path.as_ref();
109
110 let mut config = if path.exists() {
111 let contents = fs::read_to_string(path).context("Failed to read config file")?;
112
113 let processed_contents = Self::process_template(&contents)?;
115
116 toml::from_str(&processed_contents).context("Failed to parse config file")?
117 } else {
118 let config = Self::default();
119 if let Some(parent) = path.parent() {
120 fs::create_dir_all(parent)?;
121 }
122 config.save_to_path(path)?;
123 config
124 };
125
126 config.apply_env_overrides();
128
129 Ok(config)
130 }
131
132 fn process_template(content: &str) -> Result<String> {
135 let mut result = content.to_string();
136 let mut vars = HashMap::new();
137
138 for (key, value) in std::env::vars() {
140 vars.insert(key, value);
141 }
142
143 let re_with_default = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}")
145 .context("Failed to compile regex")?;
146
147 for cap in re_with_default.captures_iter(content) {
148 let full_match = cap.get(0).unwrap().as_str();
149 let var_name = cap.get(1).unwrap().as_str();
150 let default_value = cap.get(2).unwrap().as_str();
151
152 let replacement = vars
153 .get(var_name)
154 .map(|v| v.as_str())
155 .unwrap_or(default_value);
156 result = result.replace(full_match, replacement);
157 }
158
159 let re_simple = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
161 .context("Failed to compile regex")?;
162
163 for cap in re_simple.captures_iter(&result.clone()) {
164 let full_match = cap.get(0).unwrap().as_str();
165 let var_name = cap.get(1).unwrap().as_str();
166
167 if let Some(value) = vars.get(var_name) {
168 result = result.replace(full_match, value);
169 } else {
170 result = result.replace(full_match, "");
172 }
173 }
174
175 Ok(result)
176 }
177
178 fn apply_env_overrides(&mut self) {
181 if let Ok(val) = std::env::var("MIELIN_NODE_ID") {
183 self.node.id = Some(val);
184 }
185 if let Ok(val) = std::env::var("MIELIN_NODE_ROLE") {
186 self.node.role = val;
187 }
188 if let Ok(val) = std::env::var("MIELIN_NODE_LISTEN_ADDRESS") {
189 self.node.listen_address = val;
190 }
191 if let Ok(val) = std::env::var("MIELIN_NODE_BOOTSTRAP_NODES") {
192 self.node.bootstrap_nodes = val.split(',').map(|s| s.trim().to_string()).collect();
193 }
194
195 if let Ok(val) = std::env::var("MIELIN_CLI_DEFAULT_OUTPUT_FORMAT") {
197 self.cli.default_output_format = val;
198 }
199 if let Ok(val) = std::env::var("MIELIN_CLI_ENABLE_COLORS") {
200 if let Ok(parsed) = val.parse::<bool>() {
201 self.cli.enable_colors = parsed;
202 }
203 }
204 if let Ok(val) = std::env::var("MIELIN_CLI_COMMAND_TIMEOUT_SECS") {
205 if let Ok(parsed) = val.parse::<u64>() {
206 self.cli.command_timeout_secs = parsed;
207 }
208 }
209
210 if let Ok(val) = std::env::var("MIELIN_DAEMON_ENABLE_MDNS") {
212 if let Ok(parsed) = val.parse::<bool>() {
213 self.daemon.enable_mdns = parsed;
214 }
215 }
216 if let Ok(val) = std::env::var("MIELIN_DAEMON_ENABLE_GOSSIP") {
217 if let Ok(parsed) = val.parse::<bool>() {
218 self.daemon.enable_gossip = parsed;
219 }
220 }
221 if let Ok(val) = std::env::var("MIELIN_DAEMON_ENABLE_REGISTRY") {
222 if let Ok(parsed) = val.parse::<bool>() {
223 self.daemon.enable_registry = parsed;
224 }
225 }
226 if let Ok(val) = std::env::var("MIELIN_DAEMON_ENABLE_MIGRATION") {
227 if let Ok(parsed) = val.parse::<bool>() {
228 self.daemon.enable_migration = parsed;
229 }
230 }
231 }
232
233 pub fn save(&self) -> Result<()> {
235 let config_path = Self::config_path()?;
236 self.save_to_path(&config_path)
237 }
238
239 pub fn save_to_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
241 let contents = toml::to_string_pretty(self).context("Failed to serialize config")?;
242 fs::write(path.as_ref(), contents).context("Failed to write config file")?;
243 Ok(())
244 }
245
246 pub fn get(&self, key: &str) -> Option<String> {
248 match key {
249 "node.id" => self.node.id.clone(),
250 "node.role" => Some(self.node.role.clone()),
251 "node.listen_address" => Some(self.node.listen_address.clone()),
252 "cli.default_output_format" => Some(self.cli.default_output_format.clone()),
253 "cli.enable_colors" => Some(self.cli.enable_colors.to_string()),
254 "cli.command_timeout_secs" => Some(self.cli.command_timeout_secs.to_string()),
255 "daemon.enable_mdns" => Some(self.daemon.enable_mdns.to_string()),
256 "daemon.enable_gossip" => Some(self.daemon.enable_gossip.to_string()),
257 "daemon.enable_registry" => Some(self.daemon.enable_registry.to_string()),
258 "daemon.enable_migration" => Some(self.daemon.enable_migration.to_string()),
259 _ => None,
260 }
261 }
262
263 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
265 match key {
266 "node.id" => self.node.id = Some(value.to_string()),
267 "node.role" => self.node.role = value.to_string(),
268 "node.listen_address" => self.node.listen_address = value.to_string(),
269 "cli.default_output_format" => self.cli.default_output_format = value.to_string(),
270 "cli.enable_colors" => {
271 self.cli.enable_colors = value.parse().context("Invalid boolean value")?;
272 }
273 "cli.command_timeout_secs" => {
274 self.cli.command_timeout_secs = value.parse().context("Invalid integer value")?;
275 }
276 "daemon.enable_mdns" => {
277 self.daemon.enable_mdns = value.parse().context("Invalid boolean value")?;
278 }
279 "daemon.enable_gossip" => {
280 self.daemon.enable_gossip = value.parse().context("Invalid boolean value")?;
281 }
282 "daemon.enable_registry" => {
283 self.daemon.enable_registry = value.parse().context("Invalid boolean value")?;
284 }
285 "daemon.enable_migration" => {
286 self.daemon.enable_migration = value.parse().context("Invalid boolean value")?;
287 }
288 "node.bootstrap_nodes" => {
289 self.node.bootstrap_nodes =
290 value.split(',').map(|s| s.trim().to_string()).collect();
291 }
292 _ => anyhow::bail!("Unknown configuration key: {}", key),
293 }
294 Ok(())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_default_config() {
304 let config = Config::default();
305 assert_eq!(config.node.role, "edge");
306 assert_eq!(config.node.listen_address, "0.0.0.0:8080");
307 assert!(config.daemon.enable_mdns);
308 }
309
310 #[test]
311 fn test_get_config() {
312 let config = Config::default();
313 assert_eq!(config.get("node.role"), Some("edge".to_string()));
314 assert_eq!(config.get("cli.enable_colors"), Some("true".to_string()));
315 assert_eq!(config.get("unknown"), None);
316 }
317
318 #[test]
319 fn test_set_config() {
320 let mut config = Config::default();
321 config.set("node.role", "core").unwrap();
322 assert_eq!(config.node.role, "core");
323
324 config.set("cli.command_timeout_secs", "60").unwrap();
325 assert_eq!(config.cli.command_timeout_secs, 60);
326 }
327
328 #[test]
329 fn test_serialize_deserialize() {
330 let config = Config::default();
331 let toml_str = toml::to_string(&config).unwrap();
332 let deserialized: Config = toml::from_str(&toml_str).unwrap();
333 assert_eq!(config.node.role, deserialized.node.role);
334 }
335
336 #[test]
337 fn test_env_override_node_role() {
338 std::env::set_var("MIELIN_NODE_ROLE", "core");
339 let mut config = Config::default();
340 config.apply_env_overrides();
341 assert_eq!(config.node.role, "core");
342 std::env::remove_var("MIELIN_NODE_ROLE");
343 }
344
345 #[test]
346 fn test_env_override_cli_timeout() {
347 std::env::set_var("MIELIN_CLI_COMMAND_TIMEOUT_SECS", "120");
348 let mut config = Config::default();
349 config.apply_env_overrides();
350 assert_eq!(config.cli.command_timeout_secs, 120);
351 std::env::remove_var("MIELIN_CLI_COMMAND_TIMEOUT_SECS");
352 }
353
354 #[test]
355 fn test_env_override_bool_values() {
356 std::env::set_var("MIELIN_CLI_ENABLE_COLORS", "false");
357 std::env::set_var("MIELIN_DAEMON_ENABLE_MDNS", "false");
358 let mut config = Config::default();
359 config.apply_env_overrides();
360 assert!(!config.cli.enable_colors);
361 assert!(!config.daemon.enable_mdns);
362 std::env::remove_var("MIELIN_CLI_ENABLE_COLORS");
363 std::env::remove_var("MIELIN_DAEMON_ENABLE_MDNS");
364 }
365
366 #[test]
367 fn test_env_override_bootstrap_nodes() {
368 std::env::set_var("MIELIN_NODE_BOOTSTRAP_NODES", "node1:8080, node2:8080");
369 let mut config = Config::default();
370 config.apply_env_overrides();
371 assert_eq!(config.node.bootstrap_nodes.len(), 2);
372 assert_eq!(config.node.bootstrap_nodes[0], "node1:8080");
373 assert_eq!(config.node.bootstrap_nodes[1], "node2:8080");
374 std::env::remove_var("MIELIN_NODE_BOOTSTRAP_NODES");
375 }
376
377 #[test]
378 fn test_template_simple_substitution() {
379 std::env::set_var("TEST_VAR", "test_value");
380 let input = "key = \"${TEST_VAR}\"";
381 let result = Config::process_template(input).unwrap();
382 assert_eq!(result, "key = \"test_value\"");
383 std::env::remove_var("TEST_VAR");
384 }
385
386 #[test]
387 fn test_template_with_default() {
388 std::env::remove_var("NONEXISTENT_VAR");
389 let input = "key = \"${NONEXISTENT_VAR:-default_value}\"";
390 let result = Config::process_template(input).unwrap();
391 assert_eq!(result, "key = \"default_value\"");
392 }
393
394 #[test]
395 fn test_template_with_default_override() {
396 std::env::set_var("EXISTING_VAR", "actual_value");
397 let input = "key = \"${EXISTING_VAR:-default_value}\"";
398 let result = Config::process_template(input).unwrap();
399 assert_eq!(result, "key = \"actual_value\"");
400 std::env::remove_var("EXISTING_VAR");
401 }
402
403 #[test]
404 fn test_template_multiple_variables() {
405 std::env::set_var("VAR1", "value1");
406 std::env::set_var("VAR2", "value2");
407 let input = "key1 = \"${VAR1}\"\nkey2 = \"${VAR2:-default}\"";
408 let result = Config::process_template(input).unwrap();
409 assert!(result.contains("value1"));
410 assert!(result.contains("value2"));
411 std::env::remove_var("VAR1");
412 std::env::remove_var("VAR2");
413 }
414
415 #[test]
416 fn test_template_empty_when_not_found() {
417 std::env::remove_var("MISSING_VAR");
418 let input = "key = \"${MISSING_VAR}\"";
419 let result = Config::process_template(input).unwrap();
420 assert_eq!(result, "key = \"\"");
421 }
422}