vstorage 0.7.0

Common API for various icalendar/vcard storages.
Documentation
// Copyright 2025 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: EUPL-1.2

//! State cache for optimizing JMAP operations.
//!
//! This module implements a cache that tracks JMAP state transitions and the items
//! that changed between states. This allows determining if a specific item has changed
//! between two states without making additional network requests.

// TODO: evaluate how much of this logic fits best in libjmap.

use std::collections::{HashMap, HashSet};

use libjmap::ChangeStatus;

/// Changes that occurred in a state transition.
#[derive(Debug, Clone, Default)]
pub struct StateChanges {
    /// Items created in this transition.
    pub created: HashSet<String>,
    /// Items updated in this transition.
    pub updated: HashSet<String>,
    /// Items destroyed in this transition.
    pub destroyed: HashSet<String>,
}

impl StateChanges {
    /// Create a new empty `StateChanges`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create `StateChanges` for a single created item.
    pub fn created(item_id: String) -> Self {
        let mut changes = Self::new();
        changes.created.insert(item_id);
        changes
    }

    /// Create `StateChanges` for a single updated item.
    pub fn updated(item_id: String) -> Self {
        let mut changes = Self::new();
        changes.updated.insert(item_id);
        changes
    }

    /// Create `StateChanges` for a single destroyed item.
    pub fn destroyed(item_id: String) -> Self {
        let mut changes = Self::new();
        changes.destroyed.insert(item_id);
        changes
    }
}

/// A node in the state graph representing a JMAP state.
#[derive(Debug, Clone)]
pub struct StateNode {
    /// The previous state this transitioned from.
    pub parent_state: Option<String>,
    /// Changes that occurred to reach this state from the parent.
    pub changes: StateChanges,
}

/// Cache tracking JMAP state transitions and changes.
#[derive(Debug, Default)]
pub struct StateCache {
    /// Map of state identifier to state node.
    nodes: HashMap<String, StateNode>,
    /// The most recent known state.
    latest_state: Option<String>,
}

impl StateCache {
    /// Create a new empty state cache.
    pub fn new() -> Self {
        Self::default()
    }

    /// Query changes for a specific item between two states.
    ///
    /// Returns `Some(status)` if we can determine the status from cached data,
    /// or `None` if either state is not in the cache or there's no path between them.
    pub fn query_changes(&self, from_state: &str, item_id: &str) -> Option<ChangeStatus> {
        let to_state = self.latest_state.as_ref()?;

        if from_state == to_state {
            return Some(ChangeStatus::NotChanged);
        }

        // Traverse from current state back to from_state.
        // Accumulate changes (in reverse chronological order).
        let mut cursor = to_state.as_str();
        let mut accumulated_changes = StateChanges::new();
        loop {
            // Check if we've reached the from_state BEFORE accumulating.
            if cursor == from_state {
                break;
            }

            let node = self.nodes.get(cursor)?;

            accumulated_changes
                .created
                .extend(node.changes.created.iter().cloned());
            accumulated_changes
                .updated
                .extend(node.changes.updated.iter().cloned());
            accumulated_changes
                .destroyed
                .extend(node.changes.destroyed.iter().cloned());

            cursor = node.parent_state.as_ref()?; // Move to parent.
        }

        // Determine status based on accumulated changes (note: order is important here!).
        if accumulated_changes.destroyed.contains(item_id) {
            Some(ChangeStatus::Deleted)
        } else if accumulated_changes.updated.contains(item_id)
            || accumulated_changes.created.contains(item_id)
        {
            Some(ChangeStatus::Changed)
        } else {
            Some(ChangeStatus::NotChanged)
        }
    }

    /// Add a state transition to the cache.
    ///
    /// Records that `new_state` was reached from `old_state` with the given changes.
    pub fn add_transition(&mut self, old_state: String, new_state: String, changes: StateChanges) {
        // If old_state is not in cache, add it as a node with no parent
        if !self.nodes.contains_key(&old_state) {
            self.nodes.insert(
                old_state.clone(),
                StateNode {
                    parent_state: None,
                    changes: StateChanges::new(),
                },
            );
        }

        // Add the new state node.
        self.nodes.insert(
            new_state.clone(),
            StateNode {
                parent_state: Some(old_state),
                changes,
            },
        );

        // Update current state.
        self.latest_state = Some(new_state);

        // TODO: Implement cache pruning to limit memory usage
    }

    /// Get the current known state, if any.
    pub fn latest_state(&self) -> Option<&str> {
        self.latest_state.as_deref()
    }
}

#[cfg(test)]
mod tests {
    use libjmap::ChangeStatus;

    use super::*;

    #[test]
    fn test_empty_cache() {
        let cache = StateCache::new();
        assert_eq!(cache.query_changes("state1", "item1"), None);
        assert_eq!(cache.latest_state(), None);
    }

    #[test]
    fn test_single_transition() {
        let mut cache = StateCache::new();

        let changes = StateChanges::updated("item1".to_string());
        cache.add_transition("state1".to_string(), "state2".to_string(), changes);

        assert_eq!(cache.latest_state(), Some("state2"));
        assert_eq!(
            cache.query_changes("state1", "item1"),
            Some(ChangeStatus::Changed)
        );
        assert_eq!(
            cache.query_changes("state1", "item2"),
            Some(ChangeStatus::NotChanged)
        );
    }

    #[test]
    fn test_multiple_transitions() {
        let mut cache = StateCache::new();

        // state1 -> state2: item1 updated.
        cache.add_transition(
            "state1".to_string(),
            "state2".to_string(),
            StateChanges::updated("item1".to_string()),
        );

        // state2 -> state3: item2 created.
        cache.add_transition(
            "state2".to_string(),
            "state3".to_string(),
            StateChanges::created("item2".to_string()),
        );

        // state3 -> state4: item1 destroyed.
        cache.add_transition(
            "state3".to_string(),
            "state4".to_string(),
            StateChanges::destroyed("item1".to_string()),
        );

        assert_eq!(cache.latest_state(), Some("state4"));

        // From state1 to state4: item1 was updated then destroyed.
        assert_eq!(
            cache.query_changes("state1", "item1"),
            Some(ChangeStatus::Deleted)
        );

        // From state1 to state4: item2 was created.
        assert_eq!(
            cache.query_changes("state1", "item2"),
            Some(ChangeStatus::Changed)
        );

        // From state2 to state4: item1 was destroyed.
        assert_eq!(
            cache.query_changes("state2", "item1"),
            Some(ChangeStatus::Deleted)
        );

        // From state2 to state4: item2 was created.
        assert_eq!(
            cache.query_changes("state2", "item2"),
            Some(ChangeStatus::Changed)
        );

        // From state2 to state4: item1 (from state2, before it was updated) should show as deleted
        // because the update happened in state1->state2 transition.
        assert_eq!(
            cache.query_changes("state2", "item1"),
            Some(ChangeStatus::Deleted)
        );

        // From state1 to state4: item3 (never touched) should be NotChanged.
        assert_eq!(
            cache.query_changes("state1", "item3"),
            Some(ChangeStatus::NotChanged)
        );
    }

    #[test]
    fn test_same_state() {
        let mut cache = StateCache::new();

        cache.add_transition(
            "state1".to_string(),
            "state2".to_string(),
            StateChanges::updated("item1".to_string()),
        );

        // Querying from current state to itself should return NotChanged.
        assert_eq!(
            cache.query_changes("state2", "item1"),
            Some(ChangeStatus::NotChanged)
        );
    }
}