cargo-aprz-lib 0.14.0

Internal library for cargo-aprz
Documentation
//! Boolean expression evaluation for filtering crates

use cel_interpreter::Program;
use serde::de::Error as DeError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::sync::Arc;

/// A boolean expression that can be evaluated against crate metrics
#[derive(Debug, Clone)]
pub struct Expression {
    name: Arc<str>,
    description: Option<Arc<str>>,
    points: Option<u32>,
    program: Arc<Program>,

    #[expect(clippy::struct_field_names, reason = "Field name matches struct name intentionally for clarity")]
    expression_string: Arc<str>,
}

impl Expression {
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns a cheap clone of the name as an `Arc<str>`.
    #[must_use]
    pub fn name_arc(&self) -> Arc<str> {
        Arc::clone(&self.name)
    }

    #[cfg(any(test, debug_assertions))]
    #[must_use]
    pub fn description(&self) -> Option<&str> {
        self.description.as_deref()
    }

    /// Returns the description (or expression string as fallback) as an `Arc<str>`.
    #[must_use]
    pub fn description_or_expression_arc(&self) -> Arc<str> {
        self.description.as_ref().map_or_else(|| Arc::clone(&self.expression_string), Arc::clone)
    }

    #[must_use]
    pub const fn points(&self) -> Option<u32> {
        self.points
    }

    #[cfg(any(test, debug_assertions))]
    #[must_use]
    pub fn expression(&self) -> &str {
        &self.expression_string
    }

    #[must_use]
    pub fn program(&self) -> &Program {
        &self.program
    }
}

impl Serialize for Expression {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        use serde::ser::SerializeStruct;

        let mut state = serializer.serialize_struct("Expression", 3)?;
        state.serialize_field("name", &*self.name)?;
        if let Some(ref desc) = self.description {
            state.serialize_field("description", &**desc)?;
        }
        state.serialize_field("expression", &*self.expression_string)?;
        if let Some(points) = self.points {
            state.serialize_field("points", &points)?;
        }
        state.end()
    }
}

impl<'de> Deserialize<'de> for Expression {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(deny_unknown_fields)]
        struct ExpressionData {
            name: Arc<str>,
            description: Option<Arc<str>>,
            expression: Arc<str>,
            points: Option<u32>,
        }

        let data = ExpressionData::deserialize(deserializer)?;
        let program = Program::compile(&data.expression)
            .map_err(|e| D::Error::custom(format!("could not parse expression '{}': {e}", data.name)))?;

        Ok(Self {
            name: data.name,
            description: data.description,
            points: data.points,
            program: Arc::new(program),
            expression_string: data.expression,
        })
    }
}

#[cfg(test)]
impl Expression {
    /// Create a new expression by parsing an expression string (test helper).
    pub(crate) fn new(name: &str, description: Option<&str>, expression: &str, points: Option<u32>) -> crate::Result<Self> {
        use ohno::app_err;

        let program = Program::compile(expression).map_err(|e| app_err!("could not parse expression '{name}': {e}"))?;

        Ok(Self {
            name: Arc::from(name),
            description: description.map(Arc::from),
            points,
            program: Arc::new(program),
            expression_string: Arc::from(expression),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_create_expression() {
        let expr = Expression::new(
            "high_stars",
            Some("Checks if repo has many stars"),
            "stars > 100",
            Some(10),
        );

        assert!(expr.is_ok());
        let expr = expr.unwrap();
        assert_eq!(&*expr.name, "high_stars");
        assert_eq!(expr.description.as_deref(), Some("Checks if repo has many stars"));
        assert_eq!(expr.expression(), "stars > 100");
        assert_eq!(expr.points, Some(10));
    }

    #[test]
    fn test_create_expression_no_description() {
        let expr = Expression::new("simple_check", None, "x > 5", None);

        assert!(expr.is_ok());
        let expr = expr.unwrap();
        assert_eq!(&*expr.name, "simple_check");
        assert!(expr.description.is_none());
    }

    #[test]
    fn test_create_expression_invalid() {
        let expr = Expression::new(
            "bad_expr",
            None,
            "(x > 5", // Mismatched parentheses
            None,
        );

        let _ = expr.unwrap_err();
    }

    #[test]
    fn test_serialize_deserialize_json() {
        let expr = Expression::new("test_expr", None, "a && b", None).unwrap();

        let json = serde_json::to_string(&expr).unwrap();
        let deserialized: Expression = serde_json::from_str(&json).unwrap();

        assert_eq!(expr.name, deserialized.name);
        assert_eq!(expr.description, deserialized.description);
        assert_eq!(expr.expression(), deserialized.expression());
    }

    #[test]
    fn test_serialize_with_description_format() {
        let expr = Expression::new("test", Some("A test description"), "x > 5", None).unwrap();

        let json = serde_json::to_value(&expr).unwrap();
        assert_eq!(json["name"], "test");
        assert_eq!(json["description"], "A test description");
        assert_eq!(json["expression"], "x > 5");
        assert_eq!(json.as_object().unwrap().len(), 3);
    }

    #[test]
    fn test_serialize_without_description_format() {
        let expr = Expression::new("test", None, "x > 5", None).unwrap();

        let json = serde_json::to_value(&expr).unwrap();
        assert_eq!(json["name"], "test");
        assert_eq!(json["expression"], "x > 5");
        assert!(!json.as_object().unwrap().contains_key("description"));
        assert_eq!(json.as_object().unwrap().len(), 2);
    }

    #[test]
    fn test_deserialize_invalid_expression() {
        let json = r#"{"name": "bad", "expression": "(x > 5"}"#;
        let result: Result<Expression, _> = serde_json::from_str(json);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.to_string().contains("could not parse expression"));
    }

    #[test]
    fn test_deserialize_with_description_invalid_expression() {
        let json = r#"{"name": "bad", "description": "desc", "expression": "!!invalid!!"}"#;
        let result: Result<Expression, _> = serde_json::from_str(json);
        let _ = result.unwrap_err();
    }

    #[test]
    fn test_program_getter() {
        let expr = Expression::new("test", None, "x > 5", None).unwrap();

        let program = expr.program();
        // Verify it's a valid program by checking it's not null
        assert!(!core::ptr::eq(core::ptr::from_ref::<Program>(program), core::ptr::null()));
    }

    #[test]
    fn test_all_getters() {
        let expr = Expression::new("test_name", Some("test_desc"), "a && b", None).unwrap();

        assert_eq!(expr.name(), "test_name");
        assert_eq!(expr.description(), Some("test_desc"));
        assert_eq!(expr.expression(), "a && b");
        assert!(!core::ptr::eq(core::ptr::from_ref::<Program>(expr.program()), core::ptr::null()));
    }

    #[test]
    fn test_roundtrip_with_description() {
        let original = r#"{"name":"test","description":"desc","expression":"x > 5"}"#;
        let expr: Expression = serde_json::from_str(original).unwrap();
        let reserialized = serde_json::to_string(&expr).unwrap();
        let expr2: Expression = serde_json::from_str(&reserialized).unwrap();

        assert_eq!(expr.name(), expr2.name());
        assert_eq!(expr.description(), expr2.description());
        assert_eq!(expr.expression(), expr2.expression());
    }

    #[test]
    fn test_complex_expression() {
        let expr = Expression::new("complex", None, "x > 5 && (y < 10 || z == true)", None);
        let _ = expr.unwrap();
    }

    #[test]
    fn test_expression_with_functions() {
        let expr = Expression::new("func_test", None, "size(mylist) > 0", None);
        let _ = expr.unwrap();
    }

    #[test]
    fn test_deserialize_rejects_unknown_fields() {
        let json = r#"{"name": "test", "descriptiono": "typo", "expression": "x > 5"}"#;
        let result: Result<Expression, _> = serde_json::from_str(json);
        assert!(result.is_err(), "misspelled field should be rejected");
    }
}