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