deployramp 0.1.3

DeployRamp SDK - Feature flag management for Rust
Documentation
//! DeployRamp SDK — Feature flag management for Rust.
//!
//! # Quick start
//!
//! ```rust,no_run
//! use deployramp::{Config, init, flag, close};
//!
//! fn main() {
//!     init(Config::new("pk_live_abc123")).unwrap();
//!
//!     if flag("new-checkout", None) {
//!         // new checkout flow
//!     }
//!
//!     close();
//! }
//! ```

pub mod types;
mod client;
mod user;

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

use client::ApiClient;
pub use types::{Config, EvaluationEvent, FlagData, FlagSegment, TraitCondition};
use types::WsMessage;
use user::get_user_id;

// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------

struct SdkState {
    client: ApiClient,
    flags: HashMap<String, FlagData>,
    traits: HashMap<String, String>,
    base_url: String,
    public_token: String,
}

static SDK: Mutex<Option<SdkState>> = Mutex::new(None);

// ---------------------------------------------------------------------------
// Hash function (matches the JS SDK)
// ---------------------------------------------------------------------------

/// Produces a 0–99 bucket matching the JS SDK hash implementation.
fn hash_key(input: &str) -> i32 {
    let mut h: i32 = 0;
    for ch in input.chars() {
        h = h.wrapping_shl(5).wrapping_sub(h).wrapping_add(ch as i32);
    }
    (h.wrapping_abs()) % 100
}

// ---------------------------------------------------------------------------
// Condition matching
// ---------------------------------------------------------------------------

/// Recursively evaluates a trait condition against the given traits.
fn match_condition(cond: &TraitCondition, traits: &HashMap<String, String>) -> bool {
    match cond.condition_type.as_str() {
        "match" => {
            let key = cond.trait_key.as_deref().unwrap_or("");
            let expected = cond.trait_value.as_deref().unwrap_or("");
            traits.get(key).map_or(false, |v| v == expected)
        }
        "and" => cond
            .conditions
            .as_ref()
            .map_or(true, |cs| cs.iter().all(|c| match_condition(c, traits))),
        "or" => cond
            .conditions
            .as_ref()
            .map_or(false, |cs| cs.iter().any(|c| match_condition(c, traits))),
        _ => false,
    }
}

// ---------------------------------------------------------------------------
// Trait merging
// ---------------------------------------------------------------------------

fn merge_traits(
    base: &HashMap<String, String>,
    overrides: Option<&HashMap<String, String>>,
) -> HashMap<String, String> {
    let mut merged = base.clone();
    if let Some(o) = overrides {
        for (k, v) in o {
            merged.insert(k.clone(), v.clone());
        }
    }
    merged
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Initialises the DeployRamp SDK. Fetches flags from the server.
///
/// # Errors
///
/// Returns an error string if the initial flag fetch fails.
pub fn init(config: Config) -> Result<(), String> {
    let base_url = config.base_url.trim_end_matches('/').to_string();
    let client = ApiClient::new(base_url.clone(), config.public_token.clone());

    let resp = client.fetch_flags(
        Some(get_user_id().to_string()),
        Some(config.traits.clone()),
    )?;

    let mut flag_map = HashMap::new();
    for f in resp.flags {
        flag_map.insert(f.name.clone(), f);
    }

    let mut sdk = SDK.lock().unwrap();
    *sdk = Some(SdkState {
        client,
        flags: flag_map,
        traits: config.traits,
        base_url,
        public_token: config.public_token,
    });

    Ok(())
}

/// Replaces the current global traits.
pub fn set_traits(traits: HashMap<String, String>) {
    if let Ok(mut sdk) = SDK.lock() {
        if let Some(ref mut state) = *sdk {
            state.traits = traits;
        }
    }
}

/// Evaluates a feature flag and returns whether it is active.
///
/// Returns `false` if the SDK has not been initialised, the flag is unknown,
/// or the flag is disabled.
pub fn flag(name: &str, trait_overrides: Option<&HashMap<String, String>>) -> bool {
    let sdk = SDK.lock().unwrap();
    let state = match sdk.as_ref() {
        Some(s) => s,
        None => return false,
    };

    let f = match state.flags.get(name) {
        Some(f) => f,
        None => return false,
    };

    if !f.enabled {
        return false;
    }

    let traits = merge_traits(&state.traits, trait_overrides);
    let user_id = get_user_id();

    // Check segments for trait-based rollout
    if let Some(ref segments) = f.segments {
        for segment in segments {
            if match_condition(&segment.condition, &traits) {
                // Sticky check
                if segment.sticky {
                    if let Some(ref sticky) = f.sticky_assignments {
                        if sticky.contains(&segment.segment_id) {
                            return true;
                        }
                    }
                }

                let bucket = hash_key(&format!("{}:{}:{}", name, user_id, segment.segment_id));
                return bucket < segment.rollout_percentage;
            }
        }
    }

    // Default: use the top-level rollout_percentage
    if f.rollout_percentage >= 100 {
        return true;
    }
    if f.rollout_percentage <= 0 {
        return false;
    }

    let bucket = hash_key(&format!("{}:{}", name, user_id));
    bucket < f.rollout_percentage
}

/// Reports an error to DeployRamp. Fire-and-forget.
pub fn report(
    error_msg: &str,
    stack: Option<&str>,
    flag_name: Option<&str>,
    trait_overrides: Option<&HashMap<String, String>>,
) {
    let sdk = SDK.lock().unwrap();
    let state = match sdk.as_ref() {
        Some(s) => s,
        None => return,
    };

    let traits = merge_traits(&state.traits, trait_overrides);

    ApiClient::report_error(
        state.base_url.clone(),
        state.public_token.clone(),
        flag_name.unwrap_or("unknown").to_string(),
        error_msg.to_string(),
        stack.map(|s| s.to_string()),
        Some(get_user_id().to_string()),
        Some(traits),
    );
}

/// Shuts down the SDK and clears all state.
pub fn close() {
    let mut sdk = SDK.lock().unwrap();
    *sdk = None;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn test_hash_key_range() {
        for input in &["", "hello", "flag:user:seg", "test-flag:user-123"] {
            let h = hash_key(input);
            assert!(h >= 0 && h < 100, "hash_key({input}) = {h} out of range");
        }
    }

    #[test]
    fn test_hash_key_deterministic() {
        let a = hash_key("test-flag:user-123");
        let b = hash_key("test-flag:user-123");
        assert_eq!(a, b);
    }

    #[test]
    fn test_match_condition_match_type() {
        let cond = TraitCondition {
            condition_type: "match".to_string(),
            trait_key: Some("env".to_string()),
            trait_value: Some("prod".to_string()),
            conditions: None,
        };
        let mut traits = HashMap::new();
        traits.insert("env".to_string(), "prod".to_string());
        assert!(match_condition(&cond, &traits));

        traits.insert("env".to_string(), "staging".to_string());
        assert!(!match_condition(&cond, &traits));
    }

    #[test]
    fn test_match_condition_and() {
        let cond = TraitCondition {
            condition_type: "and".to_string(),
            trait_key: None,
            trait_value: None,
            conditions: Some(vec![
                TraitCondition {
                    condition_type: "match".to_string(),
                    trait_key: Some("env".to_string()),
                    trait_value: Some("prod".to_string()),
                    conditions: None,
                },
                TraitCondition {
                    condition_type: "match".to_string(),
                    trait_key: Some("plan".to_string()),
                    trait_value: Some("enterprise".to_string()),
                    conditions: None,
                },
            ]),
        };

        let mut traits = HashMap::new();
        traits.insert("env".to_string(), "prod".to_string());
        traits.insert("plan".to_string(), "enterprise".to_string());
        assert!(match_condition(&cond, &traits));

        traits.insert("plan".to_string(), "free".to_string());
        assert!(!match_condition(&cond, &traits));
    }

    #[test]
    fn test_match_condition_or() {
        let cond = TraitCondition {
            condition_type: "or".to_string(),
            trait_key: None,
            trait_value: None,
            conditions: Some(vec![
                TraitCondition {
                    condition_type: "match".to_string(),
                    trait_key: Some("env".to_string()),
                    trait_value: Some("prod".to_string()),
                    conditions: None,
                },
                TraitCondition {
                    condition_type: "match".to_string(),
                    trait_key: Some("env".to_string()),
                    trait_value: Some("staging".to_string()),
                    conditions: None,
                },
            ]),
        };

        let mut traits = HashMap::new();
        traits.insert("env".to_string(), "prod".to_string());
        assert!(match_condition(&cond, &traits));

        traits.insert("env".to_string(), "dev".to_string());
        assert!(!match_condition(&cond, &traits));
    }

    #[test]
    fn test_match_condition_unknown() {
        let cond = TraitCondition {
            condition_type: "unknown".to_string(),
            trait_key: None,
            trait_value: None,
            conditions: None,
        };
        assert!(!match_condition(&cond, &HashMap::new()));
    }

    #[test]
    fn test_flag_before_init() {
        assert!(!flag("anything", None));
    }

    #[test]
    fn test_merge_traits() {
        let mut base = HashMap::new();
        base.insert("a".to_string(), "1".to_string());
        base.insert("b".to_string(), "2".to_string());

        let mut overrides = HashMap::new();
        overrides.insert("b".to_string(), "3".to_string());
        overrides.insert("c".to_string(), "4".to_string());

        let merged = super::merge_traits(&base, Some(&overrides));
        assert_eq!(merged.get("a").unwrap(), "1");
        assert_eq!(merged.get("b").unwrap(), "3");
        assert_eq!(merged.get("c").unwrap(), "4");
    }
}