use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::Path; use std::time::SystemTime; use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowState { pub width: i32, pub height: i32, pub x: Option, pub y: Option, } impl Default for WindowState { fn default() -> Self { Self { width: 1320, height: 860, x: None, y: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Prefs { #[serde(default = "default_version")] pub version: u32, #[serde(default = "default_ui_zoom")] pub ui_zoom: f64, #[serde(default = "default_split_ratio")] pub split_ratio: f64, #[serde(default = "default_dock_ratio")] pub dock_ratio: f64, #[serde(default)] pub always_on_top: bool, #[serde(default)] pub window: WindowState, #[serde(default)] pub last_folder_path: Option, #[serde(default)] pub last_library_id: Option, #[serde(default)] pub updated_at: u64, } fn default_version() -> u32 { 19 } fn default_ui_zoom() -> f64 { 1.0 } fn default_split_ratio() -> f64 { 0.62 } fn default_dock_ratio() -> f64 { 0.62 } impl Default for Prefs { fn default() -> Self { Self { version: 19, ui_zoom: 1.0, split_ratio: 0.62, dock_ratio: 0.62, always_on_top: false, window: WindowState::default(), last_folder_path: None, last_library_id: None, updated_at: 0, } } } impl Prefs { /// Load preferences from `{state_dir}/prefs.json`, merging with defaults. /// /// Window fields are merged individually so that a saved file with only /// `{"window": {"width": 800}}` keeps the default height, x, and y. pub fn load(state_dir: &Path) -> Prefs { let path = state_dir.join("prefs.json"); let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); let raw = match loaded { Some(v) => v, None => return Prefs::default(), }; let raw_obj = match raw.as_object() { Some(obj) => obj, None => return Prefs::default(), }; // Start with defaults let mut prefs = Prefs::default(); // Merge window fields individually if present if let Some(Value::Object(win_obj)) = raw_obj.get("window") { if let Some(Value::Number(n)) = win_obj.get("width") { if let Some(v) = n.as_i64() { prefs.window.width = v as i32; } } if let Some(Value::Number(n)) = win_obj.get("height") { if let Some(v) = n.as_i64() { prefs.window.height = v as i32; } } if let Some(val) = win_obj.get("x") { prefs.window.x = val.as_i64().map(|v| v as i32); } if let Some(val) = win_obj.get("y") { prefs.window.y = val.as_i64().map(|v| v as i32); } } // Merge all other top-level keys if let Some(Value::Number(n)) = raw_obj.get("version") { if let Some(v) = n.as_u64() { prefs.version = v as u32; } } if let Some(Value::Number(n)) = raw_obj.get("ui_zoom") { if let Some(v) = n.as_f64() { prefs.ui_zoom = v; } } if let Some(Value::Number(n)) = raw_obj.get("split_ratio") { if let Some(v) = n.as_f64() { prefs.split_ratio = v; } } if let Some(Value::Number(n)) = raw_obj.get("dock_ratio") { if let Some(v) = n.as_f64() { prefs.dock_ratio = v; } } if let Some(Value::Bool(b)) = raw_obj.get("always_on_top") { prefs.always_on_top = *b; } if let Some(val) = raw_obj.get("last_folder_path") { prefs.last_folder_path = val.as_str().map(|s| s.to_string()); } if let Some(val) = raw_obj.get("last_library_id") { prefs.last_library_id = val.as_str().map(|s| s.to_string()); } if let Some(Value::Number(n)) = raw_obj.get("updated_at") { if let Some(v) = n.as_u64() { prefs.updated_at = v; } } prefs } /// Save preferences to `{state_dir}/prefs.json`. pub fn save(&self, state_dir: &Path) { let path = state_dir.join("prefs.json"); let value = serde_json::to_value(self).expect("failed to serialize Prefs"); atomic_write_json(&path, &value, BACKUP_COUNT); } /// Apply a partial JSON update, merge window fields individually, set /// `updated_at` to the current unix timestamp, and save. pub fn update(&mut self, patch: &Value, state_dir: &Path) { if let Some(obj) = patch.as_object() { // Merge window fields individually if let Some(Value::Object(win_obj)) = obj.get("window") { if let Some(Value::Number(n)) = win_obj.get("width") { if let Some(v) = n.as_i64() { self.window.width = v as i32; } } if let Some(Value::Number(n)) = win_obj.get("height") { if let Some(v) = n.as_i64() { self.window.height = v as i32; } } if let Some(val) = win_obj.get("x") { self.window.x = val.as_i64().map(|v| v as i32); } if let Some(val) = win_obj.get("y") { self.window.y = val.as_i64().map(|v| v as i32); } } // Apply other top-level keys if let Some(Value::Number(n)) = obj.get("version") { if let Some(v) = n.as_u64() { self.version = v as u32; } } if let Some(Value::Number(n)) = obj.get("ui_zoom") { if let Some(v) = n.as_f64() { self.ui_zoom = v; } } if let Some(Value::Number(n)) = obj.get("split_ratio") { if let Some(v) = n.as_f64() { self.split_ratio = v; } } if let Some(Value::Number(n)) = obj.get("dock_ratio") { if let Some(v) = n.as_f64() { self.dock_ratio = v; } } if let Some(Value::Bool(b)) = obj.get("always_on_top") { self.always_on_top = *b; } if let Some(val) = obj.get("last_folder_path") { self.last_folder_path = val.as_str().map(|s| s.to_string()); } if let Some(val) = obj.get("last_library_id") { self.last_library_id = val.as_str().map(|s| s.to_string()); } // Set updated_at to current unix timestamp self.updated_at = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); self.save(state_dir); } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; use tempfile::TempDir; #[test] fn test_default_prefs() { let prefs = Prefs::default(); assert_eq!(prefs.version, 19); assert_eq!(prefs.ui_zoom, 1.0); assert_eq!(prefs.split_ratio, 0.62); assert_eq!(prefs.dock_ratio, 0.62); assert!(!prefs.always_on_top); assert_eq!(prefs.window.width, 1320); assert_eq!(prefs.window.height, 860); assert_eq!(prefs.window.x, None); assert_eq!(prefs.window.y, None); assert_eq!(prefs.last_folder_path, None); assert_eq!(prefs.last_library_id, None); assert_eq!(prefs.updated_at, 0); } #[test] fn test_default_window_state() { let ws = WindowState::default(); assert_eq!(ws.width, 1320); assert_eq!(ws.height, 860); assert_eq!(ws.x, None); assert_eq!(ws.y, None); } #[test] fn test_save_and_load_round_trip() { let dir = TempDir::new().unwrap(); let mut prefs = Prefs::default(); prefs.ui_zoom = 1.5; prefs.split_ratio = 0.75; prefs.always_on_top = true; prefs.window.width = 1920; prefs.window.height = 1080; prefs.window.x = Some(100); prefs.window.y = Some(200); prefs.last_folder_path = Some("/my/folder".to_string()); prefs.last_library_id = Some("lib-abc".to_string()); prefs.updated_at = 1234567890; prefs.save(dir.path()); let loaded = Prefs::load(dir.path()); assert_eq!(loaded.version, 19); assert_eq!(loaded.ui_zoom, 1.5); assert_eq!(loaded.split_ratio, 0.75); assert_eq!(loaded.dock_ratio, 0.62); assert!(loaded.always_on_top); assert_eq!(loaded.window.width, 1920); assert_eq!(loaded.window.height, 1080); assert_eq!(loaded.window.x, Some(100)); assert_eq!(loaded.window.y, Some(200)); assert_eq!(loaded.last_folder_path, Some("/my/folder".to_string())); assert_eq!(loaded.last_library_id, Some("lib-abc".to_string())); assert_eq!(loaded.updated_at, 1234567890); } #[test] fn test_load_nonexistent_returns_defaults() { let dir = TempDir::new().unwrap(); let prefs = Prefs::load(dir.path()); assert_eq!(prefs.version, 19); assert_eq!(prefs.ui_zoom, 1.0); assert_eq!(prefs.window.width, 1320); } #[test] fn test_partial_update() { let dir = TempDir::new().unwrap(); let mut prefs = Prefs::default(); let patch = json!({ "ui_zoom": 2.0, "always_on_top": true }); prefs.update(&patch, dir.path()); assert_eq!(prefs.ui_zoom, 2.0); assert!(prefs.always_on_top); // Unchanged fields stay at defaults assert_eq!(prefs.split_ratio, 0.62); assert_eq!(prefs.dock_ratio, 0.62); assert_eq!(prefs.window.width, 1320); // updated_at should have been set assert!(prefs.updated_at > 0); } #[test] fn test_update_saves_to_disk() { let dir = TempDir::new().unwrap(); let mut prefs = Prefs::default(); let patch = json!({"ui_zoom": 3.0}); prefs.update(&patch, dir.path()); let loaded = Prefs::load(dir.path()); assert_eq!(loaded.ui_zoom, 3.0); } #[test] fn test_window_merge_on_load() { let dir = TempDir::new().unwrap(); // Write a JSON file with only partial window data let partial = json!({ "window": {"width": 800} }); let path = dir.path().join("prefs.json"); atomic_write_json(&path, &partial, BACKUP_COUNT); let prefs = Prefs::load(dir.path()); // Width should be overridden assert_eq!(prefs.window.width, 800); // Height should remain at default assert_eq!(prefs.window.height, 860); // x, y should remain None (defaults) assert_eq!(prefs.window.x, None); assert_eq!(prefs.window.y, None); } #[test] fn test_window_merge_on_update() { let dir = TempDir::new().unwrap(); let mut prefs = Prefs::default(); prefs.window.width = 1000; prefs.window.height = 700; prefs.window.x = Some(50); prefs.window.y = Some(60); // Patch only width — height, x, y should remain let patch = json!({ "window": {"width": 1600} }); prefs.update(&patch, dir.path()); assert_eq!(prefs.window.width, 1600); assert_eq!(prefs.window.height, 700); assert_eq!(prefs.window.x, Some(50)); assert_eq!(prefs.window.y, Some(60)); } #[test] fn test_window_merge_with_position() { let dir = TempDir::new().unwrap(); // Saved file has window with x/y set let saved = json!({ "version": 19, "window": {"width": 1320, "height": 860, "x": 150, "y": 250} }); let path = dir.path().join("prefs.json"); atomic_write_json(&path, &saved, BACKUP_COUNT); let prefs = Prefs::load(dir.path()); assert_eq!(prefs.window.x, Some(150)); assert_eq!(prefs.window.y, Some(250)); } #[test] fn test_load_with_missing_fields_uses_defaults() { let dir = TempDir::new().unwrap(); // Write a JSON with only version — everything else should be defaults let partial = json!({"version": 20}); let path = dir.path().join("prefs.json"); atomic_write_json(&path, &partial, BACKUP_COUNT); let prefs = Prefs::load(dir.path()); assert_eq!(prefs.version, 20); assert_eq!(prefs.ui_zoom, 1.0); assert_eq!(prefs.split_ratio, 0.62); assert_eq!(prefs.window.width, 1320); assert_eq!(prefs.window.height, 860); assert_eq!(prefs.last_folder_path, None); } #[test] fn test_update_with_non_object_is_noop() { let dir = TempDir::new().unwrap(); let mut prefs = Prefs::default(); let original_zoom = prefs.ui_zoom; prefs.update(&json!("not an object"), dir.path()); // Nothing should have changed assert_eq!(prefs.ui_zoom, original_zoom); assert_eq!(prefs.updated_at, 0); } #[test] fn test_window_null_xy_in_update() { let dir = TempDir::new().unwrap(); let mut prefs = Prefs::default(); prefs.window.x = Some(100); prefs.window.y = Some(200); // Setting x/y to null should clear them let patch = json!({ "window": {"x": null, "y": null} }); prefs.update(&patch, dir.path()); assert_eq!(prefs.window.x, None); assert_eq!(prefs.window.y, None); } }