diff --git a/crates/cac_client/src/eval.rs b/crates/cac_client/src/eval.rs index 41d89196e..a23e91a86 100644 --- a/crates/cac_client/src/eval.rs +++ b/crates/cac_client/src/eval.rs @@ -5,6 +5,9 @@ use superposition_types::{logic::evaluate_local_cohorts, Config, Overrides}; use crate::{utils::core::MapError, Context, MergeStrategy}; +type KeyTransitions = Vec; +type ReasoningHook<'a> = &'a mut dyn FnMut(Context, &Overrides, KeyTransitions); + pub fn merge(doc: &mut Value, patch: &Value) { if !patch.is_object() { *doc = patch.clone(); @@ -20,6 +23,43 @@ pub fn merge(doc: &mut Value, patch: &Value) { } } +fn merged_value( + previous: Option<&Value>, + patch: &Value, + merge_strategy: &MergeStrategy, +) -> Value { + match merge_strategy { + MergeStrategy::REPLACE => patch.clone(), + MergeStrategy::MERGE => { + let mut next = previous.cloned().unwrap_or(Value::Null); + merge(&mut next, patch); + next + } + } +} + +fn build_key_transitions( + current_overrides: &Value, + override_patch: &Overrides, + merge_strategy: &MergeStrategy, +) -> KeyTransitions { + let current_map = current_overrides.as_object(); + + override_patch + .iter() + .map(|(key, patch)| { + let previous = current_map.and_then(|map| map.get(key)).cloned(); + let next = merged_value(previous.as_ref(), patch, merge_strategy); + + json!({ + "key": key, + "previous": previous, + "next": next, + }) + }) + .collect() +} + fn replace_top_level( doc: &mut Map, patch: &Value, @@ -44,14 +84,17 @@ fn get_overrides( contexts: &[Context], overrides: &HashMap, merge_strategy: &MergeStrategy, - mut on_override_select: Option<&mut dyn FnMut(Context)>, + mut on_override_select: Option>, ) -> serde_json::Result { let mut required_overrides: Value = json!({}); - let mut on_override_select = |context: Context| { - if let Some(ref mut func) = on_override_select { - func(context) - } - }; + let mut on_override_select = + |context: Context, + override_patch: &Overrides, + key_transitions: KeyTransitions| { + if let Some(ref mut func) = on_override_select { + func(context, override_patch, key_transitions) + } + }; for context in contexts { let valid_context = superposition_types::apply(&context.condition, query_data); @@ -59,11 +102,22 @@ fn get_overrides( if valid_context { let override_key = context.override_with_keys.get_key(); if let Some(overriden_value) = overrides.get(override_key) { + let key_transitions = build_key_transitions( + &required_overrides, + overriden_value, + merge_strategy, + ); match merge_strategy { MergeStrategy::REPLACE => replace_top_level( required_overrides.as_object_mut().unwrap(), &Value::Object(overriden_value.clone().into()), - || on_override_select(context.clone()), + || { + on_override_select( + context.clone(), + overriden_value, + key_transitions.clone(), + ) + }, override_key, ), MergeStrategy::MERGE => { @@ -71,7 +125,11 @@ fn get_overrides( &mut required_overrides, &Value::Object(overriden_value.clone().into()), ); - on_override_select(context.clone()) + on_override_select( + context.clone(), + overriden_value, + key_transitions, + ) } } } @@ -106,7 +164,7 @@ pub fn eval_cac( merge_strategy: MergeStrategy, ) -> Result, String> { let mut default_config = (*config.default_configs).clone(); - let on_override_select: Option<&mut dyn FnMut(Context)> = None; + let on_override_select: Option> = None; let modified_query_data = evaluate_local_cohorts(&config.dimensions, query_data); let overrides: Map = get_overrides( &modified_query_data, @@ -128,7 +186,7 @@ pub fn eval_cac_with_reasoning( merge_strategy: MergeStrategy, ) -> Result, String> { let mut default_config = (*config.default_configs).clone(); - let mut reasoning: Vec = vec![]; + let mut reasoning: Map = Map::new(); let modified_query_data = evaluate_local_cohorts(&config.dimensions, query_data); @@ -137,11 +195,34 @@ pub fn eval_cac_with_reasoning( &config.contexts, &config.overrides, &merge_strategy, - Some(&mut |context| { - reasoning.push(json!({ - "context": context.condition, - "override": context.override_with_keys - })) + Some(&mut |context, _override_patch, key_transitions| { + let override_id = context.override_with_keys.get_key().clone(); + + for transition in key_transitions { + let Some(key) = transition + .get("key") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + else { + continue; + }; + + let history = reasoning + .entry(key) + .or_insert_with(|| Value::Array(vec![])); + + if let Value::Array(entries) = history { + entries.push(json!({ + "context_id": context.id.clone(), + "condition": context.condition.clone(), + "priority": context.priority, + "weight": context.weight, + "override_id": override_id.clone(), + "previous": transition.get("previous").cloned().unwrap_or(Value::Null), + "next": transition.get("next").cloned().unwrap_or(Value::Null), + })); + } + } }), ) .and_then(serde_json::from_value) @@ -156,3 +237,108 @@ pub fn eval_cac_with_reasoning( overriden_config.insert("metadata".into(), json!(reasoning)); Ok(overriden_config) } + +#[cfg(test)] +mod tests { + use super::*; + use superposition_types::{Context, ExtendedMap, OverrideWithKeys}; + + fn test_context( + id: &str, + condition: Map, + override_id: &str, + ) -> Context { + Context { + id: id.to_string(), + condition: serde_json::from_value(Value::Object(condition)).unwrap(), + priority: 1, + weight: 1, + override_with_keys: OverrideWithKeys::new(override_id.to_string()), + } + } + + fn base_config() -> Config { + let mut default_configs = Map::new(); + default_configs.insert("featureA".to_string(), json!(false)); + default_configs.insert("settings".to_string(), json!({ "mode": "default" })); + + let mut first_override = Map::new(); + first_override.insert("featureA".to_string(), json!(true)); + first_override.insert("settings".to_string(), json!({ "mode": "first" })); + + let mut second_override = Map::new(); + second_override.insert("settings".to_string(), json!({ "mode": "second" })); + + let mut first_condition = Map::new(); + first_condition.insert("clientId".to_string(), json!("android")); + + let mut second_condition = Map::new(); + second_condition.insert("country".to_string(), json!("IN")); + + Config { + contexts: vec![ + test_context("ctx-1", first_condition, "override-1"), + test_context("ctx-2", second_condition, "override-2"), + ], + overrides: HashMap::from([ + ( + "override-1".to_string(), + serde_json::from_value(Value::Object(first_override)).unwrap(), + ), + ( + "override-2".to_string(), + serde_json::from_value(Value::Object(second_override)).unwrap(), + ), + ]), + default_configs: ExtendedMap::from(default_configs), + dimensions: HashMap::new(), + } + } + + #[test] + fn eval_cac_with_reasoning_groups_metadata_by_config_key() { + let config = base_config(); + let query = Map::from_iter([ + ("clientId".to_string(), json!("android")), + ("country".to_string(), json!("IN")), + ]); + + let resolved = eval_cac_with_reasoning(&config, &query, MergeStrategy::MERGE) + .expect("reasoning config should resolve"); + + let metadata = resolved + .get("metadata") + .and_then(Value::as_object) + .expect("metadata should be grouped by config key"); + + let settings_history = metadata + .get("settings") + .and_then(Value::as_array) + .expect("settings history should be recorded"); + assert_eq!(settings_history.len(), 2); + assert_eq!(settings_history[0]["previous"], Value::Null); + assert_eq!(settings_history[1]["previous"], json!({ "mode": "first" })); + assert_eq!(settings_history[1]["next"], json!({ "mode": "second" })); + + let feature_history = metadata + .get("featureA") + .and_then(Value::as_array) + .expect("featureA history should be recorded"); + assert_eq!(feature_history.len(), 1); + assert_eq!(feature_history[0]["next"], json!(true)); + } + + #[test] + fn eval_cac_without_reasoning_omits_metadata() { + let config = base_config(); + let query = Map::from_iter([ + ("clientId".to_string(), json!("android")), + ("country".to_string(), json!("IN")), + ]); + + let resolved = eval_cac(&config, &query, MergeStrategy::MERGE) + .expect("config should resolve"); + + assert!(resolved.get("metadata").is_none()); + } +} diff --git a/crates/frontend/src/pages/home.rs b/crates/frontend/src/pages/home.rs index f475a593f..2a918e6df 100644 --- a/crates/frontend/src/pages/home.rs +++ b/crates/frontend/src/pages/home.rs @@ -276,7 +276,7 @@ pub fn Home() -> impl IntoView { .unwrap_or_default(); logging::log!("resolved config {:#?}", config); // unstrike those that we want to show the user - // if metadata field is found, unstrike only that override + // if metadata field is found, unstrike only the applied overrides match config.remove("metadata") { Some(Value::Array(metadata)) => { if metadata.is_empty() { @@ -298,9 +298,41 @@ pub fn Home() -> impl IntoView { }); } } + Some(Value::Object(metadata)) => { + let mut override_ids: Vec = Vec::new(); + for history in metadata.values() { + let Some(entries) = history.as_array() else { + continue; + }; + + for entry in entries { + let Some(override_id) = entry + .get("override_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + else { + continue; + }; + + if !override_ids.contains(&override_id) { + override_ids.push(override_id); + } + } + } + + if override_ids.is_empty() { + logging::log!("unstrike default config"); + unstrike(&String::new(), &config); + } + + for override_id in override_ids { + logging::log!("unstrike {:#?}", override_id); + unstrike(&override_id, &config); + } + } _ => { logging::log!( - "no metadata recieved, default config is the config to be used" + "no metadata received, default config is the config to be used" ); } }