use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::state::TimerPhase; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub work_duration_secs: u64, pub short_break_secs: u64, pub long_break_secs: u64, pub sessions_before_long_break: u32, } impl Default for Settings { fn default() -> Self { Self { work_duration_secs: 25 * 60, short_break_secs: 5 * 60, long_break_secs: 15 * 60, sessions_before_long_break: 4, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Task { pub id: String, pub name: String, pub total_sessions: u32, pub remaining_sessions: u32, pub completed: bool, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimerSnapshot { pub phase: TimerPhase, pub remaining_secs: u64, pub total_secs: u64, pub running: bool, pub session_count: u32, pub current_task_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppData { pub settings: Settings, pub tasks: Vec, pub current_task_id: Option, #[serde(default)] pub timer_snapshot: Option, } impl Default for AppData { fn default() -> Self { Self { settings: Settings::default(), tasks: Vec::new(), current_task_id: None, timer_snapshot: None, } } } pub fn data_path(app_data_dir: &PathBuf) -> PathBuf { app_data_dir.join("data.json") } pub fn load(app_data_dir: &PathBuf) -> AppData { let path = data_path(app_data_dir); if !path.exists() { return AppData::default(); } let mut data = match fs::read_to_string(&path) { Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), Err(_) => AppData::default(), }; // Guard against a corrupt/zero value that would cause division by zero if data.settings.sessions_before_long_break == 0 { data.settings.sessions_before_long_break = Settings::default().sessions_before_long_break; } data } pub fn save(app_data_dir: &PathBuf, data: &AppData) -> Result<(), String> { fs::create_dir_all(app_data_dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; let path = data_path(app_data_dir); let contents = serde_json::to_string_pretty(data) .map_err(|e| format!("Failed to serialize data: {}", e))?; let tmp_path = app_data_dir.join("data.json.tmp"); fs::write(&tmp_path, contents) .map_err(|e| format!("Failed to write temp data file: {}", e))?; if cfg!(windows) { if path.exists() { fs::remove_file(&path) .map_err(|e| format!("Failed to remove existing data file: {}", e))?; } } fs::rename(&tmp_path, &path) .map_err(|e| format!("Failed to finalize data file: {}", e))?; Ok(()) } #[cfg(test)] mod tests { use super::{load, save, AppData, Settings, Task, TimerSnapshot}; use crate::state::TimerPhase; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_dir(label: &str) -> std::path::PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!("solstice-storage-{label}-{unique}")) } #[test] fn load_preserves_persisted_timer_snapshot() { let dir = temp_dir("load-timer-snapshot"); fs::create_dir_all(&dir).unwrap(); let json = serde_json::json!({ "settings": { "work_duration_secs": 1500, "short_break_secs": 300, "long_break_secs": 900, "sessions_before_long_break": 4 }, "tasks": [], "current_task_id": "task-1", "timer_snapshot": { "phase": "short_break", "remaining_secs": 120, "total_secs": 300, "running": false, "session_count": 2, "current_task_id": "task-1" } }) .to_string(); fs::write(dir.join("data.json"), json).unwrap(); let data = load(&dir); let snapshot = data.timer_snapshot.expect("expected timer snapshot"); assert_eq!(snapshot.phase, TimerPhase::ShortBreak); assert_eq!(snapshot.remaining_secs, 120); assert_eq!(snapshot.total_secs, 300); assert_eq!(snapshot.session_count, 2); assert_eq!(snapshot.current_task_id.as_deref(), Some("task-1")); } #[test] fn save_persists_timer_snapshot() { let dir = temp_dir("save-timer-snapshot"); let data = AppData { settings: Settings::default(), tasks: vec![Task { id: "task-1".to_string(), name: "Deep work".to_string(), total_sessions: 4, remaining_sessions: 3, completed: false, created_at: "2026-06-09T00:00:00Z".to_string(), }], current_task_id: Some("task-1".to_string()), timer_snapshot: Some(TimerSnapshot { phase: TimerPhase::Work, remaining_secs: 600, total_secs: 1500, running: true, session_count: 1, current_task_id: Some("task-1".to_string()), }), }; save(&dir, &data).unwrap(); let reloaded = load(&dir); let snapshot = reloaded.timer_snapshot.expect("expected timer snapshot"); assert!(snapshot.running); assert_eq!(snapshot.remaining_secs, 600); assert_eq!(snapshot.total_secs, 1500); } }