Files
gitlore/src/core/cursor.rs
teernisse eac640225f 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>
2026-02-25 10:02:13 -05:00

153 lines
5.0 KiB
Rust

// ─── 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());
}
}