growthbook-rust 0.0.4

Official Growthbook Rust SDK
Documentation
use std::collections::HashMap;

use serde_json::Value;

use crate::dto::GrowthBookFeatureRuleExperiment;
use crate::extensions::{FindGrowthBookAttribute, JsonHelper};
use crate::hash::{HashCode, HashCodeVersion};
use crate::model_public::{ExperimentResult, FeatureResult, GrowthBookAttribute};
use crate::namespace::use_case::Namespace;
use crate::range::model::Range;

impl GrowthBookFeatureRuleExperiment {
    pub fn get_match_value(
        &self,
        feature_name: &str,
        user_attributes: &Vec<GrowthBookAttribute>,
        forced_variations: &Option<HashMap<String, i64>>,
    ) -> Option<FeatureResult> {
        if let Some(feature_attribute) = &self.hash_attribute {
            self.check_experiment(&feature_name, user_attributes, forced_variations, feature_attribute)
        } else {
            let fallback_attribute = self.get_fallback_attribute();
            self.check_experiment(&feature_name, user_attributes, forced_variations, &fallback_attribute)
        }
    }

    fn check_experiment(
        &self,
        feature_name: &&str,
        user_attributes: &Vec<GrowthBookAttribute>,
        forced_variations: &Option<HashMap<String, i64>>,
        feature_attribute: &str,
    ) -> Option<FeatureResult> {
        if let Some(user_value) = user_attributes.find_value(feature_attribute) {
            if let Some((namespace, range)) = &self.namespace_range() {
                if !Namespace::is_in(&user_value, namespace, range) {
                    return None;
                }
            }

            if let Some(forced_variation) = self.forced_variation(feature_name, user_attributes, forced_variations) {
                return Some(forced_variation);
            }

            let user_weight = HashCode::hash_code(&user_value.to_string(), &self.seed(feature_name), HashCodeVersion::from(self.hash_version)).unwrap_or(-1.0);
            let ranges = self.ranges();
            let index = choose_variation(user_weight, ranges);
            if index >= 0 {
                let usize_index = index as usize;
                let value = self.variations[usize_index].clone();
                let (meta_value, pass_through) = self.get_meta_value(usize_index);
                if !pass_through {
                    return Some(FeatureResult::experiment(
                        value.clone(),
                        self.model_experiment(),
                        create_experiment_result(
                            feature_name,
                            value.clone(),
                            index,
                            true,
                            Some(feature_attribute.to_string()),
                            Some(user_value.to_value()),
                            Some(user_weight),
                            meta_value,
                        ),
                    ));
                }
            }
        }

        None
    }

    fn forced_variation(
        &self,
        feature_name: &str,
        user_attributes: &Vec<GrowthBookAttribute>,
        forced_variations: &Option<HashMap<String, i64>>,
    ) -> Option<FeatureResult> {
        if let Some(forced_variations) = forced_variations {
            if let Some(found_forced_variation) = forced_variations.get(feature_name) {
                let hash_attribute = self.hash_attribute.clone().unwrap_or(self.get_fallback_attribute());
                if let Some(user_value) = user_attributes.find_value(&hash_attribute) {
                    let forced_variation_index = *found_forced_variation as usize;
                    let value = self.variations[forced_variation_index].clone();
                    let (meta_value, pass_through) = self.get_meta_value(forced_variation_index);
                    if !pass_through {
                        return Some(FeatureResult::experiment(
                            value.clone(),
                            self.model_experiment(),
                            create_experiment_result(
                                feature_name,
                                value.clone(),
                                *found_forced_variation,
                                true,
                                self.hash_attribute.clone(),
                                Some(user_value.to_value()),
                                None,
                                meta_value,
                            ),
                        ));
                    }
                }
            }
        }
        None
    }

    fn get_meta_value(
        &self,
        usize_index: usize,
    ) -> (String, bool) {
        match &self.meta {
            None => (format!("{usize_index}"), false),
            Some(it) => {
                if let Some(meta_value) = it.force_array(vec![]).get(usize_index) {
                    let pass_through = if let Some(pass_through_value) = meta_value.get("passthrough") {
                        pass_through_value.force_bool(false)
                    } else {
                        false
                    };

                    if let Some(key) = meta_value.get("key") {
                        (key.force_string(""), pass_through)
                    } else {
                        (format!("{usize_index}"), pass_through)
                    }
                } else {
                    (format!("{usize_index}"), false)
                }
            },
        }
    }

    fn get_fallback_attribute(&self) -> String {
        self.fallback_attribute.clone().unwrap_or(String::from("id"))
    }
}

#[allow(clippy::too_many_arguments)]
fn create_experiment_result(
    feature_name: &str,
    value: Value,
    variation_id: i64,
    hash_used: bool,
    hash_attribute: Option<String>,
    hash_value: Option<Value>,
    bucket: Option<f32>,
    key: String,
) -> ExperimentResult {
    ExperimentResult {
        feature_id: String::from(feature_name),
        value,
        variation_id,
        in_experiment: true,
        hash_used,
        hash_attribute,
        hash_value,
        bucket,
        key,
        sticky_bucket_used: false,
    }
}

fn choose_variation(
    user_weight: f32,
    ranges: Vec<Range>,
) -> i64 {
    for (index, range) in ranges.iter().enumerate() {
        if range.in_range(&user_weight) {
            return index as i64;
        }
    }
    -1
}

#[cfg(test)]
mod test {
    use std::fs;

    use serde::Deserialize;
    use serde_json::Value;

    use crate::feature::feature_rule_experiment::choose_variation;
    use crate::range::model::Range;

    #[tokio::test]
    async fn evaluate_choose_variation() -> Result<(), Box<dyn std::error::Error>> {
        let cases = Cases::new();

        for value in cases.choose_variation {
            let eval_choose_variation = EvalChooseVariation::new(value);
            let index = choose_variation(eval_choose_variation.weight, eval_choose_variation.ranges);
            if eval_choose_variation.index != index {
                panic!(
                    "EvalChooseVariation failed; name='{}' expected_index={} index={index}",
                    eval_choose_variation.name, eval_choose_variation.index
                )
            }
        }

        Ok(())
    }

    #[derive(Deserialize, Clone)]
    #[serde(rename_all = "camelCase")]
    struct Cases {
        choose_variation: Vec<Value>,
    }

    pub struct EvalChooseVariation {
        name: String,
        weight: f32,
        ranges: Vec<Range>,
        index: i64,
    }

    impl EvalChooseVariation {
        fn new(value: Value) -> Self {
            let array = value.as_array().expect("Failed to convert to array");
            Self {
                name: array[0].as_str().expect("Failed to convert to str").to_string(),
                weight: array[1].as_f64().expect("Failed to convert to f64") as f32,
                ranges: array[2]
                    .as_array()
                    .expect("Failed to convert to array")
                    .iter()
                    .map(|it| {
                        let array = it.as_array().expect("Failed to convert to array [2]");
                        Range {
                            start: array[0].as_f64().expect("Failed to convert to f64") as f32,
                            end: array[1].as_f64().expect("Failed to convert to f64") as f32,
                        }
                    })
                    .collect(),
                index: array[3].as_i64().expect("Failed to convert to i64"),
            }
        }
    }

    impl Cases {
        pub fn new() -> Self {
            let contents = fs::read_to_string("./tests/all_cases.json").expect("Should have been able to read the file");

            serde_json::from_str(&contents).expect("Failed to create cases")
        }
    }
}