incular_core/restoration.rs
1//! The renderer-independent boundary used by opt-in state restoration.
2//!
3//! `incular-runtime` owns restoration hierarchy, persistence, diagnostics,
4//! and duplicate-scope detection. Lower-level crates use this small boundary
5//! to restore and update JSON values without depending on the runtime.
6
7use std::{fmt, rc::Rc};
8
9use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
10use serde_json::Value;
11
12/// A stable, application-provided segment in a restoration path.
13///
14/// A key is a segment, not a pre-concatenated path. In particular, a slash is
15/// ordinary key content rather than an implicit hierarchy separator; runtime
16/// stores paths structurally and applies its deterministic escaping only when
17/// it needs a textual representation.
18#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub struct RestorationKey(String);
20
21impl RestorationKey {
22 /// Creates one stable restoration path segment.
23 pub fn new(segment: impl Into<String>) -> Result<Self, RestorationKeyError> {
24 let segment = segment.into();
25 if segment.is_empty() {
26 return Err(RestorationKeyError::Empty);
27 }
28 if segment.contains('\0') {
29 return Err(RestorationKeyError::ContainsNul);
30 }
31 Ok(Self(segment))
32 }
33
34 /// Borrows the application-provided segment.
35 #[must_use]
36 pub fn as_str(&self) -> &str {
37 &self.0
38 }
39
40 /// Consumes this key into its application-provided segment.
41 #[must_use]
42 pub fn into_inner(self) -> String {
43 self.0
44 }
45}
46
47impl TryFrom<String> for RestorationKey {
48 type Error = RestorationKeyError;
49
50 fn try_from(value: String) -> Result<Self, Self::Error> {
51 Self::new(value)
52 }
53}
54
55impl TryFrom<&str> for RestorationKey {
56 type Error = RestorationKeyError;
57
58 fn try_from(value: &str) -> Result<Self, Self::Error> {
59 Self::new(value)
60 }
61}
62
63impl fmt::Display for RestorationKey {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str(self.as_str())
66 }
67}
68
69impl Serialize for RestorationKey {
70 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71 where
72 S: Serializer,
73 {
74 self.0.serialize(serializer)
75 }
76}
77
78impl<'de> Deserialize<'de> for RestorationKey {
79 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80 where
81 D: Deserializer<'de>,
82 {
83 String::deserialize(deserializer)
84 .and_then(|segment| Self::new(segment).map_err(de::Error::custom))
85 }
86}
87
88/// Why a [`RestorationKey`] could not be created.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
90pub enum RestorationKeyError {
91 /// A path segment must identify something explicitly.
92 #[error("a restoration key cannot be empty")]
93 Empty,
94 /// NUL is rejected so a key remains suitable for portable storage paths.
95 #[error("a restoration key cannot contain NUL")]
96 ContainsNul,
97}
98
99/// Raw JSON storage implemented by the runtime's restoration manager.
100///
101/// Implementations are UI-thread-affine by design. They update in-memory
102/// state synchronously and are responsible for coalescing any asynchronous
103/// persistence work. Paths are always structured key segments, avoiding
104/// ambiguous string concatenation.
105pub trait RestorationBackend {
106 /// Reads a value at the fully-qualified path.
107 fn read_value(&self, path: &[RestorationKey]) -> Option<Value>;
108
109 /// Replaces a value at the fully-qualified path.
110 fn write_value(&self, path: &[RestorationKey], value: Value);
111
112 /// Removes a value at the fully-qualified path.
113 fn remove_value(&self, path: &[RestorationKey]);
114
115 /// Records restoration recovery outcomes observed outside the runtime
116 /// (scroll offsets, type-incompatible values). The default implementation
117 /// ignores them so test backends stay trivial; the runtime counts
118 /// `restored` as recovered positions and `invalid` as stale values.
119 fn note_restoration_outcome(&self, _restored: u64, _invalid: u64) {}
120}
121
122/// A restoration location supplied by the runtime to an opt-in state owner.
123///
124/// Application code normally receives scopes from runtime build APIs rather
125/// than constructing them. `new` remains public so embedded runtimes and
126/// deterministic in-memory tests can provide their own backend.
127#[derive(Clone)]
128pub struct RestorationScope {
129 backend: Rc<dyn RestorationBackend>,
130 path: Vec<RestorationKey>,
131}
132
133impl fmt::Debug for RestorationScope {
134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135 formatter
136 .debug_struct("RestorationScope")
137 .field("path", &self.path)
138 .finish_non_exhaustive()
139 }
140}
141
142impl RestorationScope {
143 /// Creates the application-root restoration scope for a backend.
144 #[must_use]
145 pub fn root(backend: Rc<dyn RestorationBackend>) -> Self {
146 Self {
147 backend,
148 path: Vec::new(),
149 }
150 }
151
152 /// Creates a scope at an already-validated, fully-qualified path.
153 #[must_use]
154 pub fn new(
155 backend: Rc<dyn RestorationBackend>,
156 path: impl IntoIterator<Item = RestorationKey>,
157 ) -> Self {
158 Self {
159 backend,
160 path: path.into_iter().collect(),
161 }
162 }
163
164 /// Extends this scope with one already-registered stable child segment.
165 ///
166 /// Runtime is responsible for registering and checking sibling scope
167 /// identities before it exposes this scope. This method deliberately does
168 /// no implicit child numbering or position-based identity assignment.
169 #[must_use]
170 pub fn child_unchecked(&self, key: RestorationKey) -> Self {
171 let mut path = self.path.clone();
172 path.push(key);
173 Self {
174 backend: self.backend.clone(),
175 path,
176 }
177 }
178
179 /// Extends this scope with one stable child segment.
180 ///
181 /// This is pure namespacing: it performs no duplicate-scope validation.
182 /// The runtime claims window scopes automatically, but application-owned
183 /// dynamic scopes that must be unique while mounted should additionally
184 /// hold a runtime scope claim (see `RestorationHandle::claim_scope`);
185 /// two live writers to one path otherwise collide last-write-wins.
186 #[must_use]
187 pub fn child(&self, key: RestorationKey) -> Self {
188 self.child_unchecked(key)
189 }
190
191 #[must_use]
192 pub fn is_root(&self) -> bool {
193 self.path.is_empty()
194 }
195
196 /// Returns the stable hierarchical path owned by this scope.
197 #[must_use]
198 pub fn path(&self) -> &[RestorationKey] {
199 &self.path
200 }
201
202 /// Reads the JSON value stored under a stable value key.
203 #[must_use]
204 pub fn get_json(&self, key: &RestorationKey) -> Option<Value> {
205 self.backend.read_value(&self.value_path(key))
206 }
207
208 /// Stores a JSON value under a stable value key.
209 ///
210 /// This only updates the runtime manager's in-memory snapshot. Its
211 /// persistence policy determines when an asynchronous disk save occurs.
212 pub fn set_json(&self, key: &RestorationKey, value: Value) {
213 self.backend.write_value(&self.value_path(key), value);
214 }
215
216 /// Removes the JSON value stored under a stable value key.
217 pub fn remove(&self, key: &RestorationKey) {
218 self.backend.remove_value(&self.value_path(key));
219 }
220
221 /// Forwards a recovery outcome to the backend. Scroll restoration and
222 /// typed-value fallbacks report here; backends that do not count outcomes
223 /// ignore the call.
224 pub fn note_restoration_outcome(&self, restored: u64, invalid: u64) {
225 self.backend.note_restoration_outcome(restored, invalid);
226 }
227
228 fn value_path(&self, key: &RestorationKey) -> Vec<RestorationKey> {
229 let mut path = self.path.clone();
230 path.push(key.clone());
231 path
232 }
233}