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