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_<username>.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<i64>: 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 <noreply@anthropic.com>
This commit is contained in:
152
src/core/cursor.rs
Normal file
152
src/core/cursor.rs
Normal file
@@ -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_<username>.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<i64> {
|
||||
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<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn with_temp_xdg_data_home<T>(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<CursorFile> = serde_json::from_str(bad_json).ok();
|
||||
assert!(parsed.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user