1use 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
17pub struct ValidateCommand {
19 paths: Vec<PathBuf>,
21 detailed: bool,
23 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 pub fn new(paths: Vec<PathBuf>) -> Self {
48 Self {
49 paths,
50 detailed: false,
51 strict: false,
52 }
53 }
54
55 pub fn with_detailed(mut self, detailed: bool) -> Self {
57 self.detailed = detailed;
58 self
59 }
60
61 pub fn with_strict(mut self, strict: bool) -> Self {
63 self.strict = strict;
64 self
65 }
66
67 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 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 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 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 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 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 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 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 fn validate_scenario_content(
190 &self,
191 content: &str,
192 path: &PathBuf,
193 errors: &mut Vec<ValidationError>,
194 warnings: &mut Vec<ValidationWarning>,
195 ) {
196 if let Ok(scenario) = serde_yaml::from_str::<Scenario>(content) {
198 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 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 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 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 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 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 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}