1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::rc::Rc;
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::{SchemaDefinitionError, SchemaValidationError, UiValue, ValueSchema};
9
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11pub struct ComponentInstancePath(Vec<ComponentInstanceSegment>);
12
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
14struct ComponentInstanceSegment {
15 component: String,
16 key: String,
17}
18
19impl ComponentInstancePath {
20 #[must_use]
21 pub fn root(component: impl Into<String>, key: impl Into<String>) -> Self {
22 Self(vec![ComponentInstanceSegment {
23 component: component.into(),
24 key: key.into(),
25 }])
26 }
27
28 #[must_use]
29 pub fn child(&self, component: impl Into<String>, key: impl Into<String>) -> Self {
30 let mut segments = self.0.clone();
31 segments.push(ComponentInstanceSegment {
32 component: component.into(),
33 key: key.into(),
34 });
35 Self(segments)
36 }
37
38 #[must_use]
39 pub fn parent(&self) -> Option<Self> {
40 if self.0.len() <= 1 {
41 return None;
42 }
43 let mut segments = self.0.clone();
44 segments.pop();
45 Some(Self(segments))
46 }
47
48 #[must_use]
49 pub fn is_within(&self, ancestor: &Self) -> bool {
50 self.0.starts_with(&ancestor.0)
51 }
52
53 pub(crate) fn single_root_key(&self, component: &str) -> Option<&str> {
54 (self.0.len() == 1 && self.0[0].component == component).then_some(self.0[0].key.as_str())
55 }
56}
57
58impl fmt::Display for ComponentInstancePath {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 for segment in &self.0 {
61 write!(formatter, "/{}[{}]", segment.component, segment.key)?;
62 }
63 Ok(())
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct StateField {
69 pub schema: ValueSchema,
70 pub default: UiValue,
71 #[serde(default)]
72 pub sensitive: bool,
73}
74
75impl StateField {
76 #[must_use]
77 pub fn new(schema: ValueSchema, default: UiValue) -> Self {
78 Self {
79 schema,
80 default,
81 sensitive: false,
82 }
83 }
84
85 #[must_use]
86 pub const fn sensitive(mut self, sensitive: bool) -> Self {
87 self.sensitive = sensitive;
88 self
89 }
90}
91
92#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
93pub struct ComponentStateSchema {
94 fields: BTreeMap<String, StateField>,
95}
96
97impl ComponentStateSchema {
98 pub fn new(fields: BTreeMap<String, StateField>) -> Result<Self, StateError> {
105 for (name, field) in &fields {
106 field
107 .schema
108 .validate_definition()
109 .map_err(|source| StateError::InvalidSchema {
110 field: name.clone(),
111 source,
112 })?;
113 field
114 .schema
115 .validate_ui_value(&field.default)
116 .map_err(|source| StateError::InvalidDefault {
117 field: name.clone(),
118 source,
119 })?;
120 }
121 Ok(Self { fields })
122 }
123
124 #[must_use]
125 pub fn is_empty(&self) -> bool {
126 self.fields.is_empty()
127 }
128
129 #[must_use]
130 pub fn field(&self, name: &str) -> Option<&StateField> {
131 self.fields.get(name)
132 }
133
134 #[must_use]
135 pub fn fields(&self) -> &BTreeMap<String, StateField> {
136 &self.fields
137 }
138
139 fn defaults(&self) -> BTreeMap<String, UiValue> {
140 self.fields
141 .iter()
142 .map(|(name, field)| (name.clone(), field.default.clone()))
143 .collect()
144 }
145}
146
147#[derive(Clone, Debug, Default)]
148pub struct StateStore {
149 instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
150}
151
152#[derive(Clone, Debug)]
153struct ComponentState {
154 schema: Rc<ComponentStateSchema>,
155 values: Rc<BTreeMap<String, UiValue>>,
156}
157
158impl StateStore {
159 #[must_use]
160 pub fn new() -> Self {
161 Self::default()
162 }
163
164 #[must_use]
170 pub fn begin_render(&self) -> RenderStateTransaction {
171 RenderStateTransaction {
172 instances: Rc::clone(&self.instances),
173 seen: BTreeSet::new(),
174 scope: None,
175 }
176 }
177
178 #[must_use]
181 pub fn begin_render_scope(&self, scope: ComponentInstancePath) -> RenderStateTransaction {
182 RenderStateTransaction {
183 instances: Rc::clone(&self.instances),
184 seen: BTreeSet::new(),
185 scope: Some(scope),
186 }
187 }
188
189 pub fn commit_render(&mut self, mut transaction: RenderStateTransaction) {
190 Rc::make_mut(&mut transaction.instances).retain(|path, _| {
191 transaction.seen.contains(path)
192 || transaction
193 .scope
194 .as_ref()
195 .is_some_and(|scope| !path.is_within(scope))
196 });
197 self.instances = transaction.instances;
198 }
199
200 pub fn remove_scope(&mut self, scope: &ComponentInstancePath) {
202 Rc::make_mut(&mut self.instances).retain(|path, _| !path.is_within(scope));
203 }
204
205 pub fn mount_instance(
212 &mut self,
213 path: ComponentInstancePath,
214 schema: &ComponentStateSchema,
215 ) -> Result<StateReconcileReport, StateError> {
216 let report = reconcile_instance(Rc::make_mut(&mut self.instances), path, schema)?;
217 Ok(report)
218 }
219
220 #[must_use]
221 pub fn get(&self, path: &ComponentInstancePath, field: &str) -> Option<&UiValue> {
222 self.instances.get(path)?.values.get(field)
223 }
224
225 pub fn set(
232 &mut self,
233 path: &ComponentInstancePath,
234 field: &str,
235 value: UiValue,
236 ) -> Result<bool, StateError> {
237 let state = Rc::make_mut(&mut self.instances)
238 .get_mut(path)
239 .ok_or_else(|| StateError::UnknownInstance(path.clone()))?;
240 let state_field = state
241 .schema
242 .field(field)
243 .ok_or_else(|| StateError::UnknownField {
244 path: path.clone(),
245 field: field.to_owned(),
246 })?;
247 state_field
248 .schema
249 .validate_ui_value(&value)
250 .map_err(|source| StateError::InvalidValue {
251 path: path.clone(),
252 field: field.to_owned(),
253 source,
254 })?;
255 if state.values.get(field) == Some(&value) {
256 return Ok(false);
257 }
258 Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
259 Ok(true)
260 }
261
262 #[must_use]
263 pub fn instance_count(&self) -> usize {
264 self.instances.len()
265 }
266
267 pub(crate) fn contains_instance(&self, path: &ComponentInstancePath) -> bool {
268 self.instances.contains_key(path)
269 }
270
271 #[must_use]
272 pub fn paths(&self) -> BTreeSet<ComponentInstancePath> {
273 self.instances.keys().cloned().collect()
274 }
275
276 #[must_use]
277 pub fn is_sensitive(&self, path: &ComponentInstancePath, field: &str) -> bool {
278 self.instances
279 .get(path)
280 .and_then(|state| state.schema.field(field))
281 .is_some_and(|field| field.sensitive)
282 }
283
284 #[must_use]
285 pub fn inspect(&self) -> Vec<StateInstanceSnapshot> {
286 self.instances
287 .iter()
288 .map(|(path, state)| StateInstanceSnapshot {
289 path: path.clone(),
290 fields: state
291 .values
292 .iter()
293 .map(|(name, value)| {
294 let sensitive = state
295 .schema
296 .field(name)
297 .is_some_and(|field| field.sensitive);
298 (
299 name.clone(),
300 StateValueSnapshot {
301 value: if sensitive {
302 UiValue::String("<sensitive>".to_owned())
303 } else {
304 value.clone()
305 },
306 sensitive,
307 },
308 )
309 })
310 .collect(),
311 })
312 .collect()
313 }
314}
315
316#[derive(Clone, Debug, PartialEq)]
317pub struct StateInstanceSnapshot {
318 pub path: ComponentInstancePath,
319 pub fields: BTreeMap<String, StateValueSnapshot>,
320}
321
322#[derive(Clone, Debug, PartialEq)]
323pub struct StateValueSnapshot {
324 pub value: UiValue,
325 pub sensitive: bool,
326}
327
328#[derive(Clone, Debug)]
329pub struct RenderStateTransaction {
330 instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
331 seen: BTreeSet<ComponentInstancePath>,
332 scope: Option<ComponentInstancePath>,
333}
334
335impl RenderStateTransaction {
336 #[must_use]
338 pub fn retain_existing(&mut self, path: &ComponentInstancePath) -> bool {
339 if self.instances.contains_key(path) {
340 self.seen.insert(path.clone());
341 true
342 } else {
343 false
344 }
345 }
346
347 pub fn mount(
354 &mut self,
355 path: ComponentInstancePath,
356 schema: &ComponentStateSchema,
357 ) -> Result<StateReconcileReport, StateError> {
358 let report = reconcile_instance(Rc::make_mut(&mut self.instances), path.clone(), schema)?;
359 self.seen.insert(path);
360 Ok(report)
361 }
362}
363
364fn reconcile_instance(
365 instances: &mut BTreeMap<ComponentInstancePath, ComponentState>,
366 path: ComponentInstancePath,
367 schema: &ComponentStateSchema,
368) -> Result<StateReconcileReport, StateError> {
369 ComponentStateSchema::new(schema.fields.clone())?;
371
372 let next = match instances.get(&path) {
373 None => (
374 ComponentState {
375 schema: Rc::new(schema.clone()),
376 values: Rc::new(schema.defaults()),
377 },
378 StateReconcileReport {
379 created: true,
380 reset_fields: Vec::new(),
381 },
382 ),
383 Some(previous) => {
384 let mut reset_fields = Vec::new();
385 let values = schema
386 .fields
387 .iter()
388 .map(|(name, field)| {
389 let value = previous
390 .values
391 .get(name)
392 .filter(|value| field.schema.validate_ui_value(value).is_ok())
393 .cloned()
394 .unwrap_or_else(|| {
395 if previous.values.contains_key(name) {
396 reset_fields.push(name.clone());
397 }
398 field.default.clone()
399 });
400 (name.clone(), value)
401 })
402 .collect();
403 (
404 ComponentState {
405 schema: Rc::new(schema.clone()),
406 values: Rc::new(values),
407 },
408 StateReconcileReport {
409 created: false,
410 reset_fields,
411 },
412 )
413 }
414 };
415 instances.insert(path, next.0);
416 Ok(next.1)
417}
418
419#[derive(Clone, Debug, Default, Eq, PartialEq)]
420pub struct StateReconcileReport {
421 pub created: bool,
422 pub reset_fields: Vec<String>,
423}
424
425#[derive(Clone, Debug, Error, Eq, PartialEq)]
426pub enum StateError {
427 #[error("schema for state field `{field}` is invalid: {source}")]
428 InvalidSchema {
429 field: String,
430 source: SchemaDefinitionError,
431 },
432 #[error("default for state field `{field}` is invalid: {source}")]
433 InvalidDefault {
434 field: String,
435 source: SchemaValidationError,
436 },
437 #[error("component state instance `{0}` does not exist")]
438 UnknownInstance(ComponentInstancePath),
439 #[error("state field `{field}` is not declared for component `{path}`")]
440 UnknownField {
441 path: ComponentInstancePath,
442 field: String,
443 },
444 #[error("state value for `{path}.{field}` is invalid: {source}")]
445 InvalidValue {
446 path: ComponentInstancePath,
447 field: String,
448 source: SchemaValidationError,
449 },
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 fn schema(value_schema: ValueSchema, default: UiValue) -> ComponentStateSchema {
457 ComponentStateSchema::new(BTreeMap::from([(
458 "value".to_owned(),
459 StateField::new(value_schema, default),
460 )]))
461 .unwrap()
462 }
463
464 #[test]
465 fn failed_render_does_not_change_committed_state() {
466 let path = ComponentInstancePath::root("App", "root").child("Combobox", "country");
467 let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
468 let mut store = StateStore::new();
469
470 let mut initial = store.begin_render();
471 initial.mount(path.clone(), &bool_schema).unwrap();
472 store.commit_render(initial);
473 store.set(&path, "value", UiValue::Bool(true)).unwrap();
474
475 let mut failed = store.begin_render();
476 failed
477 .mount(
478 path.clone(),
479 &schema(
480 ValueSchema::enumeration(["new"]),
481 UiValue::String("new".to_owned()),
482 ),
483 )
484 .unwrap();
485 drop(failed);
486
487 assert_eq!(store.get(&path, "value"), Some(&UiValue::Bool(true)));
488 }
489
490 #[test]
491 fn successful_render_resets_only_incompatible_fields() {
492 let path = ComponentInstancePath::root("App", "root");
493 let mut store = StateStore::new();
494 let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
495 let mut initial = store.begin_render();
496 initial.mount(path.clone(), &bool_schema).unwrap();
497 store.commit_render(initial);
498 store.set(&path, "value", UiValue::Bool(true)).unwrap();
499
500 let string_schema = schema(ValueSchema::string(), UiValue::String("reset".to_owned()));
501 let mut reload = store.begin_render();
502 let report = reload.mount(path.clone(), &string_schema).unwrap();
503 store.commit_render(reload);
504
505 assert_eq!(report.reset_fields, vec!["value"]);
506 assert_eq!(
507 store.get(&path, "value"),
508 Some(&UiValue::String("reset".to_owned()))
509 );
510 }
511
512 #[test]
513 fn successful_render_cleans_unreachable_instances() {
514 let first = ComponentInstancePath::root("App", "root").child("Row", "first");
515 let second = ComponentInstancePath::root("App", "root").child("Row", "second");
516 let state_schema = schema(ValueSchema::integer(), UiValue::Integer(0));
517 let mut store = StateStore::new();
518 let mut initial = store.begin_render();
519 initial.mount(first.clone(), &state_schema).unwrap();
520 initial.mount(second, &state_schema).unwrap();
521 store.commit_render(initial);
522 assert_eq!(store.instance_count(), 2);
523
524 let mut next = store.begin_render();
525 next.mount(first, &state_schema).unwrap();
526 store.commit_render(next);
527 assert_eq!(store.instance_count(), 1);
528 }
529
530 #[test]
531 fn snapshots_share_instances_until_one_store_mutates() {
532 let path = ComponentInstancePath::root("Counter", "one");
533 let schema = schema(ValueSchema::integer(), UiValue::Integer(0));
534 let mut store = StateStore::new();
535 store.mount_instance(path.clone(), &schema).unwrap();
536 let snapshot = store.clone();
537 assert!(Rc::ptr_eq(&store.instances, &snapshot.instances));
538 store.set(&path, "value", UiValue::Integer(4)).unwrap();
539 assert!(!Rc::ptr_eq(&store.instances, &snapshot.instances));
540 assert_eq!(snapshot.get(&path, "value"), Some(&UiValue::Integer(0)));
541 }
542
543 #[test]
544 fn updates_are_schema_checked() {
545 let path = ComponentInstancePath::root("App", "root");
546 let state_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
547 let mut store = StateStore::new();
548 let mut render = store.begin_render();
549 render.mount(path.clone(), &state_schema).unwrap();
550 store.commit_render(render);
551
552 assert!(matches!(
553 store.set(&path, "value", UiValue::String("no".to_owned())),
554 Err(StateError::InvalidValue { .. })
555 ));
556 }
557
558 #[test]
559 fn scoped_commit_preserves_other_window_roots_and_remove_scope_cleans_one() {
560 let state_schema = schema(ValueSchema::string(), UiValue::String("ready".to_owned()));
561 let main = ComponentInstancePath::root("App", "main");
562 let settings = ComponentInstancePath::root("App", "settings");
563 let mut store = StateStore::new();
564 let mut initial = store.begin_render();
565 initial.mount(main.clone(), &state_schema).unwrap();
566 initial.mount(settings.clone(), &state_schema).unwrap();
567 store.commit_render(initial);
568
569 let mut main_render = store.begin_render_scope(main.clone());
570 main_render.mount(main.clone(), &state_schema).unwrap();
571 store.commit_render(main_render);
572 assert!(store.get(&settings, "value").is_some());
573
574 store.remove_scope(&main);
575 assert!(store.get(&main, "value").is_none());
576 assert!(store.get(&settings, "value").is_some());
577 }
578}