feat(tui): Phase 4 completion + Phase 5 session/lock/text-width
Phase 4 (bd-1df9) — all 5 acceptance criteria met: - Sync screen with delta ledger (bd-2x2h, bd-y095) - Doctor screen with health checks (bd-2iqk) - Stats screen with document counts (bd-2iqk) - CLI integration: lore tui subcommand (bd-26lp) - CLI integration: lore sync --tui flag (bd-3l56) Phase 5 (bd-3h00) — session persistence + instance lock + text width: - text_width.rs: Unicode-aware measurement, truncation, padding (16 tests) - instance_lock.rs: Advisory PID lock with stale recovery (6 tests) - session.rs: Atomic write + CRC32 checksum + quarantine (9 tests) Closes: bd-26lp, bd-3h00, bd-3l56, bd-1df9, bd-y095
This commit is contained in:
406
crates/lore-tui/src/session.rs
Normal file
406
crates/lore-tui/src/session.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
//! Session state persistence — save on quit, restore on launch.
|
||||
//!
|
||||
//! Enables the TUI to resume where the user left off: current screen,
|
||||
//! navigation history, filter state, scroll positions.
|
||||
//!
|
||||
//! ## File format
|
||||
//!
|
||||
//! `session.json` is a versioned JSON blob with a CRC32 checksum appended
|
||||
//! as the last 8 hex characters. Writes are atomic (tmp → fsync → rename).
|
||||
//! Corrupt files are quarantined, not deleted.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum session file size (1 MB). Files larger than this are rejected.
|
||||
const MAX_SESSION_SIZE: u64 = 1_024 * 1_024;
|
||||
|
||||
/// Current session format version. Bump when the schema changes.
|
||||
const SESSION_VERSION: u32 = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted screen (decoupled from message::Screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lightweight screen identifier for serialization.
|
||||
///
|
||||
/// Decoupled from `message::Screen` so session persistence doesn't require
|
||||
/// `Serialize`/`Deserialize` on core types.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum PersistedScreen {
|
||||
Dashboard,
|
||||
IssueList,
|
||||
IssueDetail { project_id: i64, iid: i64 },
|
||||
MrList,
|
||||
MrDetail { project_id: i64, iid: i64 },
|
||||
Search,
|
||||
Timeline,
|
||||
Who,
|
||||
Trace,
|
||||
FileHistory,
|
||||
Sync,
|
||||
Stats,
|
||||
Doctor,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Versioned session state persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionState {
|
||||
/// Format version for migration.
|
||||
pub version: u32,
|
||||
/// Screen to restore on launch.
|
||||
pub current_screen: PersistedScreen,
|
||||
/// Navigation history (back stack).
|
||||
pub nav_history: Vec<PersistedScreen>,
|
||||
/// Per-screen filter text (screen name -> filter string).
|
||||
pub filters: Vec<(String, String)>,
|
||||
/// Per-screen scroll offset (screen name -> offset).
|
||||
pub scroll_offsets: Vec<(String, u16)>,
|
||||
/// Global scope project path filter (if set).
|
||||
pub global_scope: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SESSION_VERSION,
|
||||
current_screen: PersistedScreen::Dashboard,
|
||||
nav_history: Vec::new(),
|
||||
filters: Vec::new(),
|
||||
scroll_offsets: Vec::new(),
|
||||
global_scope: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save / Load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Save session state atomically.
|
||||
///
|
||||
/// Writes to a temp file, fsyncs, appends CRC32 checksum, then renames
|
||||
/// over the target path. This prevents partial writes on crash.
|
||||
pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionError> {
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| SessionError::Serialize(e.to_string()))?;
|
||||
|
||||
// Check size before writing.
|
||||
if json.len() as u64 > MAX_SESSION_SIZE {
|
||||
return Err(SessionError::TooLarge {
|
||||
size: json.len() as u64,
|
||||
max: MAX_SESSION_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute CRC32 over the JSON payload.
|
||||
let checksum = crc32fast::hash(json.as_bytes());
|
||||
let payload = format!("{json}\n{checksum:08x}");
|
||||
|
||||
// Write to temp file, fsync, rename.
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let mut file =
|
||||
fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
file.write_all(payload.as_bytes())
|
||||
.map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
drop(file);
|
||||
|
||||
fs::rename(&tmp_path, path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load session state from disk.
|
||||
///
|
||||
/// Validates CRC32 checksum. On corruption, quarantines the file and
|
||||
/// returns `SessionError::Corrupt`.
|
||||
pub fn load_session(path: &Path) -> Result<SessionState, SessionError> {
|
||||
if !path.exists() {
|
||||
return Err(SessionError::NotFound);
|
||||
}
|
||||
|
||||
// Check file size before reading.
|
||||
let metadata = fs::metadata(path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
if metadata.len() > MAX_SESSION_SIZE {
|
||||
quarantine(path)?;
|
||||
return Err(SessionError::TooLarge {
|
||||
size: metadata.len(),
|
||||
max: MAX_SESSION_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
|
||||
// Split: everything before the last newline is JSON, after is the checksum.
|
||||
let (json, checksum_hex) = raw
|
||||
.rsplit_once('\n')
|
||||
.ok_or_else(|| SessionError::Corrupt("no checksum separator".into()))?;
|
||||
|
||||
// Validate checksum.
|
||||
let expected = u32::from_str_radix(checksum_hex.trim(), 16)
|
||||
.map_err(|_| SessionError::Corrupt("invalid checksum hex".into()))?;
|
||||
let actual = crc32fast::hash(json.as_bytes());
|
||||
if actual != expected {
|
||||
quarantine(path)?;
|
||||
return Err(SessionError::Corrupt(format!(
|
||||
"CRC32 mismatch: expected {expected:08x}, got {actual:08x}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Deserialize.
|
||||
let state: SessionState = serde_json::from_str(json)
|
||||
.map_err(|e| SessionError::Corrupt(format!("JSON parse error: {e}")))?;
|
||||
|
||||
// Version check — future-proof: reject newer versions, accept current.
|
||||
if state.version > SESSION_VERSION {
|
||||
return Err(SessionError::Corrupt(format!(
|
||||
"session version {} is newer than supported ({})",
|
||||
state.version, SESSION_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Move a corrupt session file to `.quarantine/` instead of deleting it.
|
||||
fn quarantine(path: &Path) -> Result<(), SessionError> {
|
||||
let quarantine_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(".quarantine");
|
||||
fs::create_dir_all(&quarantine_dir).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let quarantine_path = quarantine_dir.join(format!("{filename}.{ts}"));
|
||||
|
||||
fs::rename(path, &quarantine_path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Session persistence errors.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SessionError {
|
||||
/// Session file not found (first launch).
|
||||
NotFound,
|
||||
/// File is corrupt (bad checksum, invalid JSON, etc.).
|
||||
Corrupt(String),
|
||||
/// File exceeds size limit.
|
||||
TooLarge { size: u64, max: u64 },
|
||||
/// I/O error.
|
||||
Io(String),
|
||||
/// Serialization error.
|
||||
Serialize(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound => write!(f, "session file not found"),
|
||||
Self::Corrupt(msg) => write!(f, "corrupt session: {msg}"),
|
||||
Self::TooLarge { size, max } => {
|
||||
write!(f, "session file too large ({size} bytes, max {max})")
|
||||
}
|
||||
Self::Io(msg) => write!(f, "session I/O error: {msg}"),
|
||||
Self::Serialize(msg) => write!(f, "session serialization error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SessionError {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_state() -> SessionState {
|
||||
SessionState {
|
||||
version: SESSION_VERSION,
|
||||
current_screen: PersistedScreen::IssueList,
|
||||
nav_history: vec![PersistedScreen::Dashboard],
|
||||
filters: vec![("IssueList".into(), "bug".into())],
|
||||
scroll_offsets: vec![("IssueList".into(), 5)],
|
||||
global_scope: Some("group/project".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
|
||||
let state = sample_state();
|
||||
save_session(&state, &path).unwrap();
|
||||
|
||||
let loaded = load_session(&path).unwrap();
|
||||
assert_eq!(state, loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_default_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
|
||||
let state = SessionState::default();
|
||||
save_session(&state, &path).unwrap();
|
||||
|
||||
let loaded = load_session(&path).unwrap();
|
||||
assert_eq!(state, loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
|
||||
let result = load_session(&path);
|
||||
assert_eq!(result.unwrap_err(), SessionError::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_corruption_detected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
|
||||
let state = sample_state();
|
||||
save_session(&state, &path).unwrap();
|
||||
|
||||
// Tamper with the file — modify a byte in the JSON section.
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
let tampered = raw.replacen("IssueList", "MrList___", 1);
|
||||
fs::write(&path, tampered).unwrap();
|
||||
|
||||
let result = load_session(&path);
|
||||
assert!(matches!(result, Err(SessionError::Corrupt(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_corruption_quarantines_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
|
||||
let state = sample_state();
|
||||
save_session(&state, &path).unwrap();
|
||||
|
||||
// Tamper with the checksum line.
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
let tampered = format!("{}\ndeadbeef", raw.rsplit_once('\n').unwrap().0);
|
||||
fs::write(&path, tampered).unwrap();
|
||||
|
||||
let _ = load_session(&path);
|
||||
|
||||
// Original file should be gone.
|
||||
assert!(!path.exists());
|
||||
|
||||
// Quarantine directory should contain the file.
|
||||
let quarantine_dir = dir.path().join(".quarantine");
|
||||
assert!(quarantine_dir.exists());
|
||||
let entries: Vec<_> = fs::read_dir(&quarantine_dir).unwrap().collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_creates_parent_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join("a").join("b").join("session.json");
|
||||
|
||||
let state = SessionState::default();
|
||||
save_session(&state, &nested).unwrap();
|
||||
assert!(nested.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_persisted_screen_variants() {
|
||||
let screens = vec![
|
||||
PersistedScreen::Dashboard,
|
||||
PersistedScreen::IssueList,
|
||||
PersistedScreen::IssueDetail {
|
||||
project_id: 1,
|
||||
iid: 42,
|
||||
},
|
||||
PersistedScreen::MrList,
|
||||
PersistedScreen::MrDetail {
|
||||
project_id: 2,
|
||||
iid: 99,
|
||||
},
|
||||
PersistedScreen::Search,
|
||||
PersistedScreen::Timeline,
|
||||
PersistedScreen::Who,
|
||||
PersistedScreen::Trace,
|
||||
PersistedScreen::FileHistory,
|
||||
PersistedScreen::Sync,
|
||||
PersistedScreen::Stats,
|
||||
PersistedScreen::Doctor,
|
||||
];
|
||||
|
||||
for screen in screens {
|
||||
let state = SessionState {
|
||||
current_screen: screen.clone(),
|
||||
..SessionState::default()
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
save_session(&state, &path).unwrap();
|
||||
let loaded = load_session(&path).unwrap();
|
||||
assert_eq!(state.current_screen, loaded.current_screen);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_max_size_enforced() {
|
||||
let state = SessionState {
|
||||
filters: (0..100_000)
|
||||
.map(|i| (format!("key_{i}"), "x".repeat(100)))
|
||||
.collect(),
|
||||
..SessionState::default()
|
||||
};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
|
||||
let result = save_session(&state, &path);
|
||||
assert!(matches!(result, Err(SessionError::TooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_atomic_write_no_partial() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
|
||||
let state = sample_state();
|
||||
save_session(&state, &path).unwrap();
|
||||
|
||||
// After save, no tmp file should remain.
|
||||
assert!(!tmp_path.exists());
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user