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 async_trait::async_trait;
12use mabi_scenario::Scenario;
13use std::path::PathBuf;
14
15pub struct ValidateCommand {
17 paths: Vec<PathBuf>,
19 detailed: bool,
21 strict: bool,
23}
24
25impl ValidateCommand {
26 pub fn new(paths: Vec<PathBuf>) -> Self {
28 Self {
29 paths,
30 detailed: false,
31 strict: false,
32 }
33 }
34
35 pub fn with_detailed(mut self, detailed: bool) -> Self {
37 self.detailed = detailed;
38 self
39 }
40
41 pub fn with_strict(mut self, strict: bool) -> Self {
43 self.strict = strict;
44 self
45 }
46
47 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 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 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 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 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 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 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 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 fn validate_scenario_content(
170 &self,
171 content: &str,
172 path: &PathBuf,
173 errors: &mut Vec<ValidationError>,
174 warnings: &mut Vec<ValidationWarning>,
175 ) {
176 if let Ok(scenario) = serde_yaml::from_str::<Scenario>(content) {
178 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 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 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 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 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 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 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}