1use super::{Severity, ValidationIssue, Validator};
4use crate::core::{types::Version, Result};
5use regex::Regex;
6use std::collections::{HashMap, HashSet};
7
8pub struct RezValidator {
10 required_fields: HashSet<String>,
12 recommended_fields: HashSet<String>,
14 deprecated_fields: HashMap<String, String>,
16 patterns: RezPatterns,
18}
19
20struct RezPatterns {
21 version_pattern: Regex,
23 name_pattern: Regex,
25 requirement_pattern: Regex,
27 #[allow(dead_code)]
29 tool_pattern: Regex,
30}
31
32impl RezValidator {
33 pub fn new() -> Result<Self> {
35 let mut required_fields = HashSet::new();
36 required_fields.insert("name".to_string());
37 required_fields.insert("version".to_string());
38
39 let mut recommended_fields = HashSet::new();
40 recommended_fields.insert("description".to_string());
41 recommended_fields.insert("authors".to_string());
42 recommended_fields.insert("requires".to_string());
43
44 let mut deprecated_fields = HashMap::new();
45 deprecated_fields.insert(
46 "uuid".to_string(),
47 "UUIDs are no longer used in Rez packages".to_string(),
48 );
49 deprecated_fields.insert(
50 "config".to_string(),
51 "Use 'private_build_requires' instead".to_string(),
52 );
53
54 let patterns = RezPatterns {
55 version_pattern: Regex::new(r"^[0-9]+(\.[0-9]+)*([a-zA-Z][a-zA-Z0-9]*)?$")?,
56 name_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$")?,
57 requirement_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*([<>=!]+[0-9]+(\.[0-9]+)*)?$")?,
58 tool_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$")?,
59 };
60
61 Ok(Self {
62 required_fields,
63 recommended_fields,
64 deprecated_fields,
65 patterns,
66 })
67 }
68
69 fn extract_fields(&self, content: &str) -> HashMap<String, (u32, String)> {
71 let mut fields = HashMap::new();
72 let assignment_regex = Regex::new(r"^(\w+)\s*=\s*(.+)$").unwrap();
73
74 for (line_num, line) in content.lines().enumerate() {
75 let line_num = line_num as u32 + 1;
76 let trimmed = line.trim();
77
78 if trimmed.is_empty() || trimmed.starts_with('#') {
80 continue;
81 }
82
83 if let Some(captures) = assignment_regex.captures(trimmed) {
84 let field_name = captures.get(1).unwrap().as_str().to_string();
85 let field_value = captures.get(2).unwrap().as_str().to_string();
86 fields.insert(field_name, (line_num, field_value));
87 }
88 }
89
90 fields
91 }
92
93 fn check_required_fields(
95 &self,
96 fields: &HashMap<String, (u32, String)>,
97 ) -> Vec<ValidationIssue> {
98 let mut issues = Vec::new();
99
100 for required_field in &self.required_fields {
101 if !fields.contains_key(required_field) {
102 issues.push(
103 ValidationIssue::new(
104 Severity::Error,
105 1,
106 1,
107 1,
108 format!("Missing required field '{}'", required_field),
109 "R001",
110 )
111 .with_suggestion(format!(
112 "Add '{}' field to the package definition",
113 required_field
114 )),
115 );
116 }
117 }
118
119 issues
120 }
121
122 fn check_recommended_fields(
124 &self,
125 fields: &HashMap<String, (u32, String)>,
126 ) -> Vec<ValidationIssue> {
127 let mut issues = Vec::new();
128
129 for recommended_field in &self.recommended_fields {
130 if !fields.contains_key(recommended_field) {
131 issues.push(
132 ValidationIssue::new(
133 Severity::Warning,
134 1,
135 1,
136 1,
137 format!("Missing recommended field '{}'", recommended_field),
138 "R101",
139 )
140 .with_suggestion(format!(
141 "Consider adding '{}' field for better package documentation",
142 recommended_field
143 )),
144 );
145 }
146 }
147
148 issues
149 }
150
151 fn check_deprecated_fields(
153 &self,
154 fields: &HashMap<String, (u32, String)>,
155 ) -> Vec<ValidationIssue> {
156 let mut issues = Vec::new();
157
158 for (field_name, (line_num, _)) in fields {
159 if let Some(reason) = self.deprecated_fields.get(field_name) {
160 issues.push(
161 ValidationIssue::new(
162 Severity::Warning,
163 *line_num,
164 1,
165 field_name.len() as u32,
166 format!("Deprecated field '{}': {}", field_name, reason),
167 "R201",
168 )
169 .with_suggestion("Remove this deprecated field"),
170 );
171 }
172 }
173
174 issues
175 }
176
177 fn validate_name(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
179 let mut issues = Vec::new();
180
181 if let Some((line_num, value)) = fields.get("name") {
182 let clean_value = self.clean_string_value(value);
183
184 if !self.patterns.name_pattern.is_match(&clean_value) {
185 issues.push(ValidationIssue::new(
186 Severity::Error,
187 *line_num,
188 1,
189 value.len() as u32,
190 "Invalid package name format",
191 "R002",
192 ).with_suggestion("Package names must start with a letter and contain only letters, numbers, and underscores"));
193 }
194
195 let reserved_names = ["test", "build", "install", "package"];
197 if reserved_names.contains(&clean_value.as_str()) {
198 issues.push(
199 ValidationIssue::new(
200 Severity::Warning,
201 *line_num,
202 1,
203 value.len() as u32,
204 format!("Package name '{}' is a reserved word", clean_value),
205 "R102",
206 )
207 .with_suggestion("Consider using a different package name"),
208 );
209 }
210 }
211
212 issues
213 }
214
215 fn validate_version(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
217 let mut issues = Vec::new();
218
219 if let Some((line_num, value)) = fields.get("version") {
220 let clean_value = self.clean_string_value(value);
221
222 match Version::new(&clean_value) {
224 version if version.tokens.is_empty() => {
225 issues.push(
226 ValidationIssue::new(
227 Severity::Error,
228 *line_num,
229 1,
230 value.len() as u32,
231 "Invalid version format",
232 "R003",
233 )
234 .with_suggestion("Use semantic versioning (e.g., '1.0.0')"),
235 );
236 }
237 _ => {
238 if !self.patterns.version_pattern.is_match(&clean_value) {
240 issues.push(
241 ValidationIssue::new(
242 Severity::Warning,
243 *line_num,
244 1,
245 value.len() as u32,
246 "Version format doesn't follow semantic versioning",
247 "R103",
248 )
249 .with_suggestion(
250 "Consider using semantic versioning (major.minor.patch)",
251 ),
252 );
253 }
254 }
255 }
256 }
257
258 issues
259 }
260
261 fn validate_requires(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
263 let mut issues = Vec::new();
264
265 if let Some((line_num, value)) = fields.get("requires") {
266 if let Some(requirements) = self.parse_list_value(value) {
268 for requirement in requirements.iter() {
269 let clean_req = self.clean_string_value(requirement);
270
271 if !self.patterns.requirement_pattern.is_match(&clean_req) {
273 issues.push(
274 ValidationIssue::new(
275 Severity::Error,
276 *line_num,
277 1,
278 requirement.len() as u32,
279 format!("Invalid requirement format: '{}'", clean_req),
280 "R004",
281 )
282 .with_suggestion(
283 "Requirements should be in format 'package' or 'package>=1.0.0'",
284 ),
285 );
286 }
287
288 let common_packages = ["python", "maya", "houdini", "nuke", "blender"];
290 if !common_packages
291 .iter()
292 .any(|&pkg| clean_req.starts_with(pkg))
293 {
294 if clean_req.contains('-') {
296 issues.push(
297 ValidationIssue::new(
298 Severity::Warning,
299 *line_num,
300 1,
301 requirement.len() as u32,
302 "Package names with hyphens may cause issues",
303 "R104",
304 )
305 .with_suggestion("Consider using underscores instead of hyphens"),
306 );
307 }
308 }
309 }
310
311 let mut seen = HashSet::new();
313 for requirement in &requirements {
314 let clean_req = self.clean_string_value(requirement);
315 let package_name = clean_req
316 .split(&['<', '>', '=', '!'][..])
317 .next()
318 .unwrap_or(&clean_req)
319 .to_string();
320
321 if !seen.insert(package_name.clone()) {
322 issues.push(
323 ValidationIssue::new(
324 Severity::Warning,
325 *line_num,
326 1,
327 value.len() as u32,
328 format!("Duplicate requirement: '{}'", package_name),
329 "R105",
330 )
331 .with_suggestion("Remove duplicate requirements"),
332 );
333 }
334 }
335 }
336 }
337
338 issues
339 }
340
341 fn validate_tools(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
343 let mut issues = Vec::new();
344
345 if let Some((line_num, value)) = fields.get("tools") {
346 if !value.trim().starts_with('{') || !value.trim().ends_with('}') {
348 issues.push(
349 ValidationIssue::new(
350 Severity::Error,
351 *line_num,
352 1,
353 value.len() as u32,
354 "Tools field must be a dictionary",
355 "R005",
356 )
357 .with_suggestion("Use dictionary format: tools = {'tool_name': 'tool_path'}"),
358 );
359 }
360 }
361
362 issues
363 }
364
365 fn clean_string_value(&self, value: &str) -> String {
367 value
368 .trim()
369 .trim_start_matches('"')
370 .trim_end_matches('"')
371 .trim_start_matches('\'')
372 .trim_end_matches('\'')
373 .to_string()
374 }
375
376 fn parse_list_value(&self, value: &str) -> Option<Vec<String>> {
378 let trimmed = value.trim();
379 if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
380 return None;
381 }
382
383 let content = &trimmed[1..trimmed.len() - 1];
384 let items: Vec<String> = content
385 .split(',')
386 .map(|s| s.trim().to_string())
387 .filter(|s| !s.is_empty())
388 .collect();
389
390 Some(items)
391 }
392}
393
394impl Default for RezValidator {
395 fn default() -> Self {
396 Self::new().expect("Failed to create RezValidator")
397 }
398}
399
400impl Validator for RezValidator {
401 fn validate(&self, content: &str, _file_path: &str) -> Result<Vec<ValidationIssue>> {
402 let mut issues = Vec::new();
403
404 let fields = self.extract_fields(content);
406
407 issues.extend(self.check_required_fields(&fields));
409 issues.extend(self.check_recommended_fields(&fields));
410 issues.extend(self.check_deprecated_fields(&fields));
411 issues.extend(self.validate_name(&fields));
412 issues.extend(self.validate_version(&fields));
413 issues.extend(self.validate_requires(&fields));
414 issues.extend(self.validate_tools(&fields));
415
416 issues.sort_by_key(|issue| issue.line);
418
419 Ok(issues)
420 }
421
422 fn name(&self) -> &str {
423 "RezValidator"
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_rez_validator_creation() {
433 let validator = RezValidator::new();
434 assert!(validator.is_ok());
435 }
436
437 #[test]
438 fn test_valid_rez_package() {
439 let validator = RezValidator::new().unwrap();
440 let content = r#"
441name = "test_package"
442version = "1.0.0"
443description = "A test package"
444authors = ["Test Author"]
445requires = ["python>=3.7"]
446"#;
447
448 let issues = validator.validate(content, "package.py").unwrap();
449 assert!(issues.iter().all(|i| i.severity != Severity::Error));
451 }
452
453 #[test]
454 fn test_missing_required_fields() {
455 let validator = RezValidator::new().unwrap();
456 let content = r#"
457description = "A test package"
458"#;
459
460 let issues = validator.validate(content, "package.py").unwrap();
461 assert!(issues
462 .iter()
463 .any(|i| i.code == "R001" && i.message.contains("name")));
464 assert!(issues
465 .iter()
466 .any(|i| i.code == "R001" && i.message.contains("version")));
467 }
468
469 #[test]
470 fn test_invalid_package_name() {
471 let validator = RezValidator::new().unwrap();
472 let content = r#"
473name = "123invalid"
474version = "1.0.0"
475"#;
476
477 let issues = validator.validate(content, "package.py").unwrap();
478 assert!(issues.iter().any(|i| i.code == "R002"));
479 }
480
481 #[test]
482 fn test_deprecated_fields() {
483 let validator = RezValidator::new().unwrap();
484 let content = r#"
485name = "test"
486version = "1.0.0"
487uuid = "some-uuid"
488"#;
489
490 let issues = validator.validate(content, "package.py").unwrap();
491 assert!(issues.iter().any(|i| i.code == "R201"));
492 }
493}