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
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct StateField {
74 pub schema: ValueSchema,
75 pub default: UiValue,
76 #[serde(default)]
77 pub sensitive: bool,
78}
79
80impl StateField {
81 #[must_use]
82 pub fn new(schema: ValueSchema, default: UiValue) -> Self {
83 Self {
84 schema,
85 default,
86 sensitive: false,
87 }
88 }
89
90 #[must_use]
91 pub const fn sensitive(mut self, sensitive: bool) -> Self {
92 self.sensitive = sensitive;
93 self
94 }
95}
96
97#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
98pub struct ComponentStateSchema {
99 fields: BTreeMap<String, StateField>,
100}
101
102impl ComponentStateSchema {
103 pub fn new(fields: BTreeMap<String, StateField>) -> Result<Self, StateError> {
110 for (name, field) in &fields {
111 field
112 .schema
113 .validate_definition()
114 .map_err(|source| StateError::InvalidSchema {
115 field: name.clone(),
116 source,
117 })?;
118 field
119 .schema
120 .validate_ui_value(&field.default)
121 .map_err(|source| StateError::InvalidDefault {
122 field: name.clone(),
123 source,
124 })?;
125 }
126 Ok(Self { fields })
127 }
128
129 #[must_use]
130 pub fn is_empty(&self) -> bool {
131 self.fields.is_empty()
132 }
133
134 #[must_use]
135 pub fn field(&self, name: &str) -> Option<&StateField> {
136 self.fields.get(name)
137 }
138
139 #[must_use]
140 pub fn fields(&self) -> &BTreeMap<String, StateField> {
141 &self.fields
142 }
143
144 fn defaults(&self) -> BTreeMap<String, UiValue> {
145 self.fields
146 .iter()
147 .map(|(name, field)| (name.clone(), field.default.clone()))
148 .collect()
149 }
150}
151
152#[derive(Clone, Debug, Default)]
153pub struct StateStore {
154 instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
155}
156
157#[derive(Clone, Debug)]
158struct ComponentState {
159 schema: Rc<ComponentStateSchema>,
160 values: Rc<BTreeMap<String, UiValue>>,
161}
162
163impl StateStore {
164 #[must_use]
165 pub fn new() -> Self {
166 Self::default()
167 }
168
169 #[must_use]
175 pub fn begin_render(&self) -> RenderStateTransaction {
176 RenderStateTransaction {
177 instances: Rc::clone(&self.instances),
178 seen: BTreeSet::new(),
179 scope: None,
180 }
181 }
182
183 #[must_use]
186 pub fn begin_render_scope(&self, scope: ComponentInstancePath) -> RenderStateTransaction {
187 RenderStateTransaction {
188 instances: Rc::clone(&self.instances),
189 seen: BTreeSet::new(),
190 scope: Some(scope),
191 }
192 }
193
194 pub fn commit_render(&mut self, mut transaction: RenderStateTransaction) {
195 Rc::make_mut(&mut transaction.instances).retain(|path, _| {
196 transaction.seen.contains(path)
197 || transaction
198 .scope
199 .as_ref()
200 .is_some_and(|scope| !path.is_within(scope))
201 });
202 self.instances = transaction.instances;
203 }
204
205 pub fn remove_scope(&mut self, scope: &ComponentInstancePath) {
207 Rc::make_mut(&mut self.instances).retain(|path, _| !path.is_within(scope));
208 }
209
210 pub fn mount_instance(
217 &mut self,
218 path: ComponentInstancePath,
219 schema: &ComponentStateSchema,
220 ) -> Result<StateReconcileReport, StateError> {
221 let report = reconcile_instance(Rc::make_mut(&mut self.instances), path, schema)?;
222 Ok(report)
223 }
224
225 #[must_use]
226 pub fn get(&self, path: &ComponentInstancePath, field: &str) -> Option<&UiValue> {
227 self.instances.get(path)?.values.get(field)
228 }
229
230 pub fn set(
237 &mut self,
238 path: &ComponentInstancePath,
239 field: &str,
240 value: UiValue,
241 ) -> Result<bool, StateError> {
242 let state = Rc::make_mut(&mut self.instances)
243 .get_mut(path)
244 .ok_or_else(|| StateError::UnknownInstance(path.clone()))?;
245 let state_field = state
246 .schema
247 .field(field)
248 .ok_or_else(|| StateError::UnknownField {
249 path: path.clone(),
250 field: field.to_owned(),
251 })?;
252 state_field
253 .schema
254 .validate_ui_value(&value)
255 .map_err(|source| StateError::InvalidValue {
256 path: path.clone(),
257 field: field.to_owned(),
258 source,
259 })?;
260 if state.values.get(field) == Some(&value) {
261 return Ok(false);
262 }
263 Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
264 Ok(true)
265 }
266
267 #[must_use]
268 pub fn instance_count(&self) -> usize {
269 self.instances.len()
270 }
271
272 pub(crate) fn contains_instance(&self, path: &ComponentInstancePath) -> bool {
273 self.instances.contains_key(path)
274 }
275
276 #[must_use]
277 pub fn paths(&self) -> BTreeSet<ComponentInstancePath> {
278 self.instances.keys().cloned().collect()
279 }
280
281 #[must_use]
282 pub fn is_sensitive(&self, path: &ComponentInstancePath, field: &str) -> bool {
283 self.instances
284 .get(path)
285 .and_then(|state| state.schema.field(field))
286 .is_some_and(|field| field.sensitive)
287 }
288
289 #[must_use]
290 pub fn inspect(&self) -> Vec<StateInstanceSnapshot> {
291 self.instances
292 .iter()
293 .map(|(path, state)| state_instance_snapshot(path, state))
294 .collect()
295 }
296
297 #[must_use]
299 pub fn inspect_instance(&self, path: &ComponentInstancePath) -> Option<StateInstanceSnapshot> {
300 self.instances
301 .get(path)
302 .map(|state| state_instance_snapshot(path, state))
303 }
304}
305
306fn state_instance_snapshot(
307 path: &ComponentInstancePath,
308 state: &ComponentState,
309) -> StateInstanceSnapshot {
310 StateInstanceSnapshot {
311 path: path.clone(),
312 fields: state
313 .values
314 .iter()
315 .map(|(name, value)| {
316 let sensitive = state
317 .schema
318 .field(name)
319 .is_some_and(|field| field.sensitive);
320 (
321 name.clone(),
322 StateValueSnapshot {
323 value: if sensitive {
324 UiValue::String("<sensitive>".to_owned())
325 } else {
326 value.clone()
327 },
328 sensitive,
329 },
330 )
331 })
332 .collect(),
333 }
334}
335
336#[derive(Clone, Debug, PartialEq)]
337pub struct StateInstanceSnapshot {
338 pub path: ComponentInstancePath,
339 pub fields: BTreeMap<String, StateValueSnapshot>,
340}
341
342#[derive(Clone, Debug, PartialEq)]
343pub struct StateValueSnapshot {
344 pub value: UiValue,
345 pub sensitive: bool,
346}
347
348#[derive(Clone, Debug)]
349pub struct RenderStateTransaction {
350 instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
351 seen: BTreeSet<ComponentInstancePath>,
352 scope: Option<ComponentInstancePath>,
353}
354
355impl RenderStateTransaction {
356 #[must_use]
358 pub fn retain_existing(&mut self, path: &ComponentInstancePath) -> bool {
359 if self.instances.contains_key(path) {
360 self.seen.insert(path.clone());
361 true
362 } else {
363 false
364 }
365 }
366
367 pub fn mount(
374 &mut self,
375 path: ComponentInstancePath,
376 schema: &ComponentStateSchema,
377 ) -> Result<StateReconcileReport, StateError> {
378 let report = reconcile_instance(Rc::make_mut(&mut self.instances), path.clone(), schema)?;
379 self.seen.insert(path);
380 Ok(report)
381 }
382}
383
384fn reconcile_instance(
385 instances: &mut BTreeMap<ComponentInstancePath, ComponentState>,
386 path: ComponentInstancePath,
387 schema: &ComponentStateSchema,
388) -> Result<StateReconcileReport, StateError> {
389 ComponentStateSchema::new(schema.fields.clone())?;
391
392 let next = match instances.get(&path) {
393 None => (
394 ComponentState {
395 schema: Rc::new(schema.clone()),
396 values: Rc::new(schema.defaults()),
397 },
398 StateReconcileReport {
399 created: true,
400 reset_fields: Vec::new(),
401 },
402 ),
403 Some(previous) => {
404 let mut reset_fields = Vec::new();
405 let values = schema
406 .fields
407 .iter()
408 .map(|(name, field)| {
409 let value = previous
410 .values
411 .get(name)
412 .filter(|value| field.schema.validate_ui_value(value).is_ok())
413 .cloned()
414 .unwrap_or_else(|| {
415 if previous.values.contains_key(name) {
416 reset_fields.push(name.clone());
417 }
418 field.default.clone()
419 });
420 (name.clone(), value)
421 })
422 .collect();
423 (
424 ComponentState {
425 schema: Rc::new(schema.clone()),
426 values: Rc::new(values),
427 },
428 StateReconcileReport {
429 created: false,
430 reset_fields,
431 },
432 )
433 }
434 };
435 instances.insert(path, next.0);
436 Ok(next.1)
437}
438
439#[derive(Clone, Debug, Default, Eq, PartialEq)]
440pub struct StateReconcileReport {
441 pub created: bool,
442 pub reset_fields: Vec<String>,
443}
444
445#[derive(Clone, Debug, Error, Eq, PartialEq)]
446pub enum StateError {
447 #[error("schema for state field `{field}` is invalid: {source}")]
448 InvalidSchema {
449 field: String,
450 source: SchemaDefinitionError,
451 },
452 #[error("default for state field `{field}` is invalid: {source}")]
453 InvalidDefault {
454 field: String,
455 source: SchemaValidationError,
456 },
457 #[error("component state instance `{0}` does not exist")]
458 UnknownInstance(ComponentInstancePath),
459 #[error("state field `{field}` is not declared for component `{path}`")]
460 UnknownField {
461 path: ComponentInstancePath,
462 field: String,
463 },
464 #[error("state value for `{path}.{field}` is invalid: {source}")]
465 InvalidValue {
466 path: ComponentInstancePath,
467 field: String,
468 source: SchemaValidationError,
469 },
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 fn schema(value_schema: ValueSchema, default: UiValue) -> ComponentStateSchema {
477 ComponentStateSchema::new(BTreeMap::from([(
478 "value".to_owned(),
479 StateField::new(value_schema, default),
480 )]))
481 .unwrap()
482 }
483
484 #[test]
485 fn failed_render_does_not_change_committed_state() {
486 let path = ComponentInstancePath::root("App", "root").child("Combobox", "country");
487 let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
488 let mut store = StateStore::new();
489
490 let mut initial = store.begin_render();
491 initial.mount(path.clone(), &bool_schema).unwrap();
492 store.commit_render(initial);
493 store.set(&path, "value", UiValue::Bool(true)).unwrap();
494
495 let mut failed = store.begin_render();
496 failed
497 .mount(
498 path.clone(),
499 &schema(
500 ValueSchema::enumeration(["new"]),
501 UiValue::String("new".to_owned()),
502 ),
503 )
504 .unwrap();
505 drop(failed);
506
507 assert_eq!(store.get(&path, "value"), Some(&UiValue::Bool(true)));
508 }
509
510 #[test]
511 fn successful_render_resets_only_incompatible_fields() {
512 let path = ComponentInstancePath::root("App", "root");
513 let mut store = StateStore::new();
514 let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
515 let mut initial = store.begin_render();
516 initial.mount(path.clone(), &bool_schema).unwrap();
517 store.commit_render(initial);
518 store.set(&path, "value", UiValue::Bool(true)).unwrap();
519
520 let string_schema = schema(ValueSchema::string(), UiValue::String("reset".to_owned()));
521 let mut reload = store.begin_render();
522 let report = reload.mount(path.clone(), &string_schema).unwrap();
523 store.commit_render(reload);
524
525 assert_eq!(report.reset_fields, vec!["value"]);
526 assert_eq!(
527 store.get(&path, "value"),
528 Some(&UiValue::String("reset".to_owned()))
529 );
530 }
531
532 #[test]
533 fn inspect_instance_is_exact_and_redacts_sensitive_values() {
534 let selected = ComponentInstancePath::root("App", "root").child("Panel", "selected");
535 let unrelated = ComponentInstancePath::root("App", "root").child("Panel", "other");
536 let schema = ComponentStateSchema::new(BTreeMap::from([(
537 "token".to_owned(),
538 StateField::new(ValueSchema::string(), UiValue::String("secret".to_owned()))
539 .sensitive(true),
540 )]))
541 .unwrap();
542 let mut store = StateStore::new();
543 let mut render = store.begin_render();
544 render.mount(selected.clone(), &schema).unwrap();
545 render.mount(unrelated, &schema).unwrap();
546 store.commit_render(render);
547
548 let snapshot = store.inspect_instance(&selected).unwrap();
549 assert_eq!(snapshot.path, selected);
550 assert_eq!(
551 snapshot.fields["token"].value,
552 UiValue::String("<sensitive>".to_owned())
553 );
554 assert!(snapshot.fields["token"].sensitive);
555 assert!(
556 store
557 .inspect_instance(&ComponentInstancePath::root("missing", "root"))
558 .is_none()
559 );
560 }
561
562 #[test]
563 fn successful_render_cleans_unreachable_instances() {
564 let first = ComponentInstancePath::root("App", "root").child("Row", "first");
565 let second = ComponentInstancePath::root("App", "root").child("Row", "second");
566 let state_schema = schema(ValueSchema::integer(), UiValue::Integer(0));
567 let mut store = StateStore::new();
568 let mut initial = store.begin_render();
569 initial.mount(first.clone(), &state_schema).unwrap();
570 initial.mount(second, &state_schema).unwrap();
571 store.commit_render(initial);
572 assert_eq!(store.instance_count(), 2);
573
574 let mut next = store.begin_render();
575 next.mount(first, &state_schema).unwrap();
576 store.commit_render(next);
577 assert_eq!(store.instance_count(), 1);
578 }
579
580 #[test]
581 fn snapshots_share_instances_until_one_store_mutates() {
582 let path = ComponentInstancePath::root("Counter", "one");
583 let schema = schema(ValueSchema::integer(), UiValue::Integer(0));
584 let mut store = StateStore::new();
585 store.mount_instance(path.clone(), &schema).unwrap();
586 let snapshot = store.clone();
587 assert!(Rc::ptr_eq(&store.instances, &snapshot.instances));
588 store.set(&path, "value", UiValue::Integer(4)).unwrap();
589 assert!(!Rc::ptr_eq(&store.instances, &snapshot.instances));
590 assert_eq!(snapshot.get(&path, "value"), Some(&UiValue::Integer(0)));
591 }
592
593 #[test]
594 fn updates_are_schema_checked() {
595 let path = ComponentInstancePath::root("App", "root");
596 let state_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
597 let mut store = StateStore::new();
598 let mut render = store.begin_render();
599 render.mount(path.clone(), &state_schema).unwrap();
600 store.commit_render(render);
601
602 assert!(matches!(
603 store.set(&path, "value", UiValue::String("no".to_owned())),
604 Err(StateError::InvalidValue { .. })
605 ));
606 }
607
608 #[test]
609 fn scoped_commit_preserves_other_window_roots_and_remove_scope_cleans_one() {
610 let state_schema = schema(ValueSchema::string(), UiValue::String("ready".to_owned()));
611 let main = ComponentInstancePath::root("App", "main");
612 let settings = ComponentInstancePath::root("App", "settings");
613 let mut store = StateStore::new();
614 let mut initial = store.begin_render();
615 initial.mount(main.clone(), &state_schema).unwrap();
616 initial.mount(settings.clone(), &state_schema).unwrap();
617 store.commit_render(initial);
618
619 let mut main_render = store.begin_render_scope(main.clone());
620 main_render.mount(main.clone(), &state_schema).unwrap();
621 store.commit_render(main_render);
622 assert!(store.get(&settings, "value").is_some());
623
624 store.remove_scope(&main);
625 assert!(store.get(&main, "value").is_none());
626 assert!(store.get(&settings, "value").is_some());
627 }
628}