use std::fmt;
use std::marker::PhantomData;
use serde::de::DeserializeOwned;
use serde::Serialize;
#[derive(Debug)]
pub enum StorageError {
Unavailable,
Serialize(serde_json::Error),
Deserialize(serde_json::Error),
Browser(String),
}
impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unavailable => f.write_str("browser localStorage is unavailable"),
Self::Serialize(err) => write!(f, "could not serialize localStorage value: {err}"),
Self::Deserialize(err) => {
write!(f, "could not deserialize localStorage value: {err}")
}
Self::Browser(err) => write!(f, "browser localStorage error: {err}"),
}
}
}
impl std::error::Error for StorageError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialize(err) | Self::Deserialize(err) => Some(err),
Self::Unavailable | Self::Browser(_) => None,
}
}
}
#[derive(Clone, Debug)]
pub struct LocalStorage<T> {
key: String,
_marker: PhantomData<fn() -> T>,
}
impl<T> LocalStorage<T> {
pub fn new(key: impl Into<String>) -> Self {
Self {
key: key.into(),
_marker: PhantomData,
}
}
pub fn key(&self) -> &str {
&self.key
}
pub fn remove(&self) -> Result<(), StorageError> {
remove_local_storage_item(&self.key)
}
}
impl<T> LocalStorage<T>
where
T: DeserializeOwned,
{
pub fn get(&self) -> Result<Option<T>, StorageError> {
let Some(value) = get_local_storage_item(&self.key)? else {
return Ok(None);
};
serde_json::from_str(&value)
.map(Some)
.map_err(StorageError::Deserialize)
}
}
impl<T> LocalStorage<T>
where
T: Serialize,
{
pub fn set(&self, value: &T) -> Result<(), StorageError> {
let value = serde_json::to_string(value).map_err(StorageError::Serialize)?;
set_local_storage_item(&self.key, &value)
}
}
#[cfg(target_arch = "wasm32")]
fn get_local_storage_item(key: &str) -> Result<Option<String>, StorageError> {
storage()?
.get_item(key)
.map_err(|err| StorageError::Browser(js_error_message(err)))
}
#[cfg(not(target_arch = "wasm32"))]
fn get_local_storage_item(_: &str) -> Result<Option<String>, StorageError> {
Err(StorageError::Unavailable)
}
#[cfg(target_arch = "wasm32")]
fn set_local_storage_item(key: &str, value: &str) -> Result<(), StorageError> {
storage()?
.set_item(key, value)
.map_err(|err| StorageError::Browser(js_error_message(err)))
}
#[cfg(not(target_arch = "wasm32"))]
fn set_local_storage_item(_: &str, _: &str) -> Result<(), StorageError> {
Err(StorageError::Unavailable)
}
#[cfg(target_arch = "wasm32")]
fn remove_local_storage_item(key: &str) -> Result<(), StorageError> {
storage()?
.remove_item(key)
.map_err(|err| StorageError::Browser(js_error_message(err)))
}
#[cfg(not(target_arch = "wasm32"))]
fn remove_local_storage_item(_: &str) -> Result<(), StorageError> {
Err(StorageError::Unavailable)
}
#[cfg(target_arch = "wasm32")]
fn storage() -> Result<web_sys::Storage, StorageError> {
let window = web_sys::window().ok_or(StorageError::Unavailable)?;
window
.local_storage()
.map_err(|err| StorageError::Browser(js_error_message(err)))?
.ok_or(StorageError::Unavailable)
}
#[cfg(target_arch = "wasm32")]
fn js_error_message(err: wasm_bindgen::JsValue) -> String {
err.as_string().unwrap_or_else(|| format!("{err:?}"))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn local_storage_reports_unavailable_on_host() {
let storage = LocalStorage::<String>::new("pocopine.test");
assert!(matches!(storage.get(), Err(StorageError::Unavailable)));
assert!(matches!(
storage.set(&"value".to_string()),
Err(StorageError::Unavailable)
));
assert!(matches!(storage.remove(), Err(StorageError::Unavailable)));
}
}