Files
DarkRoom/ui/dr-ui/src/preset_store.rs
T
dtourolle 26e50ae723 Save presets beside the settings, not under a raw HOME
PresetStore::open built its path from XDG_CONFIG_HOME or HOME. Android
sets neither, so the library resolved to /.config/darkroom, which is
read-only, and every preset saved on the tablet failed. Windows sets no
HOME either and got a directory relative to the working directory. The
settings store was moved to dr_sync::account::config_dir for the same
reason in 0.12.1; the presets now follow it. Linux and macOS resolve to
the same file as before.
2026-10-04 00:49:59 -04:00

190 lines
7.2 KiB
Rust

//! 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 std::fs::read_to_string(&self.path) {
Ok(text) => match PresetLibrary::parse(&text) {
Ok(library) => library,
Err(e) => {
log::warn!(
"{} is not a readable preset library ({e}); \
starting empty, the file is left alone",
self.path.display()
);
PresetLibrary::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
Err(e) => {
log::warn!("reading {}: {e}; starting empty", self.path.display());
PresetLibrary::default()
}
}
}
/// 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),
}
#[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()));
}
}