//! User preferences persisted to the config directory. //! //! Lives at `config_dir/preferences.json`. Only options that need to survive //! across launches and that the user can change at runtime belong here. //! Build-time settings stay in `synckit.toml` / env vars. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; const PREFERENCES_FILENAME: &str = "preferences.json"; fn default_check_for_updates() -> bool { true } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Preferences { /// Whether the OTA update checker is allowed to contact makenot.work. /// Defaults to `true`. Users on metered networks or in privacy-sensitive /// environments can disable from the About box. #[serde(default = "default_check_for_updates")] pub check_for_updates: bool, } impl Default for Preferences { fn default() -> Self { Self { check_for_updates: default_check_for_updates(), } } } fn path_for(config_dir: &Path) -> PathBuf { config_dir.join(PREFERENCES_FILENAME) } impl Preferences { pub fn load(config_dir: &Path) -> Self { let path = path_for(config_dir); match std::fs::read_to_string(&path) { Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| { tracing::warn!("Failed to parse {}: {e}; using defaults", path.display()); Preferences::default() }), Err(_) => Preferences::default(), } } pub fn save(&self, config_dir: &Path) { let path = path_for(config_dir); match serde_json::to_string_pretty(self) { Ok(s) => { if let Err(e) = std::fs::write(&path, s) { tracing::warn!("Failed to write {}: {e}", path.display()); } } Err(e) => tracing::warn!("Failed to serialise preferences: {e}"), } } } #[cfg(test)] mod tests { use super::*; #[test] fn default_enables_update_checks() { let p = Preferences::default(); assert!(p.check_for_updates); } #[test] fn missing_file_returns_defaults() { let dir = tempfile::tempdir().unwrap(); let p = Preferences::load(dir.path()); assert!(p.check_for_updates); } #[test] fn save_then_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); let p = Preferences { check_for_updates: false }; p.save(dir.path()); let loaded = Preferences::load(dir.path()); assert!(!loaded.check_for_updates); } #[test] fn unknown_field_in_file_does_not_break_load() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join(PREFERENCES_FILENAME), r#"{"check_for_updates": false, "future_field": 42}"#, ) .unwrap(); let loaded = Preferences::load(dir.path()); assert!(!loaded.check_for_updates); } #[test] fn missing_check_for_updates_field_defaults_to_true() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join(PREFERENCES_FILENAME), r#"{}"#).unwrap(); let loaded = Preferences::load(dir.path()); assert!(loaded.check_for_updates); } #[test] fn corrupt_file_falls_back_to_defaults() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join(PREFERENCES_FILENAME), "{not json").unwrap(); let loaded = Preferences::load(dir.path()); assert!(loaded.check_for_updates); } }