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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod backoff;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod cron;
|
pub mod cron;
|
||||||
|
pub mod cursor;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod dependent_queue;
|
pub mod dependent_queue;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ pub fn get_log_dir(config_override: Option<&str>) -> PathBuf {
|
|||||||
get_data_dir().join("logs")
|
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 {
|
pub fn get_backup_dir(config_override: Option<&str>) -> PathBuf {
|
||||||
if let Some(path) = config_override {
|
if let Some(path) = config_override {
|
||||||
return PathBuf::from(path);
|
return PathBuf::from(path);
|
||||||
|
|||||||
Reference in New Issue
Block a user