solverforge_core/
entity_context.rs

1//! Thread-local context for canonical entity deduplication during deserialization.
2//!
3//! When deserializing a solution from JSON, planning list variables contain copies
4//! of entities rather than references to canonical entities from value range providers.
5//! This causes WASM pointer mismatches that break Timefold's entity tracking.
6//!
7//! This module provides a thread-local registry that:
8//! 1. Registers canonical entities (from value range providers) during Phase 1
9//! 2. Allows lookup of canonical entities by (type, planning_id) during Phase 2
10//!
11//! The `EntityContextGuard` ensures proper cleanup via RAII.
12
13use std::cell::RefCell;
14use std::collections::HashMap;
15
16use crate::Value;
17
18thread_local! {
19    static REGISTRY: RefCell<Option<HashMap<(String, String), Value>>> = const { RefCell::new(None) };
20}
21
22/// RAII guard that initializes and cleans up the entity context.
23///
24/// Create this at the start of deserialization; it will automatically
25/// clean up the registry when dropped.
26pub struct EntityContextGuard;
27
28impl EntityContextGuard {
29    /// Creates a new context guard, initializing the thread-local registry.
30    pub fn new() -> Self {
31        REGISTRY.with(|r| *r.borrow_mut() = Some(HashMap::new()));
32        Self
33    }
34}
35
36impl Default for EntityContextGuard {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl Drop for EntityContextGuard {
43    fn drop(&mut self) {
44        REGISTRY.with(|r| *r.borrow_mut() = None);
45    }
46}
47
48/// Registers a canonical entity in the thread-local registry.
49///
50/// Call this during Phase 1 (value range provider deserialization) to register
51/// the canonical version of each entity.
52///
53/// # Arguments
54/// * `type_name` - The entity type name (e.g., "Visit", "Employee")
55/// * `planning_id` - The entity's planning ID value
56/// * `entity` - The canonical entity Value to store
57pub fn register_canonical(type_name: &str, planning_id: &Value, entity: Value) {
58    REGISTRY.with(|r| {
59        if let Some(ref mut map) = *r.borrow_mut() {
60            map.insert((type_name.to_string(), id_to_string(planning_id)), entity);
61        }
62    });
63}
64
65/// Looks up a canonical entity by type and planning ID.
66///
67/// Call this during Phase 2 (entity collection deserialization) to resolve
68/// planning list variable elements to their canonical versions.
69///
70/// # Arguments
71/// * `type_name` - The entity type name to look up
72/// * `planning_id` - The planning ID to look up
73///
74/// # Returns
75/// The canonical entity Value if found, None otherwise
76pub fn lookup_canonical(type_name: &str, planning_id: &Value) -> Option<Value> {
77    REGISTRY.with(|r| {
78        r.borrow().as_ref().and_then(|map| {
79            map.get(&(type_name.to_string(), id_to_string(planning_id)))
80                .cloned()
81        })
82    })
83}
84
85/// Converts a planning ID Value to a string key for the registry.
86fn id_to_string(id: &Value) -> String {
87    match id {
88        Value::String(s) => s.clone(),
89        Value::Int(i) => i.to_string(),
90        Value::Float(f) => f.to_string(),
91        Value::Decimal(d) => d.to_string(),
92        Value::Bool(b) => b.to_string(),
93        _ => format!("{:?}", id),
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_register_and_lookup() {
103        let _guard = EntityContextGuard::new();
104
105        let entity = Value::Object(
106            [("id".to_string(), Value::String("e1".to_string()))]
107                .into_iter()
108                .collect(),
109        );
110        let id = Value::String("e1".to_string());
111
112        register_canonical("TestEntity", &id, entity.clone());
113
114        let found = lookup_canonical("TestEntity", &id);
115        assert!(found.is_some());
116        assert_eq!(found.unwrap(), entity);
117    }
118
119    #[test]
120    fn test_lookup_without_context_returns_none() {
121        let id = Value::String("e1".to_string());
122        let found = lookup_canonical("TestEntity", &id);
123        assert!(found.is_none());
124    }
125
126    #[test]
127    fn test_lookup_wrong_type_returns_none() {
128        let _guard = EntityContextGuard::new();
129
130        let entity = Value::Object(
131            [("id".to_string(), Value::String("e1".to_string()))]
132                .into_iter()
133                .collect(),
134        );
135        let id = Value::String("e1".to_string());
136
137        register_canonical("TypeA", &id, entity);
138
139        let found = lookup_canonical("TypeB", &id);
140        assert!(found.is_none());
141    }
142
143    #[test]
144    fn test_context_cleanup_on_drop() {
145        let id = Value::String("e1".to_string());
146        let entity = Value::Object(
147            [("id".to_string(), Value::String("e1".to_string()))]
148                .into_iter()
149                .collect(),
150        );
151
152        {
153            let _guard = EntityContextGuard::new();
154            register_canonical("TestEntity", &id, entity);
155            assert!(lookup_canonical("TestEntity", &id).is_some());
156        }
157
158        // After guard is dropped, lookup should return None
159        assert!(lookup_canonical("TestEntity", &id).is_none());
160    }
161
162    #[test]
163    fn test_int_id() {
164        let _guard = EntityContextGuard::new();
165
166        let entity = Value::Object([("id".to_string(), Value::Int(42))].into_iter().collect());
167        let id = Value::Int(42);
168
169        register_canonical("TestEntity", &id, entity.clone());
170
171        let found = lookup_canonical("TestEntity", &id);
172        assert!(found.is_some());
173        assert_eq!(found.unwrap(), entity);
174    }
175
176    #[test]
177    fn test_large_int_id() {
178        let _guard = EntityContextGuard::new();
179
180        let entity = Value::Object(
181            [("id".to_string(), Value::Int(123456789))]
182                .into_iter()
183                .collect(),
184        );
185        let id = Value::Int(123456789);
186
187        register_canonical("TestEntity", &id, entity.clone());
188
189        let found = lookup_canonical("TestEntity", &id);
190        assert!(found.is_some());
191        assert_eq!(found.unwrap(), entity);
192    }
193}