car-state 0.8.0

State store for Common Agent Runtime
Documentation
//! State management for Common Agent Runtime.
//!
//! Provides structured, typed state with transition logging.
//! Every mutation produces a StateTransition record for audit and replay.

use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// An explicit record of a state change.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StateTransition {
    pub key: String,
    pub old_value: Option<Value>,
    pub new_value: Option<Value>,
    pub action_id: String,
    pub timestamp: DateTime<Utc>,
}

/// Thread-safe state store with transition logging.
///
/// All reads and writes go through this store. Every write produces a
/// StateTransition record for audit and replay.
pub struct StateStore {
    state: Mutex<HashMap<String, Value>>,
    transitions: Mutex<Vec<StateTransition>>,
}

impl StateStore {
    pub fn new() -> Self {
        Self {
            state: Mutex::new(HashMap::new()),
            transitions: Mutex::new(Vec::new()),
        }
    }

    pub fn get(&self, key: &str) -> Option<Value> {
        self.state.lock().get(key).cloned()
    }

    pub fn get_or(&self, key: &str, default: Value) -> Value {
        self.state.lock().get(key).cloned().unwrap_or(default)
    }

    pub fn exists(&self, key: &str) -> bool {
        self.state.lock().contains_key(key)
    }

    pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
        let mut state = self.state.lock();
        let old = state.get(key).cloned();
        state.insert(key.to_string(), value.clone());

        let t = StateTransition {
            key: key.to_string(),
            old_value: old,
            new_value: Some(value),
            action_id: action_id.to_string(),
            timestamp: Utc::now(),
        };

        self.transitions.lock().push(t.clone());
        t
    }

    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
        let mut state = self.state.lock();
        let old = state.remove(key)?;

        let t = StateTransition {
            key: key.to_string(),
            old_value: Some(old),
            new_value: None,
            action_id: action_id.to_string(),
            timestamp: Utc::now(),
        };

        self.transitions.lock().push(t.clone());
        Some(t)
    }

    /// Deep clone of current state.
    pub fn snapshot(&self) -> HashMap<String, Value> {
        self.state.lock().clone()
    }

    /// Restore state from a snapshot, truncating transitions.
    pub fn restore(&self, snapshot: HashMap<String, Value>, transition_count: usize) {
        *self.state.lock() = snapshot;
        self.transitions.lock().truncate(transition_count);
    }

    pub fn transition_count(&self) -> usize {
        self.transitions.lock().len()
    }

    pub fn transitions(&self) -> Vec<StateTransition> {
        self.transitions.lock().clone()
    }

    pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
        let transitions = self.transitions.lock();
        let start = index.min(transitions.len());
        transitions[start..].to_vec()
    }

    pub fn keys(&self) -> Vec<String> {
        self.state.lock().keys().cloned().collect()
    }

    /// Replace the entire state map without recording transitions.
    /// Used by checkpoint restore to avoid synthetic transition history.
    /// Also clears the transitions log so callers of `transitions_since()`
    /// don't see stale history from the discarded state.
    pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
        *self.state.lock() = snapshot;
        self.transitions.lock().clear();
    }
}

impl Default for StateStore {
    fn default() -> Self {
        Self::new()
    }
}

impl car_ir::precondition::StateView for StateStore {
    fn get_value(&self, key: &str) -> Option<Value> {
        self.get(key)
    }
    fn key_exists(&self, key: &str) -> bool {
        self.exists(key)
    }
}

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

    #[test]
    fn set_and_get() {
        let store = StateStore::new();
        store.set("x", Value::from(42), "test");
        assert_eq!(store.get("x"), Some(Value::from(42)));
    }

    #[test]
    fn exists() {
        let store = StateStore::new();
        assert!(!store.exists("x"));
        store.set("x", Value::from(1), "test");
        assert!(store.exists("x"));
    }

    #[test]
    fn delete() {
        let store = StateStore::new();
        store.set("x", Value::from(1), "test");
        let t = store.delete("x", "test");
        assert!(t.is_some());
        assert!(!store.exists("x"));
    }

    #[test]
    fn delete_nonexistent() {
        let store = StateStore::new();
        assert!(store.delete("x", "test").is_none());
    }

    #[test]
    fn snapshot_and_restore() {
        let store = StateStore::new();
        store.set("x", Value::from(1), "a");
        let snap = store.snapshot();
        let tc = store.transition_count();

        store.set("y", Value::from(2), "b");
        assert!(store.exists("y"));

        store.restore(snap, tc);
        assert!(store.exists("x"));
        assert!(!store.exists("y"));
        assert_eq!(store.transition_count(), 1);
    }

    #[test]
    fn transitions_logged() {
        let store = StateStore::new();
        store.set("a", Value::from(1), "act1");
        store.set("b", Value::from(2), "act2");

        let transitions = store.transitions();
        assert_eq!(transitions.len(), 2);
        assert_eq!(transitions[0].key, "a");
        assert_eq!(transitions[1].key, "b");
    }

    #[test]
    fn transitions_since() {
        let store = StateStore::new();
        store.set("a", Value::from(1), "act1");
        let idx = store.transition_count();
        store.set("b", Value::from(2), "act2");

        let since = store.transitions_since(idx);
        assert_eq!(since.len(), 1);
        assert_eq!(since[0].key, "b");
    }

    #[test]
    fn transition_records_old_value() {
        let store = StateStore::new();
        store.set("x", Value::from(1), "first");
        store.set("x", Value::from(2), "second");

        let transitions = store.transitions();
        assert_eq!(transitions[1].old_value, Some(Value::from(1)));
        assert_eq!(transitions[1].new_value, Some(Value::from(2)));
    }

    #[test]
    fn keys() {
        let store = StateStore::new();
        store.set("a", Value::from(1), "t");
        store.set("b", Value::from(2), "t");
        let mut keys = store.keys();
        keys.sort();
        assert_eq!(keys, vec!["a", "b"]);
    }

    #[test]
    fn transitions_since_after_restore_does_not_panic() {
        let store = StateStore::new();
        store.set("a", serde_json::json!(1), "test");
        store.set("b", serde_json::json!(2), "test");
        let count_before = store.transition_count(); // 2

        // Restore to empty, truncating transitions to 0
        store.restore(HashMap::new(), 0);

        // Using the stale count_before (2) should not panic
        let result = store.transitions_since(count_before);
        assert!(result.is_empty());
    }

    #[test]
    fn transitions_since_normal_usage() {
        let store = StateStore::new();
        store.set("a", serde_json::json!(1), "test");
        let mark = store.transition_count();
        store.set("b", serde_json::json!(2), "test");
        let since = store.transitions_since(mark);
        assert_eq!(since.len(), 1);
        assert_eq!(since[0].key, "b");
    }

    #[test]
    fn replace_all_swaps_state_without_transitions() {
        let store = StateStore::new();
        store.set("old_key", serde_json::json!("old"), "setup");

        let mut new_state = HashMap::new();
        new_state.insert("new_key".to_string(), serde_json::json!("new"));
        store.replace_all(new_state);

        assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
        assert_eq!(store.get("old_key"), None);
        // After replace_all, transitions should be cleared (not preserved)
        assert_eq!(store.transition_count(), 0);
    }
}