Skip to main content

mabi_cli/commands/
validate.rs

1//! Validate command implementation.
2//!
3//! Validates scenario and configuration files.
4
5use crate::context::CliContext;
6use crate::error::{CliError, CliResult};
7use crate::output::{
8    StatusType, TableBuilder, ValidationError, ValidationResult, ValidationWarning,
9};
10use crate::runner::{Command, CommandOutput};
11use async_trait::async_trait;
12use mabi_scenario::Scenario;
13use std::path::PathBuf;
14
15/// Validate command for checking configuration files.
16pub struct ValidateCommand {
17    /// Paths to validate.
18    paths: Vec<PathBuf>,
19    /// Whether to show detailed output.
20    detailed: bool,
21    /// Whether to check for warnings only (no errors = success).
22    strict: bool,
23}
24
25impl ValidateCommand {
26    /// Create a new validate command.
27    pub fn new(paths: Vec<PathBuf>) -> Self {
28        Self {
29            paths,
30            detailed: false,
31            strict: false,
32        }
33    }
34
35    /// Enable detailed output.
36    pub fn with_detailed(mut self, detailed: bool) -> Self {
37        self.detailed = detailed;
38        self
39    }
40
41    /// Enable strict mode (warnings become errors).
42    pub fn with_strict(mut self, strict: bool) -> Self {
43        self.strict = strict;
44        self
45    }
46
47    /// Validate a single file.
48    async fn validate_file(&self, ctx: &CliContext, path: &PathBuf) -> ValidationResult {
49        let resolved_path = ctx.resolve_path(path);
50        let mut errors = Vec::new();
51        let mut warnings = Vec::new();
52
53        // Check file exists
54        if !resolved_path.exists() {
55            errors.push(ValidationError {
56                path: path.display().to_string(),
57                message: "File not found".into(),
58            });
59            return ValidationResult {
60                valid: false,
61                errors,
62                warnings,
63            };
64        }
65
66        // Read file content
67        let content = match tokio::fs::read_to_string(&resolved_path).await {
68            Ok(c) => c,
69            Err(e) => {
70                errors.push(ValidationError {
71                    path: path.display().to_string(),
72                    message: format!("Failed to read file: {}", e),
73                });
74                return ValidationResult {
75                    valid: false,
76                    errors,
77                    warnings,
78                };
79            }
80        };
81
82        // Determine file type and validate
83        let extension = resolved_path
84            .extension()
85            .and_then(|e| e.to_str())
86            .unwrap_or("");
87
88        match extension {
89            "yaml" | "yml" => {
90                self.validate_yaml(&content, path, &mut errors, &mut warnings);
91            }
92            "json" => {
93                self.validate_json(&content, path, &mut errors, &mut warnings);
94            }
95            "toml" => {
96                self.validate_toml(&content, path, &mut errors, &mut warnings);
97            }
98            _ => {
99                warnings.push(ValidationWarning {
100                    path: path.display().to_string(),
101                    message: format!("Unknown file extension: {}", extension),
102                });
103            }
104        }
105
106        // Check for scenario-specific validation
107        if content.contains("devices:") || content.contains("\"devices\"") {
108            self.validate_scenario_content(&content, path, &mut errors, &mut warnings);
109        }
110
111        let valid = errors.is_empty() && (!self.strict || warnings.is_empty());
112
113        ValidationResult {
114            valid,
115            errors,
116            warnings,
117        }
118    }
119
120    /// Validate YAML content.
121    fn validate_yaml(
122        &self,
123        content: &str,
124        path: &PathBuf,
125        errors: &mut Vec<ValidationError>,
126        _warnings: &mut Vec<ValidationWarning>,
127    ) {
128        if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(content) {
129            errors.push(ValidationError {
130                path: path.display().to_string(),
131                message: format!("Invalid YAML: {}", e),
132            });
133        }
134    }
135
136    /// Validate JSON content.
137    fn validate_json(
138        &self,
139        content: &str,
140        path: &PathBuf,
141        errors: &mut Vec<ValidationError>,
142        _warnings: &mut Vec<ValidationWarning>,
143    ) {
144        if let Err(e) = serde_json::from_str::<serde_json::Value>(content) {
145            errors.push(ValidationError {
146                path: path.display().to_string(),
147                message: format!("Invalid JSON: {}", e),
148            });
149        }
150    }
151
152    /// Validate TOML content.
153    fn validate_toml(
154        &self,
155        content: &str,
156        path: &PathBuf,
157        errors: &mut Vec<ValidationError>,
158        _warnings: &mut Vec<ValidationWarning>,
159    ) {
160        if let Err(e) = toml::from_str::<toml::Value>(content) {
161            errors.push(ValidationError {
162                path: path.display().to_string(),
163                message: format!("Invalid TOML: {}", e),
164            });
165        }
166    }
167
168    /// Validate scenario-specific content.
169    fn validate_scenario_content(
170        &self,
171        content: &str,
172        path: &PathBuf,
173        errors: &mut Vec<ValidationError>,
174        warnings: &mut Vec<ValidationWarning>,
175    ) {
176        // Try to parse as scenario
177        if let Ok(scenario) = serde_yaml::from_str::<Scenario>(content) {
178            // Validate scenario fields
179            if scenario.name.is_empty() {
180                errors.push(ValidationError {
181                    path: path.display().to_string(),
182                    message: "Scenario name is required".into(),
183                });
184            }
185
186            if scenario.devices.is_empty() {
187                warnings.push(ValidationWarning {
188                    path: path.display().to_string(),
189                    message: "Scenario has no devices".into(),
190                });
191            }
192
193            // Validate devices
194            for (idx, device) in scenario.devices.iter().enumerate() {
195                if device.id.is_empty() {
196                    errors.push(ValidationError {
197                        path: format!("{}:devices[{}]", path.display(), idx),
198                        message: "Device ID is required".into(),
199                    });
200                }
201
202                if device.protocol.is_empty() {
203                    errors.push(ValidationError {
204                        path: format!("{}:devices[{}]", path.display(), idx),
205                        message: "Device protocol is required".into(),
206                    });
207                }
208
209                // Validate protocol
210                let valid_protocols = ["modbus_tcp", "modbus_rtu", "opcua", "bacnet", "knx"];
211                if !valid_protocols.contains(&device.protocol.to_lowercase().as_str()) {
212                    warnings.push(ValidationWarning {
213                        path: format!("{}:devices[{}]", path.display(), idx),
214                        message: format!("Unknown protocol: {}", device.protocol),
215                    });
216                }
217            }
218
219            // Validate scenario points
220            for (idx, point) in scenario.points.iter().enumerate() {
221                if point.id.is_empty() {
222                    errors.push(ValidationError {
223                        path: format!("{}:points[{}]", path.display(), idx),
224                        message: "Point ID is required".into(),
225                    });
226                }
227
228                if point.point_id.is_empty() {
229                    errors.push(ValidationError {
230                        path: format!("{}:points[{}]", path.display(), idx),
231                        message: "Target point_id is required".into(),
232                    });
233                }
234
235                if point.device_id.is_empty() && point.device_tags.is_empty() {
236                    warnings.push(ValidationWarning {
237                        path: format!("{}:points[{}]", path.display(), idx),
238                        message: "Point has neither device_id nor device_tags".into(),
239                    });
240                }
241            }
242        }
243    }
244}
245
246#[async_trait]
247impl Command for ValidateCommand {
248    fn name(&self) -> &str {
249        "validate"
250    }
251
252    fn description(&self) -> &str {
253        "Validate scenario and configuration files"
254    }
255
256    fn validate(&self) -> CliResult<()> {
257        if self.paths.is_empty() {
258            return Err(CliError::InvalidConfig {
259                message: "At least one file path is required".into(),
260            });
261        }
262        Ok(())
263    }
264
265    async fn execute(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
266        let output = ctx.output();
267        let mut all_valid = true;
268        let mut results = Vec::new();
269
270        output.header("Validating Files");
271
272        for path in &self.paths {
273            let result = self.validate_file(ctx, path).await;
274            if !result.valid {
275                all_valid = false;
276            }
277            results.push((path.clone(), result));
278        }
279
280        // Display results
281        if self.detailed {
282            for (path, result) in &results {
283                output.kv("File", path.display());
284
285                if result.errors.is_empty() && result.warnings.is_empty() {
286                    output.success("  Valid");
287                } else {
288                    for error in &result.errors {
289                        output.error(format!("  {}: {}", error.path, error.message));
290                    }
291                    for warning in &result.warnings {
292                        output.warning(format!("  {}: {}", warning.path, warning.message));
293                    }
294                }
295                println!();
296            }
297        } else {
298            // Summary table
299            let mut table = TableBuilder::new(output.colors_enabled())
300                .header(["File", "Errors", "Warnings", "Status"]);
301
302            for (path, result) in &results {
303                let status = if result.valid { "Valid" } else { "Invalid" };
304                let status_type = if result.valid {
305                    StatusType::Success
306                } else {
307                    StatusType::Error
308                };
309
310                table = table.status_row(
311                    [
312                        path.display().to_string(),
313                        result.errors.len().to_string(),
314                        result.warnings.len().to_string(),
315                        status.to_string(),
316                    ],
317                    status_type,
318                );
319            }
320            table.print();
321        }
322
323        // Summary
324        println!();
325        let total = results.len();
326        let valid_count = results.iter().filter(|(_, r)| r.valid).count();
327        let error_count: usize = results.iter().map(|(_, r)| r.errors.len()).sum();
328        let warning_count: usize = results.iter().map(|(_, r)| r.warnings.len()).sum();
329
330        output.kv("Total files", total);
331        output.kv("Valid", valid_count);
332        output.kv("Errors", error_count);
333        output.kv("Warnings", warning_count);
334
335        if all_valid {
336            output.success("All files are valid");
337            Ok(CommandOutput::quiet_success())
338        } else {
339            Err(CliError::ValidationFailed {
340                errors: format!("{} file(s) failed validation", total - valid_count),
341            })
342        }
343    }
344}