audb 0.1.11

AuDB - Compile-time database application framework with gold files
Documentation
//! Validator implementation for AuDB projects

use crate::error::Result;
use crate::model::Project;
use std::collections::HashSet;

/// Validator for AuDB projects
///
/// Performs compile-time validation of gold files and project models,
/// catching errors before code generation.
pub struct Validator {
    /// Whether to treat warnings as errors
    strict: bool,
}

impl Validator {
    /// Create a new validator
    pub fn new() -> Self {
        Self { strict: false }
    }

    /// Create a strict validator (warnings are errors)
    pub fn strict() -> Self {
        Self { strict: true }
    }

    /// Validate a complete project
    pub fn validate_project(&self, project: &Project) -> Result<ValidationReport> {
        let mut report = ValidationReport::new();

        // Validate project metadata
        self.validate_metadata(project, &mut report);

        // Validate schemas
        self.validate_schemas(project, &mut report);

        // Validate queries
        self.validate_queries(project, &mut report);

        // Validate endpoints
        self.validate_endpoints(project, &mut report);

        // Validate tests
        self.validate_tests(project, &mut report);

        // Check for duplicate names
        self.check_duplicates(project, &mut report);

        // Check references
        self.check_references(project, &mut report);

        if self.strict && !report.warnings.is_empty() {
            for warning in &report.warnings {
                report.errors.push(warning.clone());
            }
            report.warnings.clear();
        }

        Ok(report)
    }

    /// Validate project metadata
    fn validate_metadata(&self, project: &Project, report: &mut ValidationReport) {
        if project.metadata.name.is_empty() {
            report.add_error("Project name cannot be empty");
        }

        if project.metadata.version.is_empty() {
            report.add_warning("Project version is empty");
        }
    }

    /// Validate all schemas
    fn validate_schemas(&self, project: &Project, report: &mut ValidationReport) {
        for (name, schema) in &project.schemas {
            if schema.fields.is_empty() {
                report.add_warning(&format!("Schema '{}' has no fields", name));
            }

            // Validate each field using the field's own validation
            for field in &schema.fields {
                if let Err(e) = field.validate() {
                    report.add_error(&format!(
                        "Field '{}' in schema '{}': {}",
                        field.name, name, e
                    ));
                }
            }
        }
    }

    /// Validate all queries
    fn validate_queries(&self, project: &Project, report: &mut ValidationReport) {
        for (name, query) in &project.queries {
            if query.source.is_empty() {
                report.add_error(&format!("Query '{}' has empty source", name));
            }

            // Type enum is validated separately, can't be empty

            // Validate parameter names are unique
            let mut param_names = HashSet::new();
            for param in &query.params {
                if !param_names.insert(&param.name) {
                    report.add_error(&format!(
                        "Duplicate parameter '{}' in query '{}'",
                        param.name, name
                    ));
                }
            }
        }
    }

    /// Validate all endpoints
    fn validate_endpoints(&self, project: &Project, report: &mut ValidationReport) {
        for endpoint in &project.endpoints {
            if endpoint.path.is_empty() {
                report.add_error("Endpoint has empty path");
            }

            if endpoint.method.is_empty() {
                report.add_error(&format!("Endpoint '{}' has empty method", endpoint.path));
            }

            // Validate HTTP method
            let valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
            if !valid_methods.contains(&endpoint.method.as_str()) {
                report.add_warning(&format!(
                    "Endpoint '{}' has unusual method '{}'",
                    endpoint.path, endpoint.method
                ));
            }
        }
    }

    /// Validate all tests
    fn validate_tests(&self, project: &Project, report: &mut ValidationReport) {
        for test in &project.tests {
            if test.steps.is_empty() {
                report.add_warning(&format!("Test '{}' has no steps", test.name));
            }
        }
    }

    /// Check for duplicate names
    fn check_duplicates(&self, project: &Project, report: &mut ValidationReport) {
        // Check schema names
        let schema_count = project.schemas.len();
        let unique_schemas: HashSet<_> = project.schemas.keys().collect();
        if schema_count != unique_schemas.len() {
            report.add_error("Duplicate schema names detected");
        }

        // Check query names
        let query_count = project.queries.len();
        let unique_queries: HashSet<_> = project.queries.keys().collect();
        if query_count != unique_queries.len() {
            report.add_error("Duplicate query names detected");
        }

        // Check endpoint paths
        let mut endpoint_paths = HashSet::new();
        for endpoint in &project.endpoints {
            let key = format!("{} {}", endpoint.method, endpoint.path);
            if !endpoint_paths.insert(key.clone()) {
                report.add_error(&format!("Duplicate endpoint: {}", key));
            }
        }
    }

    /// Check references between entities
    fn check_references(&self, project: &Project, report: &mut ValidationReport) {
        // Check query return types reference existing schemas
        for (name, query) in &project.queries {
            // Extract schema names from return type
            let schema_names = self.extract_schema_names(&query.return_type);
            for schema_name in schema_names {
                if !is_primitive_type(&schema_name) && !project.schemas.contains_key(&schema_name) {
                    report.add_error(&format!(
                        "Query '{}' return type references undefined schema '{}'",
                        name, schema_name
                    ));
                }
            }
        }

        // Check endpoints reference existing queries
        for endpoint in &project.endpoints {
            if let Some(query_name) = &endpoint.query {
                if !project.queries.contains_key(query_name) {
                    report.add_error(&format!(
                        "Endpoint '{}' references undefined query '{}'",
                        endpoint.path, query_name
                    ));
                }
            }
        }
    }

    /// Extract schema names from a Type
    fn extract_schema_names(&self, typ: &crate::schema::Type) -> Vec<String> {
        use crate::schema::Type;
        match typ {
            Type::Named(name) | Type::Custom(name) => vec![name.clone()],
            Type::Option(inner) | Type::Vec(inner) => self.extract_schema_names(inner),
            _ => vec![],
        }
    }
}

impl Default for Validator {
    fn default() -> Self {
        Self::new()
    }
}

/// Validation report containing errors and warnings
#[derive(Debug, Clone)]
pub struct ValidationReport {
    /// Validation errors (must fix)
    pub errors: Vec<String>,
    /// Validation warnings (should fix)
    pub warnings: Vec<String>,
}

impl ValidationReport {
    /// Create a new empty report
    pub fn new() -> Self {
        Self {
            errors: Vec::new(),
            warnings: Vec::new(),
        }
    }

    /// Check if the report is valid (no errors)
    pub fn is_valid(&self) -> bool {
        self.errors.is_empty()
    }

    /// Add an error to the report
    pub fn add_error(&mut self, message: &str) {
        self.errors.push(message.to_string());
    }

    /// Add a warning to the report
    pub fn add_warning(&mut self, message: &str) {
        self.warnings.push(message.to_string());
    }

    /// Get total number of issues
    pub fn total_issues(&self) -> usize {
        self.errors.len() + self.warnings.len()
    }
}

impl Default for ValidationReport {
    fn default() -> Self {
        Self::new()
    }
}

/// Check if a type name is a primitive type
fn is_primitive_type(type_name: &str) -> bool {
    matches!(
        type_name,
        "String"
            | "i8"
            | "i16"
            | "i32"
            | "i64"
            | "i128"
            | "u8"
            | "u16"
            | "u32"
            | "u64"
            | "u128"
            | "f32"
            | "f64"
            | "bool"
            | "char"
            | "EntityId"
            | "Timestamp"
            | "Integer"
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{DatabaseConfig, ProjectMetadata};

    #[test]
    fn test_validator_creation() {
        let validator = Validator::new();
        assert!(!validator.strict);

        let strict_validator = Validator::strict();
        assert!(strict_validator.strict);
    }

    #[test]
    fn test_validation_report() {
        let mut report = ValidationReport::new();
        assert!(report.is_valid());
        assert_eq!(report.total_issues(), 0);

        report.add_error("test error");
        assert!(!report.is_valid());
        assert_eq!(report.errors.len(), 1);

        report.add_warning("test warning");
        assert_eq!(report.warnings.len(), 1);
        assert_eq!(report.total_issues(), 2);
    }

    #[test]
    fn test_empty_project_name() {
        let validator = Validator::new();
        let project = Project {
            metadata: ProjectMetadata {
                name: String::new(),
                version: "0.1.0".to_string(),
                authors: Vec::new(),
                description: None,
            },
            database: DatabaseConfig::default(),
            server: None,
            scaffold: None,
            schemas: Default::default(),
            queries: Default::default(),
            endpoints: Vec::new(),
            tests: Vec::new(),
            docs: None,
            deployment: None,
            dependencies: crate::model::dependency::DependencyManager::new(),
        };

        let report = validator.validate_project(&project).unwrap();
        assert!(!report.is_valid());
        assert!(
            report
                .errors
                .iter()
                .any(|e| e.contains("name cannot be empty"))
        );
    }

    #[test]
    fn test_is_primitive_type() {
        assert!(is_primitive_type("String"));
        assert!(is_primitive_type("i64"));
        assert!(is_primitive_type("bool"));
        assert!(is_primitive_type("EntityId"));
        assert!(!is_primitive_type("User"));
        assert!(!is_primitive_type("CustomType"));
    }
}