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 leaf_key(&self) -> Option<&str> {
50 self.0.last().map(|segment| segment.key.as_str())
51 }
52
53 #[must_use]
54 pub fn is_within(&self, ancestor: &Self) -> bool {
55 self.0.starts_with(&ancestor.0)
56 }
57
58 pub(crate) fn single_root_key(&self, component: &str) -> Option<&str> {
59 (self.0.len() == 1 && self.0[0].component == component).then_some(self.0[0].key.as_str())
60 }
61}
62
63impl fmt::Display for ComponentInstancePath {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 for segment in &self.0 {
66 write!(formatter, "/{}[{}]", segment.component, segment.key)?;
67 }
68 Ok(())
69 }
70}
71
72pub(crate) fn topmost_paths(paths: &BTreeSet<ComponentInstancePath>) -> Vec<ComponentInstancePath> {
73 let mut roots = Vec::<ComponentInstancePath>::new();
74 for path in paths {
77 if roots.last().is_none_or(|root| !path.is_within(root)) {
78 roots.push(path.clone());
79 }
80 }
81 roots
82}
83
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct StateField {
86 pub schema: ValueSchema,
87 pub default: UiValue,
88 #[serde(default)]
89 pub sensitive: bool,
90}
91
92impl StateField {
93 #[must_use]
94 pub fn new(schema: ValueSchema, default: UiValue) -> Self {
95 Self {
96 schema,
97 default,
98 sensitive: false,
99 }
100 }
101
102 #[must_use]
103 pub const fn sensitive(mut self, sensitive: bool) -> Self {
104 self.sensitive = sensitive;
105 self
106 }
107}
108
109#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
110pub struct ComponentStateSchema {
111 fields: BTreeMap<String, StateField>,
112}
113
114impl ComponentStateSchema {
115 pub fn new(fields: BTreeMap<String, StateField>) -> Result<Self, StateError> {
122 for (name, field) in &fields {
123 field
124 .schema
125 .validate_definition()
126 .map_err(|source| StateError::InvalidSchema {
127 field: name.clone(),
128 source,
129 })?;
130 field
131 .schema
132 .validate_ui_value(&field.default)
133 .map_err(|source| StateError::InvalidDefault {
134 field: name.clone(),
135 source,
136 })?;
137 }
138 Ok(Self { fields })
139 }
140
141 #[must_use]
142 pub fn is_empty(&self) -> bool {
143 self.fields.is_empty()
144 }
145
146 #[must_use]
147 pub fn field(&self, name: &str) -> Option<&StateField> {
148 self.fields.get(name)
149 }
150
151 #[must_use]
152 pub fn fields(&self) -> &BTreeMap<String, StateField> {
153 &self.fields
154 }
155
156 fn defaults(&self) -> BTreeMap<String, UiValue> {
157 self.fields
158 .iter()
159 .map(|(name, field)| (name.clone(), field.default.clone()))
160 .collect()
161 }
162}
163
164#[derive(Clone, Debug, Default)]
165pub struct StateStore {
166 instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
167}
168
169#[derive(Clone, Debug)]
170struct ComponentState {
171 schema: Rc<ComponentStateSchema>,
172 values: Rc<BTreeMap<String, UiValue>>,
173}
174
175impl StateStore {
176 #[must_use]
177 pub fn new() -> Self {
178 Self::default()
179 }
180
181 #[must_use]
187 pub fn begin_render(&self) -> RenderStateTransaction {
188 RenderStateTransaction {
189 instances: Rc::clone(&self.instances),
190 seen: BTreeSet::new(),
191 scope: None,
192 }
193 }
194
195 #[must_use]
198 pub fn begin_render_scope(&self, scope: ComponentInstancePath) -> RenderStateTransaction {
199 RenderStateTransaction {
200 instances: Rc::clone(&self.instances),
201 seen: BTreeSet::new(),
202 scope: Some(scope),
203 }
204 }
205
206 pub fn commit_render(&mut self, transaction: RenderStateTransaction) {
207 let active = transaction.seen.clone();
208 self.commit_render_batch([transaction], &active);
209 }
210
211 pub(crate) fn commit_render_batch(
212 &mut self,
213 transactions: impl IntoIterator<Item = RenderStateTransaction>,
214 active: &BTreeSet<ComponentInstancePath>,
215 ) {
216 let candidate = Rc::make_mut(&mut self.instances);
217 let mut scopes = BTreeSet::new();
218 let mut global = false;
219 for transaction in transactions {
220 for path in &transaction.seen {
221 if transaction
222 .scope
223 .as_ref()
224 .is_none_or(|scope| path.is_within(scope))
225 && let Some(state) = transaction.instances.get(path)
226 {
227 candidate.insert(path.clone(), state.clone());
228 }
229 }
230 if let Some(scope) = transaction.scope {
231 scopes.insert(scope);
232 } else {
233 global = true;
234 }
235 }
236 let scopes = topmost_paths(&scopes).into_iter().collect::<BTreeSet<_>>();
237 candidate.retain(|path, _| {
238 let covered = global
239 || scopes
240 .range((std::ops::Bound::Unbounded, std::ops::Bound::Included(path)))
241 .next_back()
242 .is_some_and(|scope| path.is_within(scope));
243 !covered || active.contains(path)
244 });
245 }
246
247 pub fn remove_scope(&mut self, scope: &ComponentInstancePath) {
249 Rc::make_mut(&mut self.instances).retain(|path, _| !path.is_within(scope));
250 }
251
252 pub fn mount_instance(
259 &mut self,
260 path: ComponentInstancePath,
261 schema: &ComponentStateSchema,
262 ) -> Result<StateReconcileReport, StateError> {
263 let report = reconcile_instance(Rc::make_mut(&mut self.instances), path, schema)?;
264 Ok(report)
265 }
266
267 #[must_use]
268 pub fn get(&self, path: &ComponentInstancePath, field: &str) -> Option<&UiValue> {
269 self.instances.get(path)?.values.get(field)
270 }
271
272 pub fn set(
279 &mut self,
280 path: &ComponentInstancePath,
281 field: &str,
282 value: UiValue,
283 ) -> Result<bool, StateError> {
284 let state = Rc::make_mut(&mut self.instances)
285 .get_mut(path)
286 .ok_or_else(|| StateError::UnknownInstance(path.clone()))?;
287 let state_field = state
288 .schema
289 .field(field)
290 .ok_or_else(|| StateError::UnknownField {
291 path: path.clone(),
292 field: field.to_owned(),
293 })?;
294 state_field
295 .schema
296 .validate_ui_value(&value)
297 .map_err(|source| StateError::InvalidValue {
298 path: path.clone(),
299 field: field.to_owned(),
300 source,
301 })?;
302 if state.values.get(field) == Some(&value) {
303 return Ok(false);
304 }
305 Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
306 Ok(true)
307 }
308
309 #[must_use]
310 pub fn instance_count(&self) -> usize {
311 self.instances.len()
312 }
313
314 pub(crate) fn contains_instance(&self, path: &ComponentInstancePath) -> bool {
315 self.instances.contains_key(path)
316 }
317
318 #[must_use]
319 pub fn paths(&self) -> BTreeSet<ComponentInstancePath> {
320 self.instances.keys().cloned().collect()
321 }
322
323 #[must_use]
324 pub fn is_sensitive(&self, path: &ComponentInstancePath, field: &str) -> bool {
325 self.instances
326 .get(path)
327 .and_then(|state| state.schema.field(field))
328 .is_some_and(|field| field.sensitive)
329 }
330
331 #[must_use]
332 pub fn inspect(&self) -> Vec<StateInstanceSnapshot> {
333 self.instances
334 .iter()
335 .map(|(path, state)| state_instance_snapshot(path, state))
336 .collect()
337 }
338
339 #[must_use]
341 pub fn inspect_instance(&self, path: &ComponentInstancePath) -> Option<StateInstanceSnapshot> {
342 self.instances
343 .get(path)
344 .map(|state| state_instance_snapshot(path, state))
345 }
346}
347
348fn state_instance_snapshot(
349 path: &ComponentInstancePath,
350 state: &ComponentState,
351) -> StateInstanceSnapshot {
352 StateInstanceSnapshot {
353 path: path.clone(),
354 fields: state
355 .values
356 .iter()
357 .map(|(name, value)| {
358 let sensitive = state
359 .schema
360 .field(name)
361 .is_some_and(|field| field.sensitive);
362 (
363 name.clone(),
364 StateValueSnapshot {
365 value: if sensitive {
366 UiValue::String("<sensitive>".to_owned())
367 } else {
368 value.clone()
369 },
370 sensitive,
371 },
372 )
373 })
374 .collect(),
375 }
376}
377
378#[derive(Clone, Debug, PartialEq)]
379pub struct StateInstanceSnapshot {
380 pub path: ComponentInstancePath,
381 pub fields: BTreeMap<String, StateValueSnapshot>,
382}
383
384#[derive(Clone, Debug, PartialEq)]
385pub struct StateValueSnapshot {
386 pub value: UiValue,
387 pub sensitive: bool,
388}
389
390#[derive(Clone, Debug)]
391pub struct RenderStateTransaction {
392 instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
393 seen: BTreeSet<ComponentInstancePath>,
394 scope: Option<ComponentInstancePath>,
395}
396
397impl RenderStateTransaction {
398 pub(crate) fn retains_root(&self) -> bool {
399 self.scope
400 .as_ref()
401 .is_some_and(|root| self.seen.contains(root))
402 }
403 #[must_use]
405 pub fn retain_existing(&mut self, path: &ComponentInstancePath) -> bool {
406 if self.instances.contains_key(path) {
407 self.seen.insert(path.clone());
408 true
409 } else {
410 false
411 }
412 }
413
414 pub fn mount(
421 &mut self,
422 path: ComponentInstancePath,
423 schema: &ComponentStateSchema,
424 ) -> Result<StateReconcileReport, StateError> {
425 let report = reconcile_instance(Rc::make_mut(&mut self.instances), path.clone(), schema)?;
426 self.seen.insert(path);
427 Ok(report)
428 }
429}
430
431fn reconcile_instance(
432 instances: &mut BTreeMap<ComponentInstancePath, ComponentState>,
433 path: ComponentInstancePath,
434 schema: &ComponentStateSchema,
435) -> Result<StateReconcileReport, StateError> {
436 ComponentStateSchema::new(schema.fields.clone())?;
438
439 let next = match instances.get(&path) {
440 None => (
441 ComponentState {
442 schema: Rc::new(schema.clone()),
443 values: Rc::new(schema.defaults()),
444 },
445 StateReconcileReport {
446 created: true,
447 reset_fields: Vec::new(),
448 },
449 ),
450 Some(previous) => {
451 let mut reset_fields = Vec::new();
452 let values = schema
453 .fields
454 .iter()
455 .map(|(name, field)| {
456 let value = previous
457 .values
458 .get(name)
459 .filter(|value| field.schema.validate_ui_value(value).is_ok())
460 .cloned()
461 .unwrap_or_else(|| {
462 if previous.values.contains_key(name) {
463 reset_fields.push(name.clone());
464 }
465 field.default.clone()
466 });
467 (name.clone(), value)
468 })
469 .collect();
470 (
471 ComponentState {
472 schema: Rc::new(schema.clone()),
473 values: Rc::new(values),
474 },
475 StateReconcileReport {
476 created: false,
477 reset_fields,
478 },
479 )
480 }
481 };
482 instances.insert(path, next.0);
483 Ok(next.1)
484}
485
486#[derive(Clone, Debug, Default, Eq, PartialEq)]
487pub struct StateReconcileReport {
488 pub created: bool,
489 pub reset_fields: Vec<String>,
490}
491
492#[derive(Clone, Debug, Error, Eq, PartialEq)]
493pub enum StateError {
494 #[error("schema for state field `{field}` is invalid: {source}")]
495 InvalidSchema {
496 field: String,
497 source: SchemaDefinitionError,
498 },
499 #[error("default for state field `{field}` is invalid: {source}")]
500 InvalidDefault {
501 field: String,
502 source: SchemaValidationError,
503 },
504 #[error("component state instance `{0}` does not exist")]
505 UnknownInstance(ComponentInstancePath),
506 #[error("state field `{field}` is not declared for component `{path}`")]
507 UnknownField {
508 path: ComponentInstancePath,
509 field: String,
510 },
511 #[error("state value for `{path}.{field}` is invalid: {source}")]
512 InvalidValue {
513 path: ComponentInstancePath,
514 field: String,
515 source: SchemaValidationError,
516 },
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn scoped_candidates_preserve_latest_outside_writes_and_deletions() {
525 let first = ComponentInstancePath::root("View", "first");
526 let second = ComponentInstancePath::root("View", "second");
527 let removed = second.child("Counter", "gone");
528 let schema = ComponentStateSchema::new(BTreeMap::from([(
529 "count".into(),
530 StateField::new(ValueSchema::integer(), UiValue::Integer(7)),
531 )]))
532 .unwrap();
533 let mut states = StateStore::new();
534 let mut initial = states.begin_render();
535 for path in [&first, &second, &removed] {
536 initial.mount(path.clone(), &schema).unwrap();
537 }
538 states.commit_render(initial);
539 let mut candidate = states.begin_render_scope(first.clone());
540 candidate.mount(first.clone(), &schema).unwrap();
541 states.set(&second, "count", UiValue::Integer(9)).unwrap();
542 states.remove_scope(&removed);
543 states.commit_render(candidate);
544 assert_eq!(states.get(&second, "count"), Some(&UiValue::Integer(9)));
545 assert!(states.get(&removed, "count").is_none());
546 }
547
548 fn schema(value_schema: ValueSchema, default: UiValue) -> ComponentStateSchema {
549 ComponentStateSchema::new(BTreeMap::from([(
550 "value".to_owned(),
551 StateField::new(value_schema, default),
552 )]))
553 .unwrap()
554 }
555
556 #[test]
557 fn failed_render_does_not_change_committed_state() {
558 let path = ComponentInstancePath::root("App", "root").child("Combobox", "country");
559 let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
560 let mut store = StateStore::new();
561
562 let mut initial = store.begin_render();
563 initial.mount(path.clone(), &bool_schema).unwrap();
564 store.commit_render(initial);
565 store.set(&path, "value", UiValue::Bool(true)).unwrap();
566
567 let mut failed = store.begin_render();
568 failed
569 .mount(
570 path.clone(),
571 &schema(
572 ValueSchema::enumeration(["new"]),
573 UiValue::String("new".to_owned()),
574 ),
575 )
576 .unwrap();
577 drop(failed);
578
579 assert_eq!(store.get(&path, "value"), Some(&UiValue::Bool(true)));
580 }
581
582 #[test]
583 fn successful_render_resets_only_incompatible_fields() {
584 let path = ComponentInstancePath::root("App", "root");
585 let mut store = StateStore::new();
586 let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
587 let mut initial = store.begin_render();
588 initial.mount(path.clone(), &bool_schema).unwrap();
589 store.commit_render(initial);
590 store.set(&path, "value", UiValue::Bool(true)).unwrap();
591
592 let string_schema = schema(ValueSchema::string(), UiValue::String("reset".to_owned()));
593 let mut reload = store.begin_render();
594 let report = reload.mount(path.clone(), &string_schema).unwrap();
595 store.commit_render(reload);
596
597 assert_eq!(report.reset_fields, vec!["value"]);
598 assert_eq!(
599 store.get(&path, "value"),
600 Some(&UiValue::String("reset".to_owned()))
601 );
602 }
603
604 #[test]
605 fn inspect_instance_is_exact_and_redacts_sensitive_values() {
606 let selected = ComponentInstancePath::root("App", "root").child("Panel", "selected");
607 let unrelated = ComponentInstancePath::root("App", "root").child("Panel", "other");
608 let schema = ComponentStateSchema::new(BTreeMap::from([(
609 "token".to_owned(),
610 StateField::new(ValueSchema::string(), UiValue::String("secret".to_owned()))
611 .sensitive(true),
612 )]))
613 .unwrap();
614 let mut store = StateStore::new();
615 let mut render = store.begin_render();
616 render.mount(selected.clone(), &schema).unwrap();
617 render.mount(unrelated, &schema).unwrap();
618 store.commit_render(render);
619
620 let snapshot = store.inspect_instance(&selected).unwrap();
621 assert_eq!(snapshot.path, selected);
622 assert_eq!(
623 snapshot.fields["token"].value,
624 UiValue::String("<sensitive>".to_owned())
625 );
626 assert!(snapshot.fields["token"].sensitive);
627 assert!(
628 store
629 .inspect_instance(&ComponentInstancePath::root("missing", "root"))
630 .is_none()
631 );
632 }
633
634 #[test]
635 fn successful_render_cleans_unreachable_instances() {
636 let first = ComponentInstancePath::root("App", "root").child("Row", "first");
637 let second = ComponentInstancePath::root("App", "root").child("Row", "second");
638 let state_schema = schema(ValueSchema::integer(), UiValue::Integer(0));
639 let mut store = StateStore::new();
640 let mut initial = store.begin_render();
641 initial.mount(first.clone(), &state_schema).unwrap();
642 initial.mount(second, &state_schema).unwrap();
643 store.commit_render(initial);
644 assert_eq!(store.instance_count(), 2);
645
646 let mut next = store.begin_render();
647 next.mount(first, &state_schema).unwrap();
648 store.commit_render(next);
649 assert_eq!(store.instance_count(), 1);
650 }
651
652 #[test]
653 fn snapshots_share_instances_until_one_store_mutates() {
654 let path = ComponentInstancePath::root("Counter", "one");
655 let schema = schema(ValueSchema::integer(), UiValue::Integer(0));
656 let mut store = StateStore::new();
657 store.mount_instance(path.clone(), &schema).unwrap();
658 let snapshot = store.clone();
659 assert!(Rc::ptr_eq(&store.instances, &snapshot.instances));
660 store.set(&path, "value", UiValue::Integer(4)).unwrap();
661 assert!(!Rc::ptr_eq(&store.instances, &snapshot.instances));
662 assert_eq!(snapshot.get(&path, "value"), Some(&UiValue::Integer(0)));
663 }
664
665 #[test]
666 fn updates_are_schema_checked() {
667 let path = ComponentInstancePath::root("App", "root");
668 let state_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
669 let mut store = StateStore::new();
670 let mut render = store.begin_render();
671 render.mount(path.clone(), &state_schema).unwrap();
672 store.commit_render(render);
673
674 assert!(matches!(
675 store.set(&path, "value", UiValue::String("no".to_owned())),
676 Err(StateError::InvalidValue { .. })
677 ));
678 }
679
680 #[test]
681 fn scoped_commit_preserves_other_window_roots_and_remove_scope_cleans_one() {
682 let state_schema = schema(ValueSchema::string(), UiValue::String("ready".to_owned()));
683 let main = ComponentInstancePath::root("App", "main");
684 let settings = ComponentInstancePath::root("App", "settings");
685 let mut store = StateStore::new();
686 let mut initial = store.begin_render();
687 initial.mount(main.clone(), &state_schema).unwrap();
688 initial.mount(settings.clone(), &state_schema).unwrap();
689 store.commit_render(initial);
690
691 let mut main_render = store.begin_render_scope(main.clone());
692 main_render.mount(main.clone(), &state_schema).unwrap();
693 store.commit_render(main_render);
694 assert!(store.get(&settings, "value").is_some());
695
696 store.remove_scope(&main);
697 assert!(store.get(&main, "value").is_none());
698 assert!(store.get(&settings, "value").is_some());
699 }
700}