Skip to main content

pocopine_core/
storage.rs

1//! Typed browser storage helpers.
2//!
3//! This module is intentionally small: it wraps the browser
4//! `localStorage` surface with serde so app preferences can be stored
5//! as real Rust types instead of ad hoc strings. On non-wasm targets,
6//! storage methods return [`StorageError::Unavailable`].
7
8use std::fmt;
9use std::marker::PhantomData;
10
11use serde::de::DeserializeOwned;
12use serde::Serialize;
13
14/// Error returned by typed browser storage operations.
15#[derive(Debug)]
16pub enum StorageError {
17    /// The requested browser storage surface is not available. This is
18    /// expected on host targets and can also happen in restricted
19    /// browser contexts.
20    Unavailable,
21    /// The value could not be serialized to JSON.
22    Serialize(serde_json::Error),
23    /// The stored JSON could not be deserialized as the requested type.
24    Deserialize(serde_json::Error),
25    /// Browser storage returned an error while reading, writing, or
26    /// removing an item.
27    Browser(String),
28}
29
30impl fmt::Display for StorageError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::Unavailable => f.write_str("browser localStorage is unavailable"),
34            Self::Serialize(err) => write!(f, "could not serialize localStorage value: {err}"),
35            Self::Deserialize(err) => {
36                write!(f, "could not deserialize localStorage value: {err}")
37            }
38            Self::Browser(err) => write!(f, "browser localStorage error: {err}"),
39        }
40    }
41}
42
43impl std::error::Error for StorageError {
44    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
45        match self {
46            Self::Serialize(err) | Self::Deserialize(err) => Some(err),
47            Self::Unavailable | Self::Browser(_) => None,
48        }
49    }
50}
51
52/// Typed access to `window.localStorage` for one key.
53///
54/// Values are encoded as JSON using `serde_json`, so enum/string
55/// preferences remain readable in devtools while still round-tripping
56/// through the requested Rust type.
57#[derive(Clone, Debug)]
58pub struct LocalStorage<T> {
59    key: String,
60    _marker: PhantomData<fn() -> T>,
61}
62
63impl<T> LocalStorage<T> {
64    /// Create a typed handle for `key`.
65    pub fn new(key: impl Into<String>) -> Self {
66        Self {
67            key: key.into(),
68            _marker: PhantomData,
69        }
70    }
71
72    /// The underlying `localStorage` key.
73    pub fn key(&self) -> &str {
74        &self.key
75    }
76
77    /// Remove the stored value for this key.
78    pub fn remove(&self) -> Result<(), StorageError> {
79        remove_local_storage_item(&self.key)
80    }
81}
82
83impl<T> LocalStorage<T>
84where
85    T: DeserializeOwned,
86{
87    /// Load and deserialize the stored value.
88    ///
89    /// Returns `Ok(None)` when the key is absent. Malformed JSON or a
90    /// type mismatch returns [`StorageError::Deserialize`] instead of
91    /// silently falling back.
92    pub fn get(&self) -> Result<Option<T>, StorageError> {
93        let Some(value) = get_local_storage_item(&self.key)? else {
94            return Ok(None);
95        };
96        serde_json::from_str(&value)
97            .map(Some)
98            .map_err(StorageError::Deserialize)
99    }
100}
101
102impl<T> LocalStorage<T>
103where
104    T: Serialize,
105{
106    /// Serialize and save `value`.
107    pub fn set(&self, value: &T) -> Result<(), StorageError> {
108        let value = serde_json::to_string(value).map_err(StorageError::Serialize)?;
109        set_local_storage_item(&self.key, &value)
110    }
111}
112
113#[cfg(target_arch = "wasm32")]
114fn get_local_storage_item(key: &str) -> Result<Option<String>, StorageError> {
115    storage()?
116        .get_item(key)
117        .map_err(|err| StorageError::Browser(js_error_message(err)))
118}
119
120#[cfg(not(target_arch = "wasm32"))]
121fn get_local_storage_item(_: &str) -> Result<Option<String>, StorageError> {
122    Err(StorageError::Unavailable)
123}
124
125#[cfg(target_arch = "wasm32")]
126fn set_local_storage_item(key: &str, value: &str) -> Result<(), StorageError> {
127    storage()?
128        .set_item(key, value)
129        .map_err(|err| StorageError::Browser(js_error_message(err)))
130}
131
132#[cfg(not(target_arch = "wasm32"))]
133fn set_local_storage_item(_: &str, _: &str) -> Result<(), StorageError> {
134    Err(StorageError::Unavailable)
135}
136
137#[cfg(target_arch = "wasm32")]
138fn remove_local_storage_item(key: &str) -> Result<(), StorageError> {
139    storage()?
140        .remove_item(key)
141        .map_err(|err| StorageError::Browser(js_error_message(err)))
142}
143
144#[cfg(not(target_arch = "wasm32"))]
145fn remove_local_storage_item(_: &str) -> Result<(), StorageError> {
146    Err(StorageError::Unavailable)
147}
148
149#[cfg(target_arch = "wasm32")]
150fn storage() -> Result<web_sys::Storage, StorageError> {
151    let window = web_sys::window().ok_or(StorageError::Unavailable)?;
152    window
153        .local_storage()
154        .map_err(|err| StorageError::Browser(js_error_message(err)))?
155        .ok_or(StorageError::Unavailable)
156}
157
158#[cfg(target_arch = "wasm32")]
159fn js_error_message(err: wasm_bindgen::JsValue) -> String {
160    err.as_string().unwrap_or_else(|| format!("{err:?}"))
161}
162
163#[cfg(all(test, not(target_arch = "wasm32")))]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn local_storage_reports_unavailable_on_host() {
169        let storage = LocalStorage::<String>::new("pocopine.test");
170        assert!(matches!(storage.get(), Err(StorageError::Unavailable)));
171        assert!(matches!(
172            storage.set(&"value".to_string()),
173            Err(StorageError::Unavailable)
174        ));
175        assert!(matches!(storage.remove(), Err(StorageError::Unavailable)));
176    }
177}