feat: implement prefs.rs - preferences with save/load/update

This commit is contained in:
Your Name
2026-02-19 01:50:20 +02:00
parent 6ecbeb9a9b
commit b95094c50f
2 changed files with 443 additions and 0 deletions

441
src-tauri/src/prefs.rs Normal file
View File

@@ -0,0 +1,441 @@
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<i32>,
pub y: Option<i32>,
}
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<String>,
#[serde(default)]
pub last_library_id: Option<String>,
#[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);
}
}