//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1 //! Reads and writes the named preset library beside the other config. //! //! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and //! separate from it for the reason that one is: two files, two lifetimes. //! Resetting preferences must not destroy a photographer's presets, and a //! preset library is the one file here that represents work rather than //! configuration — it is what someone would carry to another machine. //! //! # Why the library is loaded whole and saved whole //! //! There is no incremental path. The document is kilobytes (see //! [`PresetLibrary`]), the caller is holding the copy the user just edited, //! and a merge would let a preset the window has not drawn yet resurrect //! after a delete. The same argument the settings store makes about fields, //! made about entries. use std::path::{Path, PathBuf}; use dr_pipeline::PresetLibrary; /// Loads and saves the named preset library. pub struct PresetStore { path: PathBuf, } impl PresetStore { /// Open the store at the platform config location. /// /// Beside `settings.json`, in [`dr_sync::account::config_dir`], so a user /// backing up one takes all of them. Never from `HOME` directly: Android /// declares its data directory rather than setting `HOME`, and a path /// built from an empty one is `/.config`, which is read-only — every /// preset saved on the tablet failed. Windows sets no `HOME` either, and /// got a directory relative to wherever the app was started. pub fn open() -> Self { Self::open_at(dr_sync::account::config_dir().join(format!( "presets.{}", dr_pipeline::preset::LIBRARY_EXTENSION ))) } /// Open at an explicit path — for tests, and for a non-default location. pub fn open_at(path: PathBuf) -> Self { Self { path } } pub fn path(&self) -> &Path { &self.path } /// The stored library, or an empty one. /// /// A missing file is a first run. An unparseable one is answered with an /// empty library rather than an error, on the same reasoning the settings /// store gives — the alternative is an app that will not start until the /// user hand-edits a file. /// /// The difference worth stating: settings are regenerated on the next /// save, where a preset library is *work*, and rewriting it whole would /// destroy whatever was in there. So a library that failed to parse is /// held empty in memory and **not** written back over until the user saves /// a preset, at which point they have chosen to. Nothing here deletes the /// file, and the warning names the path so it can be recovered by hand. pub fn load(&self) -> PresetLibrary { match self.try_load() { Ok(library) => library.unwrap_or_default(), Err(PresetStoreError::Unreadable(e)) => { log::warn!( "{} is not a readable preset library ({e}); \ starting empty, the file is left alone", self.path.display() ); PresetLibrary::default() } Err(e) => { log::warn!("reading {}: {e}; starting empty", self.path.display()); PresetLibrary::default() } } } /// The stored library, `None` when there is no file, or why it could not /// be read. /// /// For a caller that must not mistake a file it could not read for an /// empty library. The sync is one: to it an empty library next to a /// non-empty one from the last exchange means "every preset was deleted", /// and it would carry that to every other device. pub fn try_load(&self) -> Result, PresetStoreError> { match std::fs::read_to_string(&self.path) { Ok(text) => PresetLibrary::parse(&text) .map(Some) .map_err(|e| PresetStoreError::Unreadable(e.to_string())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } /// Persist the library, replacing whatever was there. pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } // Write and rename, so an interrupted save cannot truncate the // existing file — the same discipline the settings and session stores // use, and it matters more here because what would be truncated is // every preset the user has ever made rather than a set of // preferences that rebuild themselves. let tmp = self.path.with_extension("tmp"); std::fs::write(&tmp, library.to_text())?; std::fs::rename(&tmp, &self.path)?; Ok(()) } } #[derive(Debug, thiserror::Error)] pub enum PresetStoreError { #[error("preset library io: {0}")] Io(#[from] std::io::Error), #[error("{0}")] Unreadable(String), } #[cfg(test)] mod tests { use super::*; use dr_pipeline::Preset; /// The same hand-rolled temp directory the settings store's tests use — /// unique per process and thread, so a parallel run cannot collide. fn tempdir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "dr-presets-test-{name}-{}-{:?}", std::process::id(), std::thread::current().id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } fn store(name: &str) -> (PresetStore, PathBuf) { let dir = tempdir(name); ( PresetStore::open_at(dir.join("nested").join("presets.drpl")), dir, ) } fn library() -> PresetLibrary { let mut lib = PresetLibrary::default(); let mut params = std::collections::BTreeMap::new(); params.insert(("exposure".to_string(), "exposure".to_string()), 0.75); lib.insert("Warm", Preset::from_params(params)).unwrap(); lib.insert("Neutral", Preset::default()).unwrap(); lib } #[test] fn a_library_round_trips_through_the_store() { let (store, _dir) = store("a-library-round-trips-through-the-store"); store.save(&library()).unwrap(); assert_eq!(store.load(), library()); } #[test] fn a_first_run_loads_an_empty_library() { let (store, _dir) = store("a-first-run-loads-an-empty-library"); assert!(!store.path().exists()); assert!(store.load().is_empty()); } #[test] fn saving_creates_the_config_directory() { let (store, _dir) = store("saving-creates-the-config-directory"); store.save(&library()).unwrap(); assert!(store.path().exists()); } #[test] fn an_unreadable_file_is_left_alone_rather_than_overwritten() { // The difference from the settings store, and the reason this test // exists: what is on disk is work, so a parse failure must not be the // moment it is destroyed. let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten"); std::fs::create_dir_all(store.path().parent().unwrap()).unwrap(); std::fs::write(store.path(), "this is not a preset library").unwrap(); assert!(store.load().is_empty()); assert_eq!( std::fs::read_to_string(store.path()).unwrap(), "this is not a preset library" ); } #[test] fn a_neutral_preset_survives_a_save_and_load() { // It is a real entry, not an absence — see `PresetLibrary::insert`. let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load"); store.save(&library()).unwrap(); assert_eq!(store.load().get("Neutral"), Some(&Preset::default())); } }