//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3 //! Reads and writes `settings.json` beside the session config. //! //! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore) //! rather than an extension of it. Two files, two lifetimes: signing out //! forgets a session and must not discard a cache budget, and resetting //! preferences must not revoke a credential. Merging them would couple those. //! //! There is no `version` field here, unlike `sessions.json`. Every field in //! [`Settings`] is `#[serde(default)]`, so an older file is missing fields //! rather than wrong about them, and a newer file read by an older build //! ignores what it does not know. That covers additive change, which is the //! only kind this record has had; a field whose *meaning* changes will need a //! version, and that is the point to add one. use std::path::{Path, PathBuf}; use dr_types::Settings; /// Loads and saves device preferences. pub struct SettingsStore { path: PathBuf, } impl SettingsStore { /// Open the store at the platform config location. /// /// Linux: `$XDG_CONFIG_HOME/darkroom/settings.json`, falling back to /// `~/.config` (FR-PLAT-LIN-1) — the same resolution `SessionStore` does, /// so the two files sit together and a user backing up one takes both. pub fn open() -> Self { let dir = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|| { PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config") }) .join("darkroom"); Self::open_at(dir.join("settings.json")) } /// 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 settings, or the defaults. /// /// A missing file is a first run, not a failure. An *unparseable* file is /// also answered with defaults rather than an error, because the /// alternative is an app that will not start until the user hand-edits /// JSON — and the file is rewritten whole on the next save, so the damage /// does not persist. The parse failure is logged so it is not silent. pub fn load(&self) -> Settings { let mut settings = match std::fs::read_to_string(&self.path) { Ok(text) => match serde_json::from_str::(&text) { Ok(s) => s, Err(e) => { log::warn!( "{} is not readable settings ({e}); using defaults", self.path.display() ); Settings::default() } }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Settings::default(), Err(e) => { log::warn!("reading {}: {e}; using defaults", self.path.display()); Settings::default() } }; // The file is hand-editable, so nothing downstream may assume a sane // range until this has run. settings.sanitise(); settings } /// Persist settings, replacing whatever was there. /// /// A whole-file write rather than a merge: this record is the complete set /// of preferences and the caller is holding the copy the user just edited. /// Merging would let a field the page does not yet expose resurrect an old /// value the user thought they had changed. pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(settings)?; // Write and rename, so an interrupted save cannot truncate the // existing file — the same discipline `SessionStore` uses. Settings are // saved on every field edit, which makes the interrupted-write window // something the user actually meets rather than a theoretical one. let tmp = self.path.with_extension("tmp"); std::fs::write(&tmp, json)?; std::fs::rename(&tmp, &self.path)?; Ok(()) } } #[derive(Debug, thiserror::Error)] pub enum SettingsError { #[error("settings io: {0}")] Io(#[from] std::io::Error), #[error("settings format: {0}")] Serde(#[from] serde_json::Error), } #[cfg(test)] mod tests { use super::*; use dr_types::{settings::budget, ExportFormat}; fn tempdir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "dr-settings-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) -> (SettingsStore, PathBuf) { let dir = tempdir(name); (SettingsStore::open_at(dir.join("settings.json")), dir) } #[test] fn a_first_run_gets_the_defaults() { let (store, _dir) = store("first-run"); assert!(!store.path().exists()); assert_eq!(store.load(), Settings::default()); } #[test] fn settings_survive_a_round_trip() { let (store, _dir) = store("round-trip"); let mut settings = Settings::default(); settings.export.format = ExportFormat::Tiff16; settings.export.quality = 72; settings.cache.original_budget_bytes = budget::from_gb("16"); settings.cache.keep_opened_originals = false; store.save(&settings).unwrap(); assert_eq!(store.load(), settings); } #[test] fn an_unlimited_budget_survives_the_file() { // `None` and `Some(0)` are different settings; a serialiser that // flattened one into the other would turn "keep everything" into // "keep nothing" — the worst possible confusion of the two. let (store, _dir) = store("unlimited"); let mut settings = Settings::default(); settings.cache.original_budget_bytes = None; store.save(&settings).unwrap(); assert_eq!(store.load().cache.original_budget_bytes, None); } #[test] fn saving_creates_the_config_directory() { // A first save on a fresh machine has no `~/.config/darkroom` yet. let dir = tempdir("mkdir"); let store = SettingsStore::open_at(dir.join("nested").join("settings.json")); store.save(&Settings::default()).unwrap(); assert!(store.path().exists()); } #[test] fn a_corrupt_file_yields_defaults_rather_than_failing() { // An app that will not start until the user hand-edits JSON is worse // than one that forgets a preference. let (store, _dir) = store("corrupt"); std::fs::write(store.path(), "{ this is not json").unwrap(); assert_eq!(store.load(), Settings::default()); } #[test] fn a_file_from_an_older_build_keeps_what_it_does_say() { // Additive change is the case `serde(default)` covers, and the point of // having no version field. The named value must survive. let (store, _dir) = store("older"); std::fs::write(store.path(), r#"{"export":{"quality":55}}"#).unwrap(); let loaded = store.load(); assert_eq!(loaded.export.quality, 55); assert_eq!(loaded.cache, dr_types::CacheSettings::default()); } #[test] fn load_sanitises_a_hand_edited_file() { let (store, _dir) = store("sanitise"); std::fs::write(store.path(), r#"{"export":{"quality":250}}"#).unwrap(); assert_eq!(store.load().export.quality, 100); } #[test] fn a_save_replaces_rather_than_merging() { let (store, _dir) = store("replace"); let mut first = Settings::default(); first.export.quality = 50; store.save(&first).unwrap(); // The defaults again: quality must go back to 90, not stay at 50. store.save(&Settings::default()).unwrap(); assert_eq!(store.load().export.quality, 90); } #[test] fn no_temporary_file_is_left_behind() { let (store, dir) = store("no-temp"); store.save(&Settings::default()).unwrap(); let leftovers: Vec<_> = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .filter(|n| n.ends_with(".tmp")) .collect(); assert!(leftovers.is_empty(), "left {leftovers:?} behind"); } }