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:
teernisse
2026-02-18 23:40:30 -05:00
parent 418417b0f4
commit 146eb61623
45 changed files with 5216 additions and 207 deletions

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