1use core::fmt;
42use core::hash::{Hash, Hasher};
43
44#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct StateKey {
64 pub widget_type: &'static str,
66 pub instance_id: String,
68}
69
70impl StateKey {
71 #[must_use]
73 pub fn new(widget_type: &'static str, id: impl Into<String>) -> Self {
74 Self {
75 widget_type,
76 instance_id: id.into(),
77 }
78 }
79
80 #[must_use]
89 pub fn from_path(path: &[&str]) -> Self {
90 assert!(
91 !path.is_empty(),
92 "StateKey::from_path requires a non-empty path"
93 );
94 let widget_type_str = path.last().expect("checked non-empty");
95 let widget_type: &'static str = Box::leak((*widget_type_str).to_owned().into_boxed_str());
99 Self {
100 widget_type,
101 instance_id: path.join("/"),
102 }
103 }
104
105 #[must_use]
107 pub fn canonical(&self) -> String {
108 format!("{}::{}", self.widget_type, self.instance_id)
109 }
110}
111
112impl Hash for StateKey {
113 fn hash<H: Hasher>(&self, state: &mut H) {
114 self.widget_type.hash(state);
115 self.instance_id.hash(state);
116 }
117}
118
119impl fmt::Display for StateKey {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 write!(f, "{}::{}", self.widget_type, self.instance_id)
122 }
123}
124
125pub trait Stateful: Sized {
166 type State: Default;
170
171 fn state_key(&self) -> StateKey;
175
176 fn save_state(&self) -> Self::State;
180
181 fn restore_state(&mut self, state: Self::State);
186
187 fn state_version() -> u32 {
193 1
194 }
195}
196
197#[derive(Clone, Debug)]
208#[cfg_attr(
209 feature = "state-persistence",
210 derive(serde::Serialize, serde::Deserialize)
211)]
212pub struct VersionedState<S> {
213 pub version: u32,
215 pub data: S,
217}
218
219impl<S> VersionedState<S> {
220 #[must_use]
222 pub fn new(version: u32, data: S) -> Self {
223 Self { version, data }
224 }
225
226 pub fn pack<W: Stateful<State = S>>(widget: &W) -> Self {
228 Self {
229 version: W::state_version(),
230 data: widget.save_state(),
231 }
232 }
233
234 pub fn unpack<W: Stateful<State = S>>(self) -> Option<S> {
237 if self.version == W::state_version() {
238 Some(self.data)
239 } else {
240 None
241 }
242 }
243
244 pub fn unpack_or_default<W: Stateful<State = S>>(self) -> S
247 where
248 S: Default,
249 {
250 if self.version == W::state_version() {
251 self.data
252 } else {
253 S::default()
254 }
255 }
256}
257
258impl<S: Default> Default for VersionedState<S> {
259 fn default() -> Self {
260 Self {
261 version: 1,
262 data: S::default(),
263 }
264 }
265}
266
267#[derive(Debug, Clone)]
273pub enum MigrationError {
274 NoPathFound { from: u32, to: u32 },
276 MigrationFailed { from: u32, to: u32, message: String },
278 InvalidVersionRange { from: u32, to: u32 },
280}
281
282impl core::fmt::Display for MigrationError {
283 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
284 match self {
285 Self::NoPathFound { from, to } => {
286 write!(f, "no migration path from version {} to {}", from, to)
287 }
288 Self::MigrationFailed { from, to, message } => {
289 write!(f, "migration from {} to {} failed: {}", from, to, message)
290 }
291 Self::InvalidVersionRange { from, to } => {
292 write!(f, "invalid version range: {} to {}", from, to)
293 }
294 }
295 }
296}
297
298#[allow(clippy::wrong_self_convention)]
325pub trait StateMigration {
326 type OldState;
328 type NewState;
330
331 fn from_version(&self) -> u32;
333
334 fn to_version(&self) -> u32;
337
338 fn migrate(&self, old: Self::OldState) -> Result<Self::NewState, String>;
342}
343
344#[allow(clippy::wrong_self_convention)]
348pub trait ErasedMigration<S>: Send + Sync {
349 fn from_version(&self) -> u32;
351 fn to_version(&self) -> u32;
353 fn migrate_erased(
355 &self,
356 old: Box<dyn core::any::Any + Send>,
357 ) -> Result<Box<dyn core::any::Any + Send>, String>;
358}
359
360pub struct MigrationChain<S> {
379 migrations: std::collections::HashMap<u32, Box<dyn ErasedMigration<S>>>,
381}
382
383impl<S: 'static> MigrationChain<S> {
384 #[must_use]
386 pub fn new() -> Self {
387 Self {
388 migrations: std::collections::HashMap::new(),
389 }
390 }
391
392 pub fn register(&mut self, migration: Box<dyn ErasedMigration<S>>) {
398 let from = migration.from_version();
399 let to = migration.to_version();
400 assert_eq!(
401 to,
402 from + 1,
403 "migration must increment version by exactly 1 (got {} -> {})",
404 from,
405 to
406 );
407 assert!(
408 !self.migrations.contains_key(&from),
409 "migration for version {} already registered",
410 from
411 );
412 self.migrations.insert(from, migration);
413 }
414
415 #[must_use]
417 pub fn has_path(&self, from_version: u32, to_version: u32) -> bool {
418 if from_version >= to_version {
419 return from_version == to_version;
420 }
421 let mut current = from_version;
422 while current < to_version {
423 if !self.migrations.contains_key(¤t) {
424 return false;
425 }
426 current += 1;
427 }
428 true
429 }
430
431 pub fn migrate(
435 &self,
436 state: Box<dyn core::any::Any + Send>,
437 from_version: u32,
438 to_version: u32,
439 ) -> Result<Box<dyn core::any::Any + Send>, MigrationError> {
440 if from_version > to_version {
441 return Err(MigrationError::InvalidVersionRange {
442 from: from_version,
443 to: to_version,
444 });
445 }
446 if from_version == to_version {
447 return Ok(state);
448 }
449
450 let mut current_state = state;
451 let mut current_version = from_version;
452
453 while current_version < to_version {
454 let migration =
455 self.migrations
456 .get(¤t_version)
457 .ok_or(MigrationError::NoPathFound {
458 from: current_version,
459 to: to_version,
460 })?;
461
462 current_state = migration.migrate_erased(current_state).map_err(|msg| {
463 MigrationError::MigrationFailed {
464 from: current_version,
465 to: current_version + 1,
466 message: msg,
467 }
468 })?;
469
470 current_version += 1;
471 }
472
473 Ok(current_state)
474 }
475}
476
477impl<S: 'static> Default for MigrationChain<S> {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483#[derive(Debug)]
485pub enum RestoreResult<S> {
486 Direct(S),
488 Migrated { state: S, from_version: u32 },
490 DefaultFallback { error: MigrationError, default: S },
492}
493
494impl<S> RestoreResult<S> {
495 pub fn into_state(self) -> S {
497 match self {
498 Self::Direct(s) | Self::Migrated { state: s, .. } => s,
499 Self::DefaultFallback { default, .. } => default,
500 }
501 }
502
503 #[must_use]
505 pub fn was_migrated(&self) -> bool {
506 matches!(self, Self::Migrated { .. })
507 }
508
509 #[must_use]
511 pub fn is_fallback(&self) -> bool {
512 matches!(self, Self::DefaultFallback { .. })
513 }
514}
515
516impl<S> VersionedState<S> {
517 pub fn unpack_with_migration<W>(self, chain: &MigrationChain<S>) -> RestoreResult<S>
537 where
538 W: Stateful<State = S>,
539 S: Default + 'static + Send,
540 {
541 let current_version = W::state_version();
542
543 if self.version == current_version {
544 return RestoreResult::Direct(self.data);
545 }
546
547 let boxed: Box<dyn core::any::Any + Send> = Box::new(self.data);
549 match chain.migrate(boxed, self.version, current_version) {
550 Ok(migrated) => {
551 if let Ok(state) = migrated.downcast::<S>() {
552 RestoreResult::Migrated {
553 state: *state,
554 from_version: self.version,
555 }
556 } else {
557 RestoreResult::DefaultFallback {
559 error: MigrationError::MigrationFailed {
560 from: self.version,
561 to: current_version,
562 message: "type mismatch after migration".to_string(),
563 },
564 default: S::default(),
565 }
566 }
567 }
568 Err(e) => RestoreResult::DefaultFallback {
569 error: e,
570 default: S::default(),
571 },
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[derive(Default)]
583 struct TestScrollView {
584 id: String,
585 offset: u16,
586 max: u16,
587 }
588
589 #[derive(Clone, Debug, Default, PartialEq)]
590 struct ScrollState {
591 scroll_offset: u16,
592 }
593
594 impl Stateful for TestScrollView {
595 type State = ScrollState;
596
597 fn state_key(&self) -> StateKey {
598 StateKey::new("ScrollView", &self.id)
599 }
600
601 fn save_state(&self) -> ScrollState {
602 ScrollState {
603 scroll_offset: self.offset,
604 }
605 }
606
607 fn restore_state(&mut self, state: ScrollState) {
608 self.offset = state.scroll_offset.min(self.max);
609 }
610 }
611
612 #[derive(Default)]
615 struct TestTreeView {
616 id: String,
617 expanded: Vec<u32>,
618 }
619
620 #[derive(Clone, Debug, Default, PartialEq)]
621 struct TreeState {
622 expanded_nodes: Vec<u32>,
623 collapse_all_on_blur: bool, }
625
626 impl Stateful for TestTreeView {
627 type State = TreeState;
628
629 fn state_key(&self) -> StateKey {
630 StateKey::new("TreeView", &self.id)
631 }
632
633 fn save_state(&self) -> TreeState {
634 TreeState {
635 expanded_nodes: self.expanded.clone(),
636 collapse_all_on_blur: false,
637 }
638 }
639
640 fn restore_state(&mut self, state: TreeState) {
641 self.expanded = state.expanded_nodes;
642 }
643
644 fn state_version() -> u32 {
645 2
646 }
647 }
648
649 #[test]
652 fn state_key_new() {
653 let key = StateKey::new("ScrollView", "main");
654 assert_eq!(key.widget_type, "ScrollView");
655 assert_eq!(key.instance_id, "main");
656 }
657
658 #[test]
659 fn state_key_from_path() {
660 let key = StateKey::from_path(&["app", "sidebar", "tree"]);
661 assert_eq!(key.instance_id, "app/sidebar/tree");
662 assert_eq!(key.widget_type, "tree");
663 }
664
665 #[test]
666 #[should_panic(expected = "non-empty path")]
667 fn state_key_from_empty_path_panics() {
668 let _ = StateKey::from_path(&[]);
669 }
670
671 #[test]
672 fn state_key_uniqueness() {
673 let a = StateKey::new("ScrollView", "main");
674 let b = StateKey::new("ScrollView", "sidebar");
675 let c = StateKey::new("TreeView", "main");
676 assert_ne!(a, b);
677 assert_ne!(a, c);
678 assert_ne!(b, c);
679 }
680
681 #[test]
682 fn state_key_equality() {
683 let a = StateKey::new("ScrollView", "main");
684 let b = StateKey::new("ScrollView", "main");
685 assert_eq!(a, b);
686 }
687
688 #[test]
689 fn state_key_hash_consistency() {
690 use std::collections::hash_map::DefaultHasher;
691
692 let a = StateKey::new("ScrollView", "main");
693 let b = StateKey::new("ScrollView", "main");
694
695 let hash = |key: &StateKey| {
696 let mut h = DefaultHasher::new();
697 key.hash(&mut h);
698 h.finish()
699 };
700 assert_eq!(hash(&a), hash(&b));
701 }
702
703 #[test]
704 fn state_key_display() {
705 let key = StateKey::new("ScrollView", "main");
706 assert_eq!(key.to_string(), "ScrollView::main");
707 }
708
709 #[test]
710 fn state_key_canonical() {
711 let key = StateKey::new("ScrollView", "main");
712 assert_eq!(key.canonical(), "ScrollView::main");
713 }
714
715 #[test]
718 fn save_restore_round_trip() {
719 let mut widget = TestScrollView {
720 id: "content".into(),
721 offset: 42,
722 max: 100,
723 };
724
725 let saved = widget.save_state();
726 assert_eq!(saved.scroll_offset, 42);
727
728 widget.offset = 0; widget.restore_state(saved);
730 assert_eq!(widget.offset, 42);
731 }
732
733 #[test]
734 fn restore_clamps_to_valid_range() {
735 let mut widget = TestScrollView {
736 id: "content".into(),
737 offset: 0,
738 max: 10,
739 };
740
741 widget.restore_state(ScrollState { scroll_offset: 999 });
743 assert_eq!(widget.offset, 10);
744 }
745
746 #[test]
747 fn default_state_on_missing() {
748 let mut widget = TestScrollView {
749 id: "new".into(),
750 offset: 5,
751 max: 100,
752 };
753
754 widget.restore_state(ScrollState::default());
755 assert_eq!(widget.offset, 0);
756 }
757
758 #[test]
761 fn default_state_version_is_one() {
762 assert_eq!(TestScrollView::state_version(), 1);
763 }
764
765 #[test]
766 fn custom_state_version() {
767 assert_eq!(TestTreeView::state_version(), 2);
768 }
769
770 #[test]
773 fn versioned_state_pack_unpack() {
774 let widget = TestScrollView {
775 id: "main".into(),
776 offset: 77,
777 max: 100,
778 };
779
780 let packed = VersionedState::pack(&widget);
781 assert_eq!(packed.version, 1);
782 assert_eq!(packed.data.scroll_offset, 77);
783
784 let unpacked = packed.unpack::<TestScrollView>();
785 assert!(unpacked.is_some());
786 assert_eq!(unpacked.unwrap().scroll_offset, 77);
787 }
788
789 #[test]
790 fn versioned_state_version_mismatch_returns_none() {
791 let stored = VersionedState::<TreeState> {
793 version: 1,
794 data: TreeState::default(),
795 };
796
797 let result = stored.unpack::<TestTreeView>();
798 assert!(result.is_none());
799 }
800
801 #[test]
802 fn versioned_state_unpack_or_default_on_mismatch() {
803 let stored = VersionedState::<TreeState> {
804 version: 1,
805 data: TreeState {
806 expanded_nodes: vec![1, 2, 3],
807 collapse_all_on_blur: true,
808 },
809 };
810
811 let result = stored.unpack_or_default::<TestTreeView>();
812 assert_eq!(result, TreeState::default());
814 }
815
816 #[test]
817 fn versioned_state_unpack_or_default_on_match() {
818 let stored = VersionedState::<ScrollState> {
819 version: 1,
820 data: ScrollState { scroll_offset: 55 },
821 };
822
823 let result = stored.unpack_or_default::<TestScrollView>();
824 assert_eq!(result.scroll_offset, 55);
825 }
826
827 #[test]
828 fn versioned_state_default() {
829 let vs = VersionedState::<ScrollState>::default();
830 assert_eq!(vs.version, 1);
831 assert_eq!(vs.data, ScrollState::default());
832 }
833
834 #[test]
837 fn migration_error_display() {
838 let err = MigrationError::NoPathFound { from: 1, to: 3 };
839 assert_eq!(err.to_string(), "no migration path from version 1 to 3");
840
841 let err = MigrationError::MigrationFailed {
842 from: 2,
843 to: 3,
844 message: "data corrupt".into(),
845 };
846 assert_eq!(
847 err.to_string(),
848 "migration from 2 to 3 failed: data corrupt"
849 );
850
851 let err = MigrationError::InvalidVersionRange { from: 5, to: 2 };
852 assert_eq!(err.to_string(), "invalid version range: 5 to 2");
853 }
854
855 #[test]
856 fn migration_chain_new_is_empty() {
857 let chain = MigrationChain::<ScrollState>::new();
858 assert!(!chain.has_path(1, 2));
859 }
860
861 #[derive(Debug, Clone, Default)]
863 struct ScrollStateV1 {
864 scroll_offset: u16,
865 }
866
867 #[derive(Debug, Clone, Default)]
868 struct ScrollStateV2 {
869 scroll_offset: u16,
870 velocity: f32, }
872
873 struct V1ToV2Migration;
874
875 impl ErasedMigration<ScrollStateV2> for V1ToV2Migration {
876 fn from_version(&self) -> u32 {
877 1
878 }
879 fn to_version(&self) -> u32 {
880 2
881 }
882 fn migrate_erased(
883 &self,
884 old: Box<dyn core::any::Any + Send>,
885 ) -> Result<Box<dyn core::any::Any + Send>, String> {
886 let v1 = old
887 .downcast::<ScrollStateV1>()
888 .map_err(|_| "invalid state type")?;
889 Ok(Box::new(ScrollStateV2 {
890 scroll_offset: v1.scroll_offset,
891 velocity: 0.0,
892 }))
893 }
894 }
895
896 #[test]
897 fn migration_chain_register_and_has_path() {
898 let mut chain = MigrationChain::<ScrollStateV2>::new();
899 chain.register(Box::new(V1ToV2Migration));
900
901 assert!(chain.has_path(1, 2));
902 assert!(chain.has_path(1, 1)); assert!(chain.has_path(2, 2)); assert!(!chain.has_path(1, 3)); }
906
907 #[test]
908 #[should_panic(expected = "migration must increment version by exactly 1")]
909 fn migration_chain_rejects_non_sequential_migration() {
910 struct BadMigration;
911 impl ErasedMigration<ScrollStateV2> for BadMigration {
912 fn from_version(&self) -> u32 {
913 1
914 }
915 fn to_version(&self) -> u32 {
916 3
917 } fn migrate_erased(
919 &self,
920 _: Box<dyn core::any::Any + Send>,
921 ) -> Result<Box<dyn core::any::Any + Send>, String> {
922 unreachable!()
923 }
924 }
925
926 let mut chain = MigrationChain::<ScrollStateV2>::new();
927 chain.register(Box::new(BadMigration));
928 }
929
930 #[test]
931 #[should_panic(expected = "migration for version 1 already registered")]
932 fn migration_chain_rejects_duplicate_registration() {
933 let mut chain = MigrationChain::<ScrollStateV2>::new();
934 chain.register(Box::new(V1ToV2Migration));
935 chain.register(Box::new(V1ToV2Migration)); }
937
938 #[test]
939 fn migration_chain_migrate_success() {
940 let mut chain = MigrationChain::<ScrollStateV2>::new();
941 chain.register(Box::new(V1ToV2Migration));
942
943 let old_state = Box::new(ScrollStateV1 { scroll_offset: 42 });
944 let result = chain.migrate(old_state, 1, 2);
945
946 assert!(result.is_ok());
947 let migrated = result
948 .unwrap()
949 .downcast::<ScrollStateV2>()
950 .expect("should be ScrollStateV2");
951 assert_eq!(migrated.scroll_offset, 42);
952 assert_eq!(migrated.velocity, 0.0);
953 }
954
955 #[test]
956 fn migration_chain_migrate_same_version() {
957 let chain = MigrationChain::<ScrollStateV2>::new();
958 let state = Box::new(ScrollStateV2 {
959 scroll_offset: 10,
960 velocity: 1.5,
961 });
962
963 let result = chain.migrate(state, 2, 2);
964 assert!(result.is_ok());
965 }
966
967 #[test]
968 fn migration_chain_migrate_no_path() {
969 let chain = MigrationChain::<ScrollStateV2>::new();
970 let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV1 { scroll_offset: 0 });
971
972 let result = chain.migrate(state, 1, 2);
973 assert!(matches!(
974 result,
975 Err(MigrationError::NoPathFound { from: 1, to: 2 })
976 ));
977 }
978
979 #[test]
980 fn migration_chain_migrate_invalid_range() {
981 let chain = MigrationChain::<ScrollStateV2>::new();
982 let state: Box<dyn core::any::Any + Send> = Box::new(ScrollStateV2::default());
983
984 let result = chain.migrate(state, 3, 1);
985 assert!(matches!(
986 result,
987 Err(MigrationError::InvalidVersionRange { from: 3, to: 1 })
988 ));
989 }
990
991 #[test]
992 fn restore_result_into_state() {
993 let direct = RestoreResult::Direct(ScrollState { scroll_offset: 10 });
994 assert_eq!(direct.into_state().scroll_offset, 10);
995
996 let migrated = RestoreResult::Migrated {
997 state: ScrollState { scroll_offset: 20 },
998 from_version: 1,
999 };
1000 assert_eq!(migrated.into_state().scroll_offset, 20);
1001
1002 let fallback = RestoreResult::DefaultFallback {
1003 error: MigrationError::NoPathFound { from: 1, to: 2 },
1004 default: ScrollState { scroll_offset: 0 },
1005 };
1006 assert_eq!(fallback.into_state().scroll_offset, 0);
1007 }
1008
1009 #[test]
1010 fn restore_result_was_migrated() {
1011 let direct = RestoreResult::Direct(ScrollState::default());
1012 assert!(!direct.was_migrated());
1013
1014 let migrated = RestoreResult::Migrated::<ScrollState> {
1015 state: ScrollState::default(),
1016 from_version: 1,
1017 };
1018 assert!(migrated.was_migrated());
1019
1020 let fallback = RestoreResult::DefaultFallback::<ScrollState> {
1021 error: MigrationError::NoPathFound { from: 1, to: 2 },
1022 default: ScrollState::default(),
1023 };
1024 assert!(!fallback.was_migrated());
1025 }
1026
1027 #[test]
1028 fn restore_result_is_fallback() {
1029 let direct = RestoreResult::Direct(ScrollState::default());
1030 assert!(!direct.is_fallback());
1031
1032 let migrated = RestoreResult::Migrated::<ScrollState> {
1033 state: ScrollState::default(),
1034 from_version: 1,
1035 };
1036 assert!(!migrated.is_fallback());
1037
1038 let fallback = RestoreResult::DefaultFallback::<ScrollState> {
1039 error: MigrationError::NoPathFound { from: 1, to: 2 },
1040 default: ScrollState::default(),
1041 };
1042 assert!(fallback.is_fallback());
1043 }
1044}