From eac640225fc7a09c077ab998447cca3b3198ec75 Mon Sep 17 00:00:00 2001 From: teernisse Date: Mon, 23 Feb 2026 13:04:30 -0500 Subject: [PATCH] feat(core): add cursor persistence module for session-based timestamps Introduces a lightweight file-based cursor system for persisting per-user timestamps across CLI invocations. This enables "since last check" semantics where `lore me` can track what the user has seen. Key design decisions: - Per-user cursor files: ~/.local/share/lore/me_cursor_.json - Atomic writes via temp-file + rename pattern (crash-safe) - Graceful degradation: missing/corrupt files return None - Username sanitization: non-safe chars replaced with underscore The cursor module provides three operations: - read_cursor(username) -> Option: read last-check timestamp - write_cursor(username, timestamp_ms): atomically persist timestamp - reset_cursor(username): delete cursor file (no-op if missing) Tests cover: missing file, roundtrip, per-user isolation, reset isolation, JSON validity after overwrites, corrupt file handling. Co-Authored-By: Claude Opus 4.5 --- src/core/cursor.rs | 152 +++++++++++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + src/core/paths.rs | 14 +++++ 3 files changed, 167 insertions(+) create mode 100644 src/core/cursor.rs diff --git a/src/core/cursor.rs b/src/core/cursor.rs new file mode 100644 index 0000000..2b935f1 --- /dev/null +++ b/src/core/cursor.rs @@ -0,0 +1,152 @@ +// ─── 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()); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index ab16989..5186330 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod backoff; pub mod config; #[cfg(unix)] pub mod cron; +pub mod cursor; pub mod db; pub mod dependent_queue; pub mod error; diff --git a/src/core/paths.rs b/src/core/paths.rs index b59ee08..1416d31 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -40,6 +40,20 @@ pub fn get_log_dir(config_override: Option<&str>) -> PathBuf { get_data_dir().join("logs") } +pub fn get_cursor_path(username: &str) -> PathBuf { + let safe_username: String = username + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') { + ch + } else { + '_' + } + }) + .collect(); + get_data_dir().join(format!("me_cursor_{safe_username}.json")) +} + pub fn get_backup_dir(config_override: Option<&str>) -> PathBuf { if let Some(path) = config_override { return PathBuf::from(path);