bwq 0.3.1

A linter and parser for Brandwatch query files (.bwq)
Documentation
use crate::ast::*;
use crate::error::{LintError, LintReport};
use crate::validation::{rules::PureNegativeRule, ValidationEngine};

/// plugin-based query-level validator
pub struct Validator {
    engine: ValidationEngine,
    pure_negative_rule: PureNegativeRule,
}

impl Validator {
    pub fn new() -> Self {
        Self {
            engine: ValidationEngine::new(),
            pure_negative_rule: PureNegativeRule,
        }
    }

    pub fn validate(&mut self, query: &Query) -> LintReport {
        let mut report = self.engine.validate(query);

        if self
            .pure_negative_rule
            .is_pure_negative_query(&query.expression)
        {
            report.errors.push(LintError::ValidationError {
                span: query.span.clone(),
                message: "Queries must contain at least one non-excluded term".to_string(),
            });
        }

        report
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lexer::Lexer;
    use crate::parser::Parser;

    #[test]
    fn test_wildcard_validation() {
        let mut lexer = Lexer::new("valid*");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(!report.has_errors());
    }

    #[test]
    fn test_rating_validation() {
        let mut lexer = Lexer::new("rating:6");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.has_errors());
    }

    #[test]
    fn test_valid_query() {
        let mut lexer = Lexer::new("apple AND juice");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.is_clean());
    }

    #[test]
    fn test_mixed_and_or_validation() {
        let mut lexer = Lexer::new("apple AND banana OR juice");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.has_errors());
        assert!(report.errors.iter().any(|e| e
            .to_string()
            .contains("AND and OR operators cannot be mixed")));
    }

    #[test]
    fn test_pure_negative_query() {
        let mut lexer = Lexer::new("NOT bitter");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.has_errors());
        assert!(report.errors.iter().any(|e| e
            .to_string()
            .contains("must contain at least one non-excluded term")));
    }

    #[test]
    fn test_coordinate_validation() {
        let mut lexer = Lexer::new("latitude:100");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.has_errors());
        assert!(report.errors.iter().any(|e| e
            .to_string()
            .contains("Latitude must be between -90 and 90")));
    }

    #[test]
    fn test_boolean_field_validation() {
        let mut lexer = Lexer::new("authorVerified:yes");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.has_errors());
        assert!(report
            .errors
            .iter()
            .any(|e| e.to_string().contains("must be 'true' or 'false'")));
    }

    #[test]
    fn test_wildcard_placement_validation() {
        let mut lexer = Lexer::new("*invalid");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(report.has_errors());
        assert!(report.errors.iter().any(|e| e
            .to_string()
            .contains("wildcards cannot be at the beginning")));
    }

    #[test]
    fn test_performance_warnings() {
        let mut lexer = Lexer::new("ab*");
        let tokens = lexer.tokenize().unwrap();
        let mut parser = Parser::new(tokens).unwrap();
        let result = parser.parse().unwrap();

        let mut validator = Validator::new();
        let report = validator.validate(&result.query);

        assert!(!report.warnings.is_empty());
        assert!(report
            .warnings
            .iter()
            .any(|w| format!("{w:?}").contains("performance")));
    }
}