mielin_cli/
config.rs

1//! Configuration management for MielinCTL
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9/// MielinCTL configuration
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12    /// Node configuration
13    pub node: NodeConfig,
14    /// CLI configuration
15    pub cli: CliConfig,
16    /// Daemon configuration
17    pub daemon: DaemonConfig,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct NodeConfig {
22    /// Node ID (optional, generated if not provided)
23    pub id: Option<String>,
24    /// Node role (core, relay, edge)
25    pub role: String,
26    /// Listen address for the mesh service
27    pub listen_address: String,
28    /// Bootstrap nodes to connect to
29    pub bootstrap_nodes: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CliConfig {
34    /// Default output format
35    pub default_output_format: String,
36    /// Enable colors in output
37    pub enable_colors: bool,
38    /// Timeout for commands in seconds
39    pub command_timeout_secs: u64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DaemonConfig {
44    /// Enable mDNS discovery
45    pub enable_mdns: bool,
46    /// Enable gossip protocol
47    pub enable_gossip: bool,
48    /// Enable registry
49    pub enable_registry: bool,
50    /// Enable migration
51    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    /// Get the default configuration file path (without creating directories)
80    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    /// Get the configuration file path (creates directory if needed)
89    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    /// Load configuration from file, or create default if it doesn't exist
100    /// Environment variables will override file settings
101    pub fn load() -> Result<Self> {
102        let config_path = Self::config_path()?;
103        Self::load_from_path(&config_path)
104    }
105
106    /// Load configuration from a specific path
107    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            // Apply template substitution before parsing
114            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        // Apply environment variable overrides
127        config.apply_env_overrides();
128
129        Ok(config)
130    }
131
132    /// Process template variables in config content
133    /// Supports syntax: ${VAR} and ${VAR:-default}
134    fn process_template(content: &str) -> Result<String> {
135        let mut result = content.to_string();
136        let mut vars = HashMap::new();
137
138        // Collect all environment variables
139        for (key, value) in std::env::vars() {
140            vars.insert(key, value);
141        }
142
143        // Process ${VAR:-default} patterns first (with defaults)
144        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        // Process ${VAR} patterns (without defaults)
160        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                // If variable is not found and no default, leave it as empty string
171                result = result.replace(full_match, "");
172            }
173        }
174
175        Ok(result)
176    }
177
178    /// Apply environment variable overrides
179    /// Environment variables use the format: MIELIN_SECTION_KEY (e.g., MIELIN_NODE_ROLE)
180    fn apply_env_overrides(&mut self) {
181        // Node configuration
182        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        // CLI configuration
196        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        // Daemon configuration
211        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    /// Save configuration to default file
234    pub fn save(&self) -> Result<()> {
235        let config_path = Self::config_path()?;
236        self.save_to_path(&config_path)
237    }
238
239    /// Save configuration to a specific path
240    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    /// Get configuration value by key
247    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    /// Set configuration value by key
264    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}