tirea_state/
runtime.rs

1//! Sealed state: an ephemeral, non-persistent key/value container.
2//!
3//! `SealedState` stores data with set-once semantics — each key can be
4//! written exactly once, then becomes immutable for the container's lifetime.
5//!
6//! Sensitive keys are tracked separately and redacted in `Debug` output.
7//! `Serialize` is intentionally **not** implemented to prevent accidental persistence.
8
9use crate::state::{PatchSink, State};
10use crate::{get_at_path, parse_path, DocCell, Path};
11use serde_json::Value;
12use std::collections::HashSet;
13use thiserror::Error;
14
15/// Errors from `SealedState` operations.
16#[derive(Debug, Error)]
17pub enum SealedStateError {
18    /// Attempted to set a key that was already set.
19    #[error("sealed state key already set: {0}")]
20    AlreadySet(String),
21    /// JSON serialization failed.
22    #[error("sealed state serialization error: {0}")]
23    SerializationError(String),
24}
25
26/// Sealed key/value container with set-once semantics.
27///
28/// Each key can be written exactly once via `put_once()` or `put_sensitive_once()`.
29/// After that, the key is immutable for the container's lifetime.
30///
31/// Consumers generally receive `&SealedState` (read-only), so the Rust borrow checker
32/// guarantees no writes occur during execution.
33///
34/// # Sensitive keys
35///
36/// Keys set via `set_sensitive()` are redacted in `Debug` output.
37/// Use this for tokens, secrets, and other credentials.
38///
39/// # No `Serialize`
40///
41/// `SealedState` intentionally does **not** implement `Serialize`,
42/// preventing accidental persistence. This is enforced at compile time.
43#[derive(Clone)]
44pub struct SealedState {
45    doc: Value,
46    doc_cell: DocCell,
47    sensitive_keys: HashSet<String>,
48}
49
50impl SealedState {
51    /// Create an empty sealed state.
52    pub fn new() -> Self {
53        let doc = Value::Object(Default::default());
54        Self {
55            doc_cell: DocCell::new(doc.clone()),
56            doc,
57            sensitive_keys: HashSet::new(),
58        }
59    }
60
61    /// Set a key once. Returns error if key already exists.
62    pub fn put_once(
63        &mut self,
64        key: impl Into<String>,
65        value: impl serde::Serialize,
66    ) -> Result<(), SealedStateError> {
67        let key = key.into();
68        let obj = self
69            .doc
70            .as_object_mut()
71            .expect("sealed state doc is object");
72        if obj.contains_key(&key) {
73            return Err(SealedStateError::AlreadySet(key));
74        }
75        let v = serde_json::to_value(value)
76            .map_err(|e| SealedStateError::SerializationError(e.to_string()))?;
77        obj.insert(key, v);
78        // Keep doc_cell in sync for State trait reads
79        *self.doc_cell.get() = self.doc.clone();
80        Ok(())
81    }
82
83    /// Set a sensitive key once (redacted in `Debug`).
84    pub fn put_sensitive_once(
85        &mut self,
86        key: impl Into<String>,
87        value: impl serde::Serialize,
88    ) -> Result<(), SealedStateError> {
89        let key = key.into();
90        self.put_once(key.clone(), value)?;
91        self.sensitive_keys.insert(key);
92        Ok(())
93    }
94
95    /// Backward-compatible alias for `put_once`.
96    pub fn set(
97        &mut self,
98        key: impl Into<String>,
99        value: impl serde::Serialize,
100    ) -> Result<(), SealedStateError> {
101        self.put_once(key, value)
102    }
103
104    /// Backward-compatible alias for `put_sensitive_once`.
105    pub fn set_sensitive(
106        &mut self,
107        key: impl Into<String>,
108        value: impl serde::Serialize,
109    ) -> Result<(), SealedStateError> {
110        self.put_sensitive_once(key, value)
111    }
112
113    /// Get a typed state reference (same API as `ctx.state::<T>()`).
114    ///
115    /// Returns a read-only `StateRef` backed by the sealed document.
116    /// Any write through this ref will panic (read-only sink).
117    pub fn get<T: State>(&self) -> T::Ref<'_> {
118        T::state_ref(&self.doc_cell, Path::root(), PatchSink::read_only())
119    }
120
121    /// Get a typed state reference at a dot-separated path.
122    pub fn get_at<T: State>(&self, path: &str) -> T::Ref<'_> {
123        let base = parse_path(path);
124        T::state_ref(&self.doc_cell, base, PatchSink::read_only())
125    }
126
127    /// Get a raw JSON value by key.
128    pub fn value(&self, key: &str) -> Option<&Value> {
129        self.doc.as_object().and_then(|obj| obj.get(key))
130    }
131
132    /// Get a raw JSON value at a dot-separated path.
133    pub fn value_at(&self, path: &str) -> Option<&Value> {
134        if path.is_empty() {
135            return Some(&self.doc);
136        }
137        let p = parse_path(path);
138        get_at_path(&self.doc, &p)
139    }
140
141    /// Check if a key is marked sensitive.
142    pub fn is_sensitive(&self, key: &str) -> bool {
143        self.sensitive_keys.contains(key)
144    }
145
146    /// Check if the container has a key.
147    pub fn contains_key(&self, key: &str) -> bool {
148        self.doc
149            .as_object()
150            .is_some_and(|obj| obj.contains_key(key))
151    }
152}
153
154impl Default for SealedState {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl std::fmt::Debug for SealedState {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        let mut map = f.debug_map();
163        if let Some(obj) = self.doc.as_object() {
164            for (k, v) in obj {
165                if self.sensitive_keys.contains(k) {
166                    map.entry(k, &"[REDACTED]");
167                } else {
168                    map.entry(k, v);
169                }
170            }
171        }
172        map.finish()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180
181    #[test]
182    fn test_scope_new_is_empty() {
183        let rt = SealedState::new();
184        assert!(!rt.contains_key("anything"));
185        assert!(rt.value("anything").is_none());
186    }
187
188    #[test]
189    fn test_scope_default() {
190        let rt = SealedState::default();
191        assert!(!rt.contains_key("x"));
192    }
193
194    #[test]
195    fn test_set_once_success() {
196        let mut rt = SealedState::new();
197        rt.set("user_id", "u123").unwrap();
198        assert_eq!(rt.value("user_id"), Some(&json!("u123")));
199    }
200
201    #[test]
202    fn test_set_once_duplicate_fails() {
203        let mut rt = SealedState::new();
204        rt.set("key", "first").unwrap();
205        let err = rt.set("key", "second").unwrap_err();
206        assert!(matches!(err, SealedStateError::AlreadySet(k) if k == "key"));
207        // Value unchanged
208        assert_eq!(rt.value("key"), Some(&json!("first")));
209    }
210
211    #[test]
212    fn test_set_sensitive() {
213        let mut rt = SealedState::new();
214        rt.set_sensitive("token", "secret-abc").unwrap();
215        assert!(rt.is_sensitive("token"));
216        assert_eq!(rt.value("token"), Some(&json!("secret-abc")));
217    }
218
219    #[test]
220    fn test_set_sensitive_duplicate_fails() {
221        let mut rt = SealedState::new();
222        rt.set_sensitive("token", "first").unwrap();
223        let err = rt.set_sensitive("token", "second").unwrap_err();
224        assert!(matches!(err, SealedStateError::AlreadySet(_)));
225    }
226
227    #[test]
228    fn test_non_sensitive_key() {
229        let rt = SealedState::new();
230        assert!(!rt.is_sensitive("anything"));
231    }
232
233    #[test]
234    fn test_debug_redacts_sensitive() {
235        let mut rt = SealedState::new();
236        rt.set("user_id", "u123").unwrap();
237        rt.set_sensitive("token", "secret").unwrap();
238
239        let debug = format!("{:?}", rt);
240        assert!(debug.contains("u123"));
241        assert!(debug.contains("[REDACTED]"));
242        assert!(!debug.contains("secret"));
243    }
244
245    #[test]
246    fn test_clone() {
247        let mut rt = SealedState::new();
248        rt.set("a", 1).unwrap();
249        rt.set_sensitive("b", "secret").unwrap();
250
251        let rt2 = rt.clone();
252        assert_eq!(rt2.value("a"), Some(&json!(1)));
253        assert!(rt2.is_sensitive("b"));
254    }
255
256    #[test]
257    fn test_value_at_path() {
258        let mut rt = SealedState::new();
259        rt.set("config", json!({"nested": {"value": 42}})).unwrap();
260        assert_eq!(rt.value_at("config.nested.value"), Some(&json!(42)));
261        assert_eq!(rt.value_at("config.missing"), None);
262    }
263
264    #[test]
265    fn test_value_at_empty_path() {
266        let mut rt = SealedState::new();
267        rt.set("key", "val").unwrap();
268        // Empty path returns root doc
269        let root = rt.value_at("").unwrap();
270        assert!(root.is_object());
271    }
272
273    #[test]
274    fn test_set_various_types() {
275        let mut rt = SealedState::new();
276        rt.set("string", "hello").unwrap();
277        rt.set("number", 42).unwrap();
278        rt.set("bool", true).unwrap();
279        rt.set("array", vec![1, 2, 3]).unwrap();
280
281        assert_eq!(rt.value("string"), Some(&json!("hello")));
282        assert_eq!(rt.value("number"), Some(&json!(42)));
283        assert_eq!(rt.value("bool"), Some(&json!(true)));
284        assert_eq!(rt.value("array"), Some(&json!([1, 2, 3])));
285    }
286
287    #[test]
288    fn test_contains_key() {
289        let mut rt = SealedState::new();
290        assert!(!rt.contains_key("x"));
291        rt.set("x", 1).unwrap();
292        assert!(rt.contains_key("x"));
293    }
294}