1use crate::config::Config;
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ValidationResult {
11 pub is_valid: bool,
13 pub errors: Vec<ValidationError>,
15 pub warnings: Vec<ValidationWarning>,
17 pub suggestions: Vec<ValidationSuggestion>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ValidationError {
24 pub field: String,
26 pub message: String,
28 pub severity: ErrorSeverity,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ErrorSeverity {
36 Critical,
38 Error,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ValidationWarning {
45 pub field: String,
47 pub message: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ValidationSuggestion {
54 pub field: String,
56 pub suggested_value: String,
58 pub reason: String,
60}
61
62pub struct ConfigValidator {
64 config: Config,
65}
66
67impl ConfigValidator {
68 pub fn new(config: Config) -> Self {
70 Self { config }
71 }
72
73 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 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 self.validate_node(&mut errors, &mut warnings, &mut suggestions);
87
88 self.validate_daemon(&mut errors, &mut warnings, &mut suggestions);
90
91 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 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 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 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 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 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 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 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 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 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 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 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 pub fn auto_fix(&mut self) -> Vec<String> {
271 let mut fixes = Vec::new();
272
273 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 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 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 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 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 pub fn config(&self) -> &Config {
318 &self.config
319 }
320
321 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
323 self.config.save_to_path(path.as_ref())
324 }
325}
326
327fn is_local_address(addr: &str) -> bool {
329 addr.starts_with("127.") || addr.starts_with("localhost") || addr.starts_with("0.0.0.0")
330}
331
332pub struct ConfigMigrator;
334
335impl ConfigMigrator {
336 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 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 pub fn detect_version(_config: &Config) -> String {
355 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}