rave_engine 0.8.0

A secure and efficient JSON Schema validation and Rhai script execution engine
Documentation
use crate::types::UnitMap;
use rhai::{Array, Dynamic, EvalAltResult, Map, Position};
use std::{collections::BTreeMap, str::FromStr};
use zfuel::fuel::ZFuel;

/// Functions for parsing and type conversion in the RAVE engine.
/// This module provides utility functions for parsing different data types.
///
/// Parses a string into an integer (i64).
///
/// # Arguments
///
/// * `s` - A String that should be converted to an integer
///
/// # Returns
///
/// * `f64` - The parsed float value, or 0.0 if parsing fails
pub fn parse_float(s: String) -> f64 {
    s.parse::<f64>().unwrap_or(0.0)
}

/// Reads a numeric agreement field that may arrive as either JSON shape — a
/// number (`0.5`) or a numeric string (`"0.5"`).
///
/// `parse_float` covers neither hazard of *authored* data (price sheets,
/// invoice logs, runtime inputs): Rhai dispatches host functions by argument
/// type, so a number-typed field fails with an opaque "function not found",
/// and an unparseable string collapses to a silent `0.0` — which in a price
/// sheet reads as a free service. `to_num` accepts both shapes and refuses
/// anything else (or a non-finite value) loudly, naming the offending value
/// so the authoring mistake is findable from the error alone.
pub fn to_num(value: Dynamic) -> Result<f64, Box<EvalAltResult>> {
    let type_name = value.type_name();
    if let Ok(f) = value.as_float() {
        if !f.is_finite() {
            return Err(Box::new(EvalAltResult::ErrorRuntime(
                format!("to_num: {f} is not a finite number").into(),
                Position::NONE,
            )));
        }
        return Ok(f);
    }
    if let Ok(i) = value.as_int() {
        return Ok(i as f64);
    }
    let shown = value.to_string();
    if value.into_immutable_string().is_ok() {
        return match shown.parse::<f64>() {
            Ok(f) if f.is_finite() => Ok(f),
            _ => Err(Box::new(EvalAltResult::ErrorRuntime(
                format!("to_num: \"{shown}\" is not a finite number").into(),
                Position::NONE,
            ))),
        };
    }
    Err(Box::new(EvalAltResult::ErrorRuntime(
        format!("to_num: expected a number or numeric string, got {type_name} ({shown})").into(),
        Position::NONE,
    )))
}

pub fn add_units(a: Map, b: Map) -> Result<Map, Box<EvalAltResult>> {
    let mut a = parse_amount(a)?;
    let b = parse_amount(b)?;
    a.add(b).map_err(|e| {
        Box::new(EvalAltResult::ErrorRuntime(
            e.to_string().into(),
            Position::NONE,
        ))
    })?;
    Ok(a.to_map())
}

pub fn sub_units(a: Map, b: Map) -> Result<Map, Box<EvalAltResult>> {
    let mut a = parse_amount(a)?;
    let b = parse_amount(b)?;
    a.sub(b).map_err(|e| {
        Box::new(EvalAltResult::ErrorRuntime(
            e.to_string().into(),
            Position::NONE,
        ))
    })?;
    Ok(a.to_map())
}

pub fn extract_indexes(amount: Map, indexes: Array) -> Result<Map, Box<EvalAltResult>> {
    // convert amount to UnitMap
    let units = parse_amount(amount)?;
    // convert index array to vec of strings
    let indexes: Vec<String> = indexes.into_iter().map(|index| index.to_string()).collect();
    let mut extracted_amount = Map::new();
    for (key, value) in units.into_iter() {
        if indexes.contains(&key) {
            extracted_amount.insert(key.into(), value.to_string().into());
        }
    }
    Ok(extracted_amount)
}

fn parse_amount(amount: Map) -> Result<UnitMap, Box<EvalAltResult>> {
    let mut units = BTreeMap::new();
    for (key, value) in amount.iter() {
        units.insert(
            key.to_string(),
            ZFuel::from_str(value.to_string().as_str()).map_err(|_| {
                Box::new(EvalAltResult::ErrorRuntime(
                    format!(
                        "Failed to parse unit amount: {}",
                        value.to_string().as_str()
                    )
                    .into(),
                    Position::NONE,
                ))
            })?,
        );
    }
    Ok(UnitMap::load(units))
}

pub fn add_fuel(a: String, b: String) -> Result<String, Box<EvalAltResult>> {
    let a = ZFuel::from_str(&a).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            format!("Failed to parse unit a amount: {}", a).into(),
            Position::NONE,
        ))
    })?;
    let b = ZFuel::from_str(&b).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            format!("Failed to parse fuel b amount: {}", b).into(),
            Position::NONE,
        ))
    })?;
    let c = (a + b).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            format!("Failed to add fuel amounts: {} + {}", a, b).into(),
            Position::NONE,
        ))
    })?;
    Ok(c.to_string())
}

pub fn sub_fuel(a: String, b: String) -> Result<String, Box<EvalAltResult>> {
    let a = ZFuel::from_str(&a).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            format!("Failed to parse fuel amount: {}", a).into(),
            Position::NONE,
        ))
    })?;
    let b = ZFuel::from_str(&b).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            format!("Failed to parse fuel amount: {}", b).into(),
            Position::NONE,
        ))
    })?;
    let c = (a - b).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            format!("Failed to subtract fuel amounts: {} - {}", a, b).into(),
            Position::NONE,
        ))
    })?;
    Ok(c.to_string())
}

/// Checks if the first fuel amount is greater than the second fuel amount
///
/// # Arguments
///
/// * `a` - The first fuel amount
/// * `b` - The second fuel amount
///
/// # Returns
///
/// * `bool` - True if the first fuel amount is greater than the second fuel amount, false otherwise
pub fn is_greater_than_eq(a: String, b: String) -> Result<bool, Box<EvalAltResult>> {
    let a = ZFuel::from_str(&a).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            "Failed to parse fuel amount".into(),
            Position::NONE,
        ))
    })?;
    let b = ZFuel::from_str(&b).map_err(|_| {
        Box::new(EvalAltResult::ErrorRuntime(
            "Failed to parse fuel amount".into(),
            Position::NONE,
        ))
    })?;
    Ok(a >= b)
}

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

    #[test]
    fn to_num_accepts_both_json_shapes() {
        assert_eq!(to_num(Dynamic::from(0.5_f64)).unwrap(), 0.5);
        assert_eq!(to_num(Dynamic::from(7_i64)).unwrap(), 7.0);
        assert_eq!(to_num(Dynamic::from("0.5".to_string())).unwrap(), 0.5);
        assert_eq!(to_num(Dynamic::from("-2.99".to_string())).unwrap(), -2.99);
        assert_eq!(to_num(Dynamic::from("0".to_string())).unwrap(), 0.0);
    }

    #[test]
    fn to_num_names_the_offending_value_instead_of_silent_zero() {
        // parse_float("O.5") == 0.0 — a typo'd price-sheet rate silently
        // becomes a free service. to_num must refuse and NAME the value.
        let err = to_num(Dynamic::from("O.5".to_string()))
            .unwrap_err()
            .to_string();
        assert!(err.contains("O.5"), "error must name the value: {err}");
        assert!(to_num(Dynamic::from(String::new())).is_err());
    }

    #[test]
    fn to_num_refuses_non_numeric_types_and_non_finite() {
        let err = to_num(Dynamic::from(true)).unwrap_err().to_string();
        assert!(err.contains("bool"), "error must name the type: {err}");
        assert!(to_num(Dynamic::UNIT).is_err());
        assert!(to_num(Dynamic::from("NaN".to_string())).is_err());
        assert!(to_num(Dynamic::from("inf".to_string())).is_err());
        assert!(to_num(Dynamic::from(f64::NAN)).is_err());
    }

    #[test]
    fn test_parse_float_positive() {
        assert_eq!(parse_float("2.99".to_string()), 2.99);
        assert_eq!(parse_float("100.5".to_string()), 100.5);
        assert_eq!(parse_float("0.5".to_string()), 0.5);
    }

    #[test]
    fn test_parse_float_negative() {
        assert_eq!(parse_float("-2.99".to_string()), -2.99);
        assert_eq!(parse_float("-100.5".to_string()), -100.5);
        assert_eq!(parse_float("-0.5".to_string()), -0.5);
    }

    #[test]
    fn test_parse_float_integers() {
        assert_eq!(parse_float("42".to_string()), 42.0);
        assert_eq!(parse_float("-42".to_string()), -42.0);
        assert_eq!(parse_float("0".to_string()), 0.0);
    }

    #[test]
    fn test_parse_float_leading_decimal() {
        // Rust's f64::parse() actually supports leading decimals!
        assert_eq!(parse_float(".99".to_string()), 0.99);
        assert_eq!(parse_float(".5".to_string()), 0.5);
    }

    #[test]
    fn test_parse_float_invalid() {
        assert_eq!(parse_float("invalid".to_string()), 0.0);
        assert_eq!(parse_float("".to_string()), 0.0);
        assert_eq!(parse_float("abc123".to_string()), 0.0);
    }

    #[test]
    fn test_parse_float_edge_cases() {
        assert_eq!(parse_float("0.0".to_string()), 0.0);
        assert_eq!(parse_float("-0.0".to_string()), -0.0);
        assert_eq!(parse_float("  2.99  ".to_string()), 0.0); // Whitespace causes failure
    }

    #[test]
    fn test_parse_float_with_trim() {
        // If you want to handle whitespace, trim first
        let with_whitespace = "  2.99  ";
        assert_eq!(parse_float(with_whitespace.trim().to_string()), 2.99);

        let with_leading_space = " .99";
        assert_eq!(parse_float(with_leading_space.trim().to_string()), 0.99);
    }

    #[test]
    fn test_parse_float_substring_scenario() {
        // Simulating the scenario from rideshare code
        let negative_amount = "-2.99";
        let positive_amount = "2.99";

        // Substring of negative number (removing the minus sign)
        let result_neg = parse_float(negative_amount[1..].to_string());
        assert_eq!(result_neg, 2.99);

        // Substring of positive number - actually works!
        let result_pos = parse_float(positive_amount[1..].to_string());
        assert_eq!(result_pos, 0.99); // ".99" parses successfully to 0.99
    }
}