Skip to main content

shuyi_ui/
storage.rs

1//! Persistence: a game's saves and the player's settings go through
2//! [`Storage`], so the UI stays pure. The platform supplies the real
3//! implementation (files, the browser's `localStorage`, a console's own
4//! files); [`MemoryStorage`] is for tests and the test harness.
5
6use crate::prelude::*;
7use alloc::collections::BTreeMap;
8use core::fmt;
9
10#[cfg(any(test, feature = "storage-conformance"))]
11mod power_cut;
12#[cfg(any(test, feature = "storage-conformance"))]
13pub use power_cut::{PowerCutRig, power_cut_suite};
14
15/// Longest key [`Storage`] accepts, in bytes.
16pub const MAX_KEY_LEN: usize = 64;
17
18/// A place to persist small named blobs of text (RON documents, typically):
19/// files on native, `localStorage` on web.
20///
21/// Keys must match `^[a-z0-9_-]{1,64}$` ([`is_valid_key`]); implementations
22/// turn a key straight into a file name, so anything else is rejected rather
23/// than sanitised.
24pub trait Storage: fmt::Debug {
25    /// The value stored at `key`, or `None` if nothing has been written
26    /// there (or it was deleted).
27    fn read(&self, key: &str) -> Result<Option<String>, StorageError>;
28    /// Stores `value` at `key`, replacing whatever was there.
29    fn write(&mut self, key: &str, value: &str) -> Result<(), StorageError>;
30    /// Removes `key`. Not an error if it didn't exist.
31    fn delete(&mut self, key: &str) -> Result<(), StorageError>;
32    /// Every key currently stored, in no particular order.
33    fn list(&self) -> Result<Vec<String>, StorageError>;
34}
35
36/// Why a [`Storage`] operation failed.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum StorageError {
39    /// The key doesn't match `^[a-z0-9_-]{1,64}$`.
40    InvalidKey(String),
41    /// The backend (filesystem, `localStorage`, …) reported a problem.
42    Backend(String),
43}
44
45// check-text: not player text (for the log)
46impl fmt::Display for StorageError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::InvalidKey(key) => write!(f, "invalid storage key {key:?}"),
50            Self::Backend(message) => write!(f, "storage error: {message}"),
51        }
52    }
53}
54
55/// Whether `key` matches `^[a-z0-9_-]{1,64}$`: lowercase ASCII letters,
56/// digits, `_` and `-`, 1 to [`MAX_KEY_LEN`] bytes.
57#[must_use]
58pub fn is_valid_key(key: &str) -> bool {
59    !key.is_empty()
60        && key.len() <= MAX_KEY_LEN
61        && key
62            .bytes()
63            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
64}
65
66/// Returns [`StorageError::InvalidKey`] unless `key` is valid.
67fn check_key(key: &str) -> Result<(), StorageError> {
68    if is_valid_key(key) {
69        Ok(())
70    } else {
71        Err(StorageError::InvalidKey(key.to_owned()))
72    }
73}
74
75/// An in-memory [`Storage`], for tests and the harness. Nothing persists
76/// past the process.
77#[derive(Debug, Clone, Default)]
78pub struct MemoryStorage {
79    values: BTreeMap<String, String>,
80}
81
82impl MemoryStorage {
83    /// An empty store.
84    #[must_use]
85    pub fn new() -> Self {
86        Self::default()
87    }
88}
89
90impl Storage for MemoryStorage {
91    fn read(&self, key: &str) -> Result<Option<String>, StorageError> {
92        check_key(key)?;
93        Ok(self.values.get(key).cloned())
94    }
95
96    fn write(&mut self, key: &str, value: &str) -> Result<(), StorageError> {
97        check_key(key)?;
98        self.values.insert(key.to_owned(), value.to_owned());
99        Ok(())
100    }
101
102    fn delete(&mut self, key: &str) -> Result<(), StorageError> {
103        check_key(key)?;
104        self.values.remove(key);
105        Ok(())
106    }
107
108    fn list(&self) -> Result<Vec<String>, StorageError> {
109        Ok(self.values.keys().cloned().collect())
110    }
111}
112
113/// Unwraps a storage result, panicking with the error on failure. Named
114/// `expect_ok` rather than `.unwrap()`/`.expect()` so it isn't flagged by
115/// `clippy::unwrap_used`/`expect_used` outside `#[cfg(test)]` (this helper
116/// backs [`conformance_suite`], which the platforms' tests and debug builds
117/// call through the `storage-conformance` feature, not just `cfg(test)`).
118#[cfg(any(test, feature = "storage-conformance"))]
119fn expect_ok<T>(result: Result<T, StorageError>) -> T {
120    result.unwrap_or_else(|e| panic!("storage error: {e}"))
121}
122
123/// A conformance suite every [`Storage`] implementation must pass, shared by
124/// this crate's own tests and the platforms' implementations.
125#[cfg(any(test, feature = "storage-conformance"))]
126pub fn conformance_suite(storage: &mut dyn Storage) {
127    assert_eq!(
128        expect_ok(storage.read("missing")),
129        None,
130        "reading an absent key returns None"
131    );
132    assert_eq!(expect_ok(storage.list()), Vec::<String>::new());
133
134    expect_ok(storage.write("save-slot-1", "hello"));
135    assert_eq!(
136        expect_ok(storage.read("save-slot-1")),
137        Some("hello".to_owned())
138    );
139    assert_eq!(expect_ok(storage.list()), ["save-slot-1"]);
140
141    expect_ok(storage.write("save-slot-1", "overwritten"));
142    assert_eq!(
143        expect_ok(storage.read("save-slot-1")),
144        Some("overwritten".to_owned())
145    );
146
147    expect_ok(storage.write("settings", "{}"));
148    let mut keys = expect_ok(storage.list());
149    keys.sort();
150    assert_eq!(keys, ["save-slot-1", "settings"]);
151
152    expect_ok(storage.delete("save-slot-1"));
153    assert_eq!(expect_ok(storage.read("save-slot-1")), None);
154    assert_eq!(expect_ok(storage.list()), ["settings"]);
155
156    // Deleting an absent key is not an error.
157    expect_ok(storage.delete("save-slot-1"));
158
159    // check-text: not player text (storage keys)
160    for bad in ["", "UPPER", "has space", "has/slash", &"x".repeat(65)] {
161        assert!(
162            matches!(storage.read(bad), Err(StorageError::InvalidKey(_))),
163            "{bad:?} should be rejected"
164        );
165        assert!(matches!(
166            storage.write(bad, "x"),
167            Err(StorageError::InvalidKey(_))
168        ));
169        assert!(matches!(
170            storage.delete(bad),
171            Err(StorageError::InvalidKey(_))
172        ));
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use proptest::prelude::*;
180
181    #[test]
182    fn memory_storage_passes_the_conformance_suite() {
183        conformance_suite(&mut MemoryStorage::new());
184    }
185
186    /// A [`Storage`] that ignores every write and accepts any key: proves
187    /// [`conformance_suite`] actually checks something, rather than being
188    /// vacuously satisfied by a broken implementation.
189    #[derive(Debug, Default)]
190    struct Broken;
191
192    impl Storage for Broken {
193        fn read(&self, _key: &str) -> Result<Option<String>, StorageError> {
194            Ok(None)
195        }
196        fn write(&mut self, _key: &str, _value: &str) -> Result<(), StorageError> {
197            Ok(())
198        }
199        fn delete(&mut self, _key: &str) -> Result<(), StorageError> {
200            Ok(())
201        }
202        fn list(&self) -> Result<Vec<String>, StorageError> {
203            Ok(Vec::new())
204        }
205    }
206
207    /// A storage that ignores writes and key validation must fail the suite.
208    #[test]
209    #[should_panic(expected = "assertion `left == right` failed")]
210    fn conformance_suite_rejects_a_broken_storage() {
211        conformance_suite(&mut Broken);
212    }
213
214    #[test]
215    fn error_display() {
216        assert_eq!(
217            StorageError::InvalidKey("BAD".to_owned()).to_string(),
218            "invalid storage key \"BAD\""
219        );
220        assert_eq!(
221            StorageError::Backend("disk full".to_owned()).to_string(),
222            "storage error: disk full"
223        );
224    }
225
226    /// A definition of `^[a-z0-9_-]{1,64}$` independent of [`is_valid_key`],
227    /// so the property test below checks the real implementation against a
228    /// second one rather than against itself.
229    fn matches_pattern(key: &str) -> bool {
230        let chars: Vec<char> = key.chars().collect();
231        (1..=MAX_KEY_LEN).contains(&chars.len())
232            && chars
233                .iter()
234                .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-'))
235    }
236
237    proptest! {
238        #[test]
239        fn accepted_iff_pattern_matches(key in ".{0,80}") {
240            prop_assert_eq!(is_valid_key(&key), matches_pattern(&key));
241        }
242
243        #[test]
244        fn generated_valid_keys_are_accepted(
245            key in "[a-z0-9_-]{1,64}"
246        ) {
247            prop_assert!(is_valid_key(&key));
248        }
249    }
250}