//! The storage of the engine on the file system: what the engine holds is kept between sessions, //! in a directory of the app (see `gpx_engine::Storage`). //! //! ```text //! head the revision of the last commit //! order the order of the files //! files/ the record of each file //! trackpoints/ the chunks of trackpoints //! waypoints/ the chunks of waypoints //! settings.json the settings of the frontend //! ``` //! //! A commit writes the chunks, the records and the order first, then the head, then deletes what //! is not used anymore: the head moves once all of the new data is there, and nothing that the old //! data refers to is gone before that. use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::sync::Mutex; use gpx_engine::{ Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData, }; use uuid::Uuid; pub struct FileStorage { root: PathBuf, settings: Mutex>, } fn failed(error: impl std::fmt::Display) -> StorageError { StorageError::Failed(error.to_string()) } /// Writes a file as a whole or not at all. pub fn write_atomically(path: &Path, bytes: &[u8]) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let temporary = path.with_extension("tmp"); fs::write(&temporary, bytes)?; fs::rename(&temporary, path) } fn read_optional(path: &Path) -> io::Result>> { match fs::read(path) { Ok(bytes) => Ok(Some(bytes)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn remove(path: &Path) -> io::Result<()> { match fs::remove_file(path) { Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), _ => Ok(()), } } impl FileStorage { /// Opens the storage in `root`, which is created if it does not exist. pub fn open(root: impl Into) -> Result { let root = root.into(); fs::create_dir_all(&root).map_err(failed)?; let settings = match read_optional(&root.join("settings.json")).map_err(failed)? { Some(bytes) => serde_json::from_slice(&bytes).map_err(failed)?, None => BTreeMap::new(), }; Ok(Self { root, settings: Mutex::new(settings), }) } pub fn root(&self) -> &Path { &self.root } pub fn settings(&self) -> BTreeMap { self.settings.lock().unwrap().clone() } fn chunk_path(&self, key: &ChunkKey) -> PathBuf { let directory = match key.kind { ChunkKind::Trackpoints => "trackpoints", ChunkKind::Waypoints => "waypoints", }; self.root.join(directory).join(key.id.to_string()) } fn file_path(&self, id: &FileId) -> PathBuf { self.root.join("files").join(id.0.to_string()) } /// The ids and contents of the files of a directory, named by UUIDs. fn read_directory(&self, name: &str) -> Result)>, StorageError> { let directory = self.root.join(name); let entries = match fs::read_dir(&directory) { Ok(entries) => entries, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]), Err(error) => return Err(failed(error)), }; let mut items = vec![]; for entry in entries { let path = entry.map_err(failed)?.path(); let Some(id) = path .file_name() .and_then(|name| name.to_str()) .and_then(|name| Uuid::parse_str(name).ok()) else { // temporary files of a write that did not finish continue; }; items.push((id, fs::read(&path).map_err(failed)?)); } Ok(items) } fn save_settings(&self, settings: &BTreeMap) -> Result<(), StorageError> { let json = serde_json::to_vec(settings).map_err(failed)?; write_atomically(&self.root.join("settings.json"), &json).map_err(failed) } fn read_head(&self) -> Result { match read_optional(&self.root.join("head")).map_err(failed)? { Some(bytes) => String::from_utf8_lossy(&bytes) .trim() .parse() .map(Revision) .map_err(failed), None => Ok(Revision::default()), } } fn write_batch(&self, batch: &Batch, head: Revision) -> io::Result<()> { for (key, bytes) in &batch.put_chunks { write_atomically(&self.chunk_path(key), bytes)?; } for (id, bytes) in &batch.put_files { write_atomically(&self.file_path(id), bytes)?; } if let Some(order) = &batch.order { write_atomically(&self.root.join("order"), order)?; } write_atomically(&self.root.join("head"), head.0.to_string().as_bytes())?; for id in &batch.delete_files { remove(&self.file_path(id))?; } for key in &batch.delete_chunks { remove(&self.chunk_path(key))?; } Ok(()) } } impl Storage for FileStorage { async fn load(&self) -> Result { let chunks = |kind: ChunkKind, name: &str| -> Result, StorageError> { Ok(self .read_directory(name)? .into_iter() .map(|(id, bytes)| (ChunkKey { kind, id }, bytes)) .collect()) }; let mut all_chunks = chunks(ChunkKind::Trackpoints, "trackpoints")?; all_chunks.extend(chunks(ChunkKind::Waypoints, "waypoints")?); Ok(StoredData { files: self .read_directory("files")? .into_iter() .map(|(id, bytes)| (FileId(id), bytes)) .collect(), chunks: all_chunks, order: read_optional(&self.root.join("order")).map_err(failed)?, head: self.read_head()?, }) } async fn head(&self) -> Result { self.read_head() } async fn commit(&self, batch: &Batch) -> Result { let head = self.read_head()?; if head != batch.expect_head { return Err(CommitError::Stale { head }); } let next = head.next(); self.write_batch(batch, next) .map_err(|error| CommitError::Failed(failed(error)))?; Ok(next) } async fn load_settings(&self) -> Result, StorageError> { Ok(self .settings .lock() .unwrap() .iter() .map(|(key, value)| (key.clone(), value.clone())) .collect()) } async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> { let mut settings = self.settings.lock().unwrap(); settings.insert(key.to_owned(), value.to_owned()); self.save_settings(&settings) } async fn delete_setting(&self, key: &str) -> Result<(), StorageError> { let mut settings = self.settings.lock().unwrap(); if settings.remove(key).is_some() { self.save_settings(&settings)?; } Ok(()) } } #[cfg(test)] mod tests { use crate::Engine; use gpx_engine::{Action, Command, Load, LoadFiles, Persister}; use super::*; fn directory() -> PathBuf { std::env::temp_dir().join(format!("gpx-studio-store-{}", Uuid::new_v4())) } fn engine_with_a_file() -> Engine { let mut engine = Engine::default(); let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap(); engine .execute(Action::Edit(Command::LoadFiles(LoadFiles { files: vec![Load { data: &data, name: "simple", }], }))) .unwrap(); engine } #[test] fn keeps_the_files_and_the_settings_between_sessions() { let root = directory(); let engine = engine_with_a_file(); let order = engine.order().to_vec(); let storage = FileStorage::open(&root).unwrap(); pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap(); pollster::block_on(storage.put_setting("units", "\"metric\"")).unwrap(); // another session let storage = FileStorage::open(&root).unwrap(); let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap(); let mut other = Engine::default(); other.restore(restored); assert_eq!(other.order(), order); assert_eq!( other.file_statistics(&order[0]).unwrap().total_distance, engine.file_statistics(&order[0]).unwrap().total_distance ); assert_eq!(storage.settings()["units"], "\"metric\""); fs::remove_dir_all(root).unwrap(); } #[test] fn refuses_a_commit_that_is_not_based_on_the_head() { let root = directory(); let storage = FileStorage::open(&root).unwrap(); let batch = Batch { order: Some(vec![]), ..Default::default() }; assert_eq!(pollster::block_on(storage.commit(&batch)), Ok(Revision(1))); assert_eq!( pollster::block_on(storage.commit(&batch)), Err(CommitError::Stale { head: Revision(1) }) ); fs::remove_dir_all(root).unwrap(); } }