use super::{Severity, ValidationIssue, Validator};
use crate::core::{types::Version, Result};
use regex::Regex;
use std::collections::{HashMap, HashSet};
pub struct RezValidator {
required_fields: HashSet<String>,
recommended_fields: HashSet<String>,
deprecated_fields: HashMap<String, String>,
patterns: RezPatterns,
}
struct RezPatterns {
version_pattern: Regex,
name_pattern: Regex,
requirement_pattern: Regex,
#[allow(dead_code)]
tool_pattern: Regex,
}
impl RezValidator {
pub fn new() -> Result<Self> {
let mut required_fields = HashSet::new();
required_fields.insert("name".to_string());
required_fields.insert("version".to_string());
let mut recommended_fields = HashSet::new();
recommended_fields.insert("description".to_string());
recommended_fields.insert("authors".to_string());
recommended_fields.insert("requires".to_string());
let mut deprecated_fields = HashMap::new();
deprecated_fields.insert(
"uuid".to_string(),
"UUIDs are no longer used in Rez packages".to_string(),
);
deprecated_fields.insert(
"config".to_string(),
"Use 'private_build_requires' instead".to_string(),
);
let patterns = RezPatterns {
version_pattern: Regex::new(r"^[0-9]+(\.[0-9]+)*([a-zA-Z][a-zA-Z0-9]*)?$")?,
name_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$")?,
requirement_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*([<>=!]+[0-9]+(\.[0-9]+)*)?$")?,
tool_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$")?,
};
Ok(Self {
required_fields,
recommended_fields,
deprecated_fields,
patterns,
})
}
fn extract_fields(&self, content: &str) -> HashMap<String, (u32, String)> {
let mut fields = HashMap::new();
let assignment_regex = Regex::new(r"^(\w+)\s*=\s*(.+)$").unwrap();
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num as u32 + 1;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(captures) = assignment_regex.captures(trimmed) {
let field_name = captures.get(1).unwrap().as_str().to_string();
let field_value = captures.get(2).unwrap().as_str().to_string();
fields.insert(field_name, (line_num, field_value));
}
}
fields
}
fn check_required_fields(
&self,
fields: &HashMap<String, (u32, String)>,
) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
for required_field in &self.required_fields {
if !fields.contains_key(required_field) {
issues.push(
ValidationIssue::new(
Severity::Error,
1,
1,
1,
format!("Missing required field '{}'", required_field),
"R001",
)
.with_suggestion(format!(
"Add '{}' field to the package definition",
required_field
)),
);
}
}
issues
}
fn check_recommended_fields(
&self,
fields: &HashMap<String, (u32, String)>,
) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
for recommended_field in &self.recommended_fields {
if !fields.contains_key(recommended_field) {
issues.push(
ValidationIssue::new(
Severity::Warning,
1,
1,
1,
format!("Missing recommended field '{}'", recommended_field),
"R101",
)
.with_suggestion(format!(
"Consider adding '{}' field for better package documentation",
recommended_field
)),
);
}
}
issues
}
fn check_deprecated_fields(
&self,
fields: &HashMap<String, (u32, String)>,
) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
for (field_name, (line_num, _)) in fields {
if let Some(reason) = self.deprecated_fields.get(field_name) {
issues.push(
ValidationIssue::new(
Severity::Warning,
*line_num,
1,
field_name.len() as u32,
format!("Deprecated field '{}': {}", field_name, reason),
"R201",
)
.with_suggestion("Remove this deprecated field"),
);
}
}
issues
}
fn validate_name(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if let Some((line_num, value)) = fields.get("name") {
let clean_value = self.clean_string_value(value);
if !self.patterns.name_pattern.is_match(&clean_value) {
issues.push(ValidationIssue::new(
Severity::Error,
*line_num,
1,
value.len() as u32,
"Invalid package name format",
"R002",
).with_suggestion("Package names must start with a letter and contain only letters, numbers, and underscores"));
}
let reserved_names = ["test", "build", "install", "package"];
if reserved_names.contains(&clean_value.as_str()) {
issues.push(
ValidationIssue::new(
Severity::Warning,
*line_num,
1,
value.len() as u32,
format!("Package name '{}' is a reserved word", clean_value),
"R102",
)
.with_suggestion("Consider using a different package name"),
);
}
}
issues
}
fn validate_version(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if let Some((line_num, value)) = fields.get("version") {
let clean_value = self.clean_string_value(value);
match Version::new(&clean_value) {
version if version.tokens.is_empty() => {
issues.push(
ValidationIssue::new(
Severity::Error,
*line_num,
1,
value.len() as u32,
"Invalid version format",
"R003",
)
.with_suggestion("Use semantic versioning (e.g., '1.0.0')"),
);
}
_ => {
if !self.patterns.version_pattern.is_match(&clean_value) {
issues.push(
ValidationIssue::new(
Severity::Warning,
*line_num,
1,
value.len() as u32,
"Version format doesn't follow semantic versioning",
"R103",
)
.with_suggestion(
"Consider using semantic versioning (major.minor.patch)",
),
);
}
}
}
}
issues
}
fn validate_requires(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if let Some((line_num, value)) = fields.get("requires") {
if let Some(requirements) = self.parse_list_value(value) {
for requirement in requirements.iter() {
let clean_req = self.clean_string_value(requirement);
if !self.patterns.requirement_pattern.is_match(&clean_req) {
issues.push(
ValidationIssue::new(
Severity::Error,
*line_num,
1,
requirement.len() as u32,
format!("Invalid requirement format: '{}'", clean_req),
"R004",
)
.with_suggestion(
"Requirements should be in format 'package' or 'package>=1.0.0'",
),
);
}
let common_packages = ["python", "maya", "houdini", "nuke", "blender"];
if !common_packages
.iter()
.any(|&pkg| clean_req.starts_with(pkg))
{
if clean_req.contains('-') {
issues.push(
ValidationIssue::new(
Severity::Warning,
*line_num,
1,
requirement.len() as u32,
"Package names with hyphens may cause issues",
"R104",
)
.with_suggestion("Consider using underscores instead of hyphens"),
);
}
}
}
let mut seen = HashSet::new();
for requirement in &requirements {
let clean_req = self.clean_string_value(requirement);
let package_name = clean_req
.split(&['<', '>', '=', '!'][..])
.next()
.unwrap_or(&clean_req)
.to_string();
if !seen.insert(package_name.clone()) {
issues.push(
ValidationIssue::new(
Severity::Warning,
*line_num,
1,
value.len() as u32,
format!("Duplicate requirement: '{}'", package_name),
"R105",
)
.with_suggestion("Remove duplicate requirements"),
);
}
}
}
}
issues
}
fn validate_tools(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if let Some((line_num, value)) = fields.get("tools") {
if !value.trim().starts_with('{') || !value.trim().ends_with('}') {
issues.push(
ValidationIssue::new(
Severity::Error,
*line_num,
1,
value.len() as u32,
"Tools field must be a dictionary",
"R005",
)
.with_suggestion("Use dictionary format: tools = {'tool_name': 'tool_path'}"),
);
}
}
issues
}
fn clean_string_value(&self, value: &str) -> String {
value
.trim()
.trim_start_matches('"')
.trim_end_matches('"')
.trim_start_matches('\'')
.trim_end_matches('\'')
.to_string()
}
fn parse_list_value(&self, value: &str) -> Option<Vec<String>> {
let trimmed = value.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return None;
}
let content = &trimmed[1..trimmed.len() - 1];
let items: Vec<String> = content
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Some(items)
}
}
impl Default for RezValidator {
fn default() -> Self {
Self::new().expect("Failed to create RezValidator")
}
}
impl Validator for RezValidator {
fn validate(&self, content: &str, _file_path: &str) -> Result<Vec<ValidationIssue>> {
let mut issues = Vec::new();
let fields = self.extract_fields(content);
issues.extend(self.check_required_fields(&fields));
issues.extend(self.check_recommended_fields(&fields));
issues.extend(self.check_deprecated_fields(&fields));
issues.extend(self.validate_name(&fields));
issues.extend(self.validate_version(&fields));
issues.extend(self.validate_requires(&fields));
issues.extend(self.validate_tools(&fields));
issues.sort_by_key(|issue| issue.line);
Ok(issues)
}
fn name(&self) -> &str {
"RezValidator"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rez_validator_creation() {
let validator = RezValidator::new();
assert!(validator.is_ok());
}
#[test]
fn test_valid_rez_package() {
let validator = RezValidator::new().unwrap();
let content = r#"
name = "test_package"
version = "1.0.0"
description = "A test package"
authors = ["Test Author"]
requires = ["python>=3.7"]
"#;
let issues = validator.validate(content, "package.py").unwrap();
assert!(issues.iter().all(|i| i.severity != Severity::Error));
}
#[test]
fn test_missing_required_fields() {
let validator = RezValidator::new().unwrap();
let content = r#"
description = "A test package"
"#;
let issues = validator.validate(content, "package.py").unwrap();
assert!(issues
.iter()
.any(|i| i.code == "R001" && i.message.contains("name")));
assert!(issues
.iter()
.any(|i| i.code == "R001" && i.message.contains("version")));
}
#[test]
fn test_invalid_package_name() {
let validator = RezValidator::new().unwrap();
let content = r#"
name = "123invalid"
version = "1.0.0"
"#;
let issues = validator.validate(content, "package.py").unwrap();
assert!(issues.iter().any(|i| i.code == "R002"));
}
#[test]
fn test_deprecated_fields() {
let validator = RezValidator::new().unwrap();
let content = r#"
name = "test"
version = "1.0.0"
uuid = "some-uuid"
"#;
let issues = validator.validate(content, "package.py").unwrap();
assert!(issues.iter().any(|i| i.code == "R201"));
}
}