// ─── Me Cursor Persistence ────────────────────────────────────────────────── // // File-based cursor for the "since last check" section of `lore me`. // Stores per-user timestamps in ~/.local/share/lore/me_cursor_.json. use std::io; use std::io::Write; use serde::{Deserialize, Serialize}; use super::paths::get_cursor_path; #[derive(Serialize, Deserialize)] struct CursorFile { last_check_ms: i64, } /// Read the last-check cursor. Returns `None` if the file doesn't exist or is corrupt. pub fn read_cursor(username: &str) -> Option { let path = get_cursor_path(username); let data = std::fs::read_to_string(path).ok()?; let cursor: CursorFile = serde_json::from_str(&data).ok()?; Some(cursor.last_check_ms) } /// Write the last-check cursor atomically. pub fn write_cursor(username: &str, timestamp_ms: i64) -> io::Result<()> { let path = get_cursor_path(username); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; let cursor = CursorFile { last_check_ms: timestamp_ms, }; let json = serde_json::to_string(&cursor).map_err(io::Error::other)?; let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("me_cursor.json"); let temp_path = parent.join(format!(".{file_name}.{nonce}.tmp")); { let mut temp_file = std::fs::File::create(&temp_path)?; temp_file.write_all(json.as_bytes())?; temp_file.sync_all()?; } std::fs::rename(&temp_path, &path)?; return Ok(()); } Err(io::Error::new( io::ErrorKind::InvalidInput, "cursor path has no parent directory", )) } /// Reset the cursor by deleting the file. No-op if it doesn't exist. pub fn reset_cursor(username: &str) -> io::Result<()> { let path = get_cursor_path(username); match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e), } } #[cfg(test)] mod tests { use super::*; use std::sync::{Mutex, OnceLock}; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn with_temp_xdg_data_home(f: impl FnOnce() -> T) -> T { let _guard = env_lock().lock().unwrap(); let previous = std::env::var_os("XDG_DATA_HOME"); let dir = tempfile::tempdir().unwrap(); // SAFETY: test-only scoped env override. unsafe { std::env::set_var("XDG_DATA_HOME", dir.path()) }; let result = f(); match previous { Some(value) => { // SAFETY: restoring prior environment for test isolation. unsafe { std::env::set_var("XDG_DATA_HOME", value) }; } None => { // SAFETY: restoring prior environment for test isolation. unsafe { std::env::remove_var("XDG_DATA_HOME") }; } } result } #[test] fn read_cursor_returns_none_when_missing() { with_temp_xdg_data_home(|| { assert_eq!(read_cursor("alice"), None); }); } #[test] fn cursor_roundtrip() { with_temp_xdg_data_home(|| { write_cursor("alice", 1_700_000_000_000).unwrap(); assert_eq!(read_cursor("alice"), Some(1_700_000_000_000)); }); } #[test] fn cursor_isolated_per_user() { with_temp_xdg_data_home(|| { write_cursor("alice", 100).unwrap(); write_cursor("bob", 200).unwrap(); assert_eq!(read_cursor("alice"), Some(100)); assert_eq!(read_cursor("bob"), Some(200)); }); } #[test] fn reset_cursor_only_affects_target_user() { with_temp_xdg_data_home(|| { write_cursor("alice", 100).unwrap(); write_cursor("bob", 200).unwrap(); reset_cursor("alice").unwrap(); assert_eq!(read_cursor("alice"), None); assert_eq!(read_cursor("bob"), Some(200)); }); } #[test] fn cursor_write_keeps_valid_json() { with_temp_xdg_data_home(|| { write_cursor("alice", 111).unwrap(); write_cursor("alice", 222).unwrap(); let data = std::fs::read_to_string(get_cursor_path("alice")).unwrap(); let parsed: CursorFile = serde_json::from_str(&data).unwrap(); assert_eq!(parsed.last_check_ms, 222); }); } #[test] fn parse_corrupt_json_returns_none() { let bad_json = "not json at all"; let parsed: Option = serde_json::from_str(bad_json).ok(); assert!(parsed.is_none()); } }