switchbot-api 0.1.16

A library to to interact with the SwitchBot API and control your SwitchBot devices programmatically.
Documentation
use std::{borrow::Cow, fmt::Display, sync::LazyLock};

use regex::Regex;

#[derive(Debug, Default, PartialEq)]
pub(crate) struct ConditionalExpression<'a> {
    pub key: &'a str,
    operator: &'a str,
    value: &'a str,
}

impl Display for ConditionalExpression<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}{}{}", self.key, self.operator, self.value)
    }
}

impl<'a> TryFrom<&'a str> for ConditionalExpression<'a> {
    type Error = anyhow::Error;

    fn try_from(condition: &'a str) -> Result<Self, Self::Error> {
        const RE_PAT: &str = r"^([a-zA-Z]+)(\s*(=|<=?|>=?)\s*([a-zA-Z0-9]+))?$";
        static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(RE_PAT).unwrap());
        if let Some(captures) = RE.captures(condition) {
            return Ok(ConditionalExpression {
                key: captures.get(1).map_or_else(|| "", |m| m.as_str()),
                operator: captures.get(3).map_or_else(|| "", |m| m.as_str()),
                value: captures.get(4).map_or_else(|| "", |m| m.as_str()),
            });
        }
        Err(anyhow::anyhow!(r#"Not a valid expression "{condition}""#))
    }
}

impl ConditionalExpression<'_> {
    pub fn evaluate(&self, value: &serde_json::Value) -> anyhow::Result<bool> {
        let value_str: Cow<'_, str> = match value {
            serde_json::Value::Bool(b) => {
                if self.operator.is_empty() {
                    log::debug!("evaluate: bool {b}");
                    return Ok(*b);
                }
                Cow::Owned(b.to_string())
            }
            serde_json::Value::Number(num) => {
                if let Some(num_as_f64) = num.as_f64() {
                    let value_as_f64: f64 = self.value.parse()?;
                    return Self::eval_op(self.operator, num_as_f64, value_as_f64);
                }
                Cow::Owned(value.to_string())
            }
            serde_json::Value::String(str) => Cow::Borrowed(str),
            _ => Cow::Owned(value.to_string()),
        };
        if self.operator == "=" {
            let result = value_str == self.value;
            log::debug!(r#"evaluate: "{value_str}"="{}" -> {result}"#, self.value);
            return Ok(result);
        }
        anyhow::bail!("Unsupported condition {self} for {value}");
    }

    fn eval_op<T: Display + PartialOrd>(op: &str, left: T, right: T) -> anyhow::Result<bool> {
        let result = match op {
            "=" => left == right,
            "<" => left < right,
            "<=" => left <= right,
            ">" => left > right,
            ">=" => left >= right,
            _ => anyhow::bail!("Unsupported operator: {op}"),
        };
        log::debug!(r#"evaluate: "{left}"{op}"{right}" -> {result}"#);
        Ok(result)
    }
}

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

    fn parse(str: &str) -> anyhow::Result<ConditionalExpression<'_>> {
        ConditionalExpression::try_from(str)
    }

    fn from_key(key: &str) -> ConditionalExpression<'_> {
        ConditionalExpression {
            key,
            ..Default::default()
        }
    }

    fn from_strs<'a>(key: &'a str, operator: &'a str, value: &'a str) -> ConditionalExpression<'a> {
        ConditionalExpression {
            key,
            operator,
            value,
        }
    }

    #[test]
    fn parse_condition() -> anyhow::Result<()> {
        assert_eq!(parse("a")?, from_key("a"));
        assert_eq!(parse("a=b")?, from_strs("a", "=", "b"));
        assert_eq!(parse("a = b")?, from_strs("a", "=", "b"));
        assert!(parse("a=").is_err());
        assert!(parse("1=a").is_err());
        assert_eq!(parse("a=12")?, from_strs("a", "=", "12"));
        assert_eq!(parse("aZ=xZ2")?, from_strs("aZ", "=", "xZ2"));

        assert_eq!(parse("a<b")?, from_strs("a", "<", "b"));
        assert_eq!(parse("a>b")?, from_strs("a", ">", "b"));
        assert_eq!(parse("a<=b")?, from_strs("a", "<=", "b"));
        assert_eq!(parse("a>=b")?, from_strs("a", ">=", "b"));
        Ok(())
    }

    fn evaluate(expr: &str, value: impl serde::Serialize) -> anyhow::Result<bool> {
        ConditionalExpression::try_from(expr)?.evaluate(&serde_json::json!(value))
    }

    #[test]
    fn evaluate_bool() -> anyhow::Result<()> {
        assert!(evaluate("a", true)?);
        assert!(!(evaluate("a", false)?));
        assert!(evaluate("a=true", true)?);
        assert!(!(evaluate("a=true", false)?));
        assert!(evaluate("a=false", false)?);
        assert!(evaluate("a>true", false).is_err());
        Ok(())
    }

    #[test]
    fn evaluate_str() -> anyhow::Result<()> {
        assert!(evaluate("a", "on").is_err());
        assert!(evaluate("a=on", "on")?);
        assert!(!(evaluate("a=on", "off")?));
        assert!(evaluate("a>on", "off").is_err());
        Ok(())
    }

    #[test]
    fn evaluate_num() -> anyhow::Result<()> {
        assert!(evaluate("a", 123).is_err());
        assert!(evaluate("a=123", 123)?);
        assert!(!(evaluate("a=123", 124)?));

        assert!(evaluate("a<123", 122)?);
        assert!(!(evaluate("a<123", 123)?));
        assert!(evaluate("a>123", 124)?);
        assert!(!(evaluate("a>123", 123)?));
        assert!(evaluate("a<=123", 123)?);
        assert!(!(evaluate("a<=123", 124)?));
        assert!(evaluate("a>=123", 123)?);
        assert!(!(evaluate("a>=123", 122)?));
        Ok(())
    }
}