mielin_cli/
config_validator.rs

1//! Configuration validation and migration utilities
2
3use crate::config::Config;
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8/// Configuration validation result
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ValidationResult {
11    /// Is the configuration valid
12    pub is_valid: bool,
13    /// Validation errors
14    pub errors: Vec<ValidationError>,
15    /// Validation warnings
16    pub warnings: Vec<ValidationWarning>,
17    /// Suggested fixes
18    pub suggestions: Vec<ValidationSuggestion>,
19}
20
21/// Validation error
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ValidationError {
24    /// Field path (e.g., "node.listen_address")
25    pub field: String,
26    /// Error message
27    pub message: String,
28    /// Severity level
29    pub severity: ErrorSeverity,
30}
31
32/// Error severity levels
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ErrorSeverity {
36    /// Critical error - config cannot be used
37    Critical,
38    /// Error - config may not work correctly
39    Error,
40}
41
42/// Validation warning
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ValidationWarning {
45    /// Field path
46    pub field: String,
47    /// Warning message
48    pub message: String,
49}
50
51/// Validation suggestion
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ValidationSuggestion {
54    /// Field path
55    pub field: String,
56    /// Suggested value
57    pub suggested_value: String,
58    /// Reason for suggestion
59    pub reason: String,
60}
61
62/// Configuration validator
63pub struct ConfigValidator {
64    config: Config,
65}
66
67impl ConfigValidator {
68    /// Create a new validator for the given configuration
69    pub fn new(config: Config) -> Self {
70        Self { config }
71    }
72
73    /// Load configuration from file and create a validator
74    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
75        let config = Config::load_from_path(path.as_ref())?;
76        Ok(Self::new(config))
77    }
78
79    /// Validate the configuration
80    pub fn validate(&self) -> ValidationResult {
81        let mut errors = Vec::new();
82        let mut warnings = Vec::new();
83        let mut suggestions = Vec::new();
84
85        // Validate node configuration
86        self.validate_node(&mut errors, &mut warnings, &mut suggestions);
87
88        // Validate daemon configuration
89        self.validate_daemon(&mut errors, &mut warnings, &mut suggestions);
90
91        // Validate CLI configuration
92        self.validate_cli(&mut errors, &mut warnings, &mut suggestions);
93
94        ValidationResult {
95            is_valid: errors.is_empty(),
96            errors,
97            warnings,
98            suggestions,
99        }
100    }
101
102    fn validate_node(
103        &self,
104        errors: &mut Vec<ValidationError>,
105        warnings: &mut Vec<ValidationWarning>,
106        suggestions: &mut Vec<ValidationSuggestion>,
107    ) {
108        // Validate node ID
109        if let Some(ref id) = self.config.node.id {
110            if id.is_empty() {
111                errors.push(ValidationError {
112                    field: "node.id".to_string(),
113                    message: "Node ID cannot be empty".to_string(),
114                    severity: ErrorSeverity::Critical,
115                });
116            } else if id.len() > 64 {
117                warnings.push(ValidationWarning {
118                    field: "node.id".to_string(),
119                    message: "Node ID is very long (>64 characters), this may cause issues"
120                        .to_string(),
121                });
122            }
123        } else {
124            warnings.push(ValidationWarning {
125                field: "node.id".to_string(),
126                message: "Node ID is not set, will be auto-generated at runtime".to_string(),
127            });
128        }
129
130        // Validate role
131        let valid_roles = ["core", "relay", "edge"];
132        if !valid_roles.contains(&self.config.node.role.as_str()) {
133            errors.push(ValidationError {
134                field: "node.role".to_string(),
135                message: format!(
136                    "Invalid node role '{}'. Must be one of: core, relay, edge",
137                    self.config.node.role
138                ),
139                severity: ErrorSeverity::Error,
140            });
141        }
142
143        // Validate listen address
144        if let Err(e) = self
145            .config
146            .node
147            .listen_address
148            .parse::<std::net::SocketAddr>()
149        {
150            errors.push(ValidationError {
151                field: "node.listen_address".to_string(),
152                message: format!("Invalid listen address: {}", e),
153                severity: ErrorSeverity::Critical,
154            });
155        }
156
157        // Validate bootstrap nodes
158        for (idx, node) in self.config.node.bootstrap_nodes.iter().enumerate() {
159            if let Err(e) = node.parse::<std::net::SocketAddr>() {
160                errors.push(ValidationError {
161                    field: format!("node.bootstrap_nodes[{}]", idx),
162                    message: format!("Invalid bootstrap node address '{}': {}", node, e),
163                    severity: ErrorSeverity::Error,
164                });
165            }
166        }
167
168        // Suggestions
169        if self.config.node.role == "edge" && !self.config.node.bootstrap_nodes.is_empty() {
170            suggestions.push(ValidationSuggestion {
171                field: "node.role".to_string(),
172                suggested_value: "relay".to_string(),
173                reason: "Edge nodes with bootstrap nodes may want to be relay nodes instead"
174                    .to_string(),
175            });
176        }
177    }
178
179    fn validate_daemon(
180        &self,
181        errors: &mut Vec<ValidationError>,
182        warnings: &mut Vec<ValidationWarning>,
183        _suggestions: &mut Vec<ValidationSuggestion>,
184    ) {
185        // Validate that at least one service is enabled
186        if !self.config.daemon.enable_mdns
187            && !self.config.daemon.enable_gossip
188            && !self.config.daemon.enable_registry
189            && !self.config.daemon.enable_migration
190        {
191            warnings.push(ValidationWarning {
192                field: "daemon".to_string(),
193                message: "All daemon services are disabled, the daemon may not function properly"
194                    .to_string(),
195            });
196        }
197
198        // Warn if mDNS is enabled on non-local networks
199        if self.config.daemon.enable_mdns && !is_local_address(&self.config.node.listen_address) {
200            warnings.push(ValidationWarning {
201                field: "daemon.enable_mdns".to_string(),
202                message:
203                    "mDNS is enabled but listen address is not local, this may not work as expected"
204                        .to_string(),
205            });
206        }
207
208        // Validate gossip is enabled for distributed operations
209        if self.config.daemon.enable_registry && !self.config.daemon.enable_gossip {
210            errors.push(ValidationError {
211                field: "daemon.enable_gossip".to_string(),
212                message: "Gossip must be enabled when registry is enabled".to_string(),
213                severity: ErrorSeverity::Error,
214            });
215        }
216    }
217
218    fn validate_cli(
219        &self,
220        errors: &mut Vec<ValidationError>,
221        warnings: &mut Vec<ValidationWarning>,
222        suggestions: &mut Vec<ValidationSuggestion>,
223    ) {
224        // Validate command timeout
225        if self.config.cli.command_timeout_secs == 0 {
226            errors.push(ValidationError {
227                field: "cli.command_timeout_secs".to_string(),
228                message: "Command timeout cannot be 0".to_string(),
229                severity: ErrorSeverity::Error,
230            });
231        } else if self.config.cli.command_timeout_secs < 5 {
232            warnings.push(ValidationWarning {
233                field: "cli.command_timeout_secs".to_string(),
234                message: "Command timeout is very short (<5s), commands may timeout prematurely"
235                    .to_string(),
236            });
237        } else if self.config.cli.command_timeout_secs > 300 {
238            warnings.push(ValidationWarning {
239                field: "cli.command_timeout_secs".to_string(),
240                message: "Command timeout is very long (>5min), failed commands may hang"
241                    .to_string(),
242            });
243        }
244
245        // Validate output format
246        let valid_formats = ["table", "json", "yaml", "quiet"];
247        if !valid_formats.contains(&self.config.cli.default_output_format.as_str()) {
248            errors.push(ValidationError {
249                field: "cli.default_output_format".to_string(),
250                message: format!(
251                    "Invalid output format '{}'. Must be one of: table, json, yaml, quiet",
252                    self.config.cli.default_output_format
253                ),
254                severity: ErrorSeverity::Error,
255            });
256        }
257
258        // Suggestions for performance
259        if self.config.cli.enable_colors && self.config.cli.default_output_format == "json" {
260            suggestions.push(ValidationSuggestion {
261                field: "cli.enable_colors".to_string(),
262                suggested_value: "false".to_string(),
263                reason: "Colors are not needed for JSON output and may interfere with parsing"
264                    .to_string(),
265            });
266        }
267    }
268
269    /// Auto-fix configuration issues
270    pub fn auto_fix(&mut self) -> Vec<String> {
271        let mut fixes = Vec::new();
272
273        // Fix empty or missing node ID
274        if self
275            .config
276            .node
277            .id
278            .as_ref()
279            .map(|id| id.is_empty())
280            .unwrap_or(false)
281        {
282            let new_id = format!("node-{}", uuid::Uuid::new_v4());
283            self.config.node.id = Some(new_id.clone());
284            fixes.push(format!("Generated node ID: {}", new_id));
285        }
286
287        // Fix invalid role
288        let valid_roles = ["core", "relay", "edge"];
289        if !valid_roles.contains(&self.config.node.role.as_str()) {
290            self.config.node.role = "edge".to_string();
291            fixes.push("Set node role to 'edge' (default)".to_string());
292        }
293
294        // Fix invalid timeout
295        if self.config.cli.command_timeout_secs == 0 {
296            self.config.cli.command_timeout_secs = 30;
297            fixes.push("Set command timeout to 30 seconds (default)".to_string());
298        }
299
300        // Fix invalid output format
301        let valid_formats = ["table", "json", "yaml", "quiet"];
302        if !valid_formats.contains(&self.config.cli.default_output_format.as_str()) {
303            self.config.cli.default_output_format = "table".to_string();
304            fixes.push("Set output format to 'table' (default)".to_string());
305        }
306
307        // Enable gossip if registry is enabled
308        if self.config.daemon.enable_registry && !self.config.daemon.enable_gossip {
309            self.config.daemon.enable_gossip = true;
310            fixes.push("Enabled gossip (required for registry)".to_string());
311        }
312
313        fixes
314    }
315
316    /// Get the configuration
317    pub fn config(&self) -> &Config {
318        &self.config
319    }
320
321    /// Save the configuration to file
322    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
323        self.config.save_to_path(path.as_ref())
324    }
325}
326
327/// Check if an address is local
328fn is_local_address(addr: &str) -> bool {
329    addr.starts_with("127.") || addr.starts_with("localhost") || addr.starts_with("0.0.0.0")
330}
331
332/// Configuration migration utilities
333pub struct ConfigMigrator;
334
335impl ConfigMigrator {
336    /// Migrate configuration from version to version
337    pub fn migrate(from_version: &str, _config: &mut Config) -> Result<Vec<String>> {
338        let mut changes = Vec::new();
339
340        match from_version {
341            "0.0.1" => {
342                // Example migration: Add new fields with defaults
343                changes.push("Migrated from v0.0.1 to current version".to_string());
344            }
345            _ => {
346                anyhow::bail!("Unknown configuration version: {}", from_version);
347            }
348        }
349
350        Ok(changes)
351    }
352
353    /// Detect configuration version
354    pub fn detect_version(_config: &Config) -> String {
355        // In a real implementation, this would check for version markers
356        // For now, we'll just return the current version
357        env!("CARGO_PKG_VERSION").to_string()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_validator_empty_node_id() {
367        let mut config = Config::default();
368        config.node.id = Some(String::new());
369
370        let validator = ConfigValidator::new(config);
371        let result = validator.validate();
372
373        assert!(!result.is_valid);
374        assert!(result.errors.iter().any(|e| e.field == "node.id"));
375    }
376
377    #[test]
378    fn test_validator_invalid_role() {
379        let mut config = Config::default();
380        config.node.role = "invalid".to_string();
381
382        let validator = ConfigValidator::new(config);
383        let result = validator.validate();
384
385        assert!(!result.is_valid);
386        assert!(result.errors.iter().any(|e| e.field == "node.role"));
387    }
388
389    #[test]
390    fn test_validator_invalid_listen_address() {
391        let mut config = Config::default();
392        config.node.listen_address = "invalid".to_string();
393
394        let validator = ConfigValidator::new(config);
395        let result = validator.validate();
396
397        assert!(!result.is_valid);
398        assert!(result
399            .errors
400            .iter()
401            .any(|e| e.field == "node.listen_address"));
402    }
403
404    #[test]
405    fn test_auto_fix_empty_node_id() {
406        let mut config = Config::default();
407        config.node.id = Some(String::new());
408
409        let mut validator = ConfigValidator::new(config);
410        let fixes = validator.auto_fix();
411
412        assert!(!fixes.is_empty());
413        assert!(validator
414            .config()
415            .node
416            .id
417            .as_ref()
418            .map(|id| !id.is_empty())
419            .unwrap_or(false));
420    }
421
422    #[test]
423    fn test_auto_fix_invalid_timeout() {
424        let mut config = Config::default();
425        config.cli.command_timeout_secs = 0;
426
427        let mut validator = ConfigValidator::new(config);
428        let fixes = validator.auto_fix();
429
430        assert!(!fixes.is_empty());
431        assert_eq!(validator.config().cli.command_timeout_secs, 30);
432    }
433
434    #[test]
435    fn test_is_local_address() {
436        assert!(is_local_address("127.0.0.1:8080"));
437        assert!(is_local_address("localhost:8080"));
438        assert!(is_local_address("0.0.0.0:8080"));
439        assert!(!is_local_address("192.168.1.1:8080"));
440    }
441}