1use 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
12pub struct ValidateCommand {
14 paths: Vec<PathBuf>,
16 detailed: bool,
18 strict: bool,
20}
21
22impl ValidateCommand {
23 pub fn new(paths: Vec<PathBuf>) -> Self {
25 Self {
26 paths,
27 detailed: false,
28 strict: false,
29 }
30 }
31
32 pub fn with_detailed(mut self, detailed: bool) -> Self {
34 self.detailed = detailed;
35 self
36 }
37
38 pub fn with_strict(mut self, strict: bool) -> Self {
40 self.strict = strict;
41 self
42 }
43
44 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 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 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 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 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 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 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 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 fn validate_scenario_content(
164 &self,
165 content: &str,
166 path: &PathBuf,
167 errors: &mut Vec<ValidationError>,
168 warnings: &mut Vec<ValidationWarning>,
169 ) {
170 if let Ok(scenario) = serde_yaml::from_str::<super::run::ScenarioConfig>(content) {
172 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 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 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 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 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 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 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}