Skip to main content

ravenrs_core/
storage.rs

1//! Somewhere to keep named pieces of data between runs: a file on the desktop, the browser's
2//! IndexedDB on the web, memory in tests. Reading is synchronous, so a store that is slow to
3//! read, like the browser's, loads everything before the app opens.
4
5use std::cell::RefCell;
6use std::collections::BTreeMap;
7use std::fmt;
8
9/// Named pieces of data kept between runs.
10///
11/// ```
12/// use ravenrs_core::{MemoryStorage, Storage};
13///
14/// let storage = MemoryStorage::default();
15/// storage.write("records", b"{}")?;
16/// assert_eq!(storage.read("records")?, Some(b"{}".to_vec()));
17/// storage.remove("records")?;
18/// assert_eq!(storage.read("records")?, None);
19/// # Ok::<(), String>(())
20/// ```
21pub trait Storage {
22    /// What is kept under `name`, or `None` if nothing is.
23    fn read(&self, name: &str) -> Result<Option<Vec<u8>>, String>;
24    /// Keeps `bytes` under `name`, in place of what was there.
25    fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String>;
26    /// Forgets what is kept under `name`.
27    fn remove(&self, name: &str) -> Result<(), String>;
28}
29
30/// Pieces of data kept in memory, gone when the program ends: for tests and previews.
31#[derive(Default)]
32#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct MemoryStorage(RefCell<BTreeMap<String, Vec<u8>>>);
34
35impl fmt::Debug for MemoryStorage {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("MemoryStorage").field("names", &self.0.borrow().keys().collect::<Vec<_>>()).finish()
38    }
39}
40
41impl Storage for MemoryStorage {
42    fn read(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
43        Ok(self.0.borrow().get(name).cloned())
44    }
45
46    fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
47        self.0.borrow_mut().insert(name.to_owned(), bytes.to_vec());
48        Ok(())
49    }
50
51    fn remove(&self, name: &str) -> Result<(), String> {
52        self.0.borrow_mut().remove(name);
53        Ok(())
54    }
55}
56
57impl<S: Storage + ?Sized> Storage for std::rc::Rc<S> {
58    fn read(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
59        (**self).read(name)
60    }
61
62    fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
63        (**self).write(name, bytes)
64    }
65
66    fn remove(&self, name: &str) -> Result<(), String> {
67        (**self).remove(name)
68    }
69}
70
71/// Pieces of data kept as files in a folder on the desktop, one file per name. A write goes
72/// to a draft first and then takes the file's place, so a crash never leaves half a file.
73#[cfg(not(target_arch = "wasm32"))]
74#[derive(Clone, Debug)]
75#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
76pub struct FileStorage {
77    folder: std::path::PathBuf,
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81#[cfg_attr(feature = "live", ravenrs_live::expose)]
82impl FileStorage {
83    /// Files in `folder`, which is made when first written to.
84    pub fn new(folder: impl Into<std::path::PathBuf>) -> Self {
85        Self { folder: folder.into() }
86    }
87
88    fn path(&self, name: &str) -> Result<std::path::PathBuf, String> {
89        let plain = !name.is_empty()
90            && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
91            && !name.starts_with('.');
92        if plain {
93            Ok(self.folder.join(name))
94        } else {
95            Err(format!("{name:?} is not a plain name: use letters, digits, '-', '_' and '.'"))
96        }
97    }
98}
99
100#[cfg(not(target_arch = "wasm32"))]
101impl Storage for FileStorage {
102    fn read(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
103        let path = self.path(name)?;
104        match std::fs::read(&path) {
105            Ok(bytes) => Ok(Some(bytes)),
106            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
107            Err(e) => Err(format!("{}: {e}", path.display())),
108        }
109    }
110
111    fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
112        let path = self.path(name)?;
113        let failed = |e: std::io::Error| format!("{}: {e}", path.display());
114        std::fs::create_dir_all(&self.folder).map_err(failed)?;
115        let draft = path.with_extension("draft");
116        std::fs::write(&draft, bytes).map_err(failed)?;
117        std::fs::rename(&draft, &path).map_err(failed)
118    }
119
120    fn remove(&self, name: &str) -> Result<(), String> {
121        let path = self.path(name)?;
122        match std::fs::remove_file(&path) {
123            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
124            _ => Ok(()),
125        }
126    }
127}
128
129#[cfg(all(test, not(target_arch = "wasm32")))]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn files_keep_what_was_written_and_refuse_paths() {
135        let folder = std::env::temp_dir().join(format!("ravenrs-storage-{}", std::process::id()));
136        let storage = FileStorage::new(&folder);
137        assert_eq!(storage.read("records"), Ok(None));
138        assert_eq!(storage.write("records", b"one"), Ok(()));
139        assert_eq!(storage.write("records", b"two"), Ok(()));
140        assert_eq!(storage.read("records"), Ok(Some(b"two".to_vec())));
141        assert!(storage.write("../outside", b"x").is_err());
142        assert!(storage.read(".hidden").is_err());
143        assert_eq!(storage.remove("records"), Ok(()));
144        assert_eq!(storage.remove("records"), Ok(()), "nothing to forget is fine");
145        assert_eq!(storage.read("records"), Ok(None));
146        let _ = std::fs::remove_dir_all(folder);
147    }
148}