Mechanical formatting pass to satisfy rustfmt line-width limits and clippy pedantic/nursery lints. No behavioral changes. Formatting (rustfmt line wrapping): - action/sync.rs: multiline tuple destructure, function call args in tests - state/sync.rs: if-let chain formatting, remove unnecessary Vec collect - view/sync.rs: multiline array entries, format!(), vec! literals - view/doctor.rs: multiline floor_char_boundary chain - view/scope_picker.rs: multiline format!() with floor_char_boundary - view/stats.rs: multiline render_stat_row call - view/mod.rs: multiline assert!() in test - app/update.rs: multiline enum variant destructure - entity_cache.rs: multiline assert_eq!() with messages - render_cache.rs: multiline retain() closure - session.rs: multiline serde_json/File::create/parent() chains Clippy: - action/sync.rs: #[allow(clippy::too_many_arguments)] on test helper Import/module ordering (alphabetical): - state/mod.rs: move scope_picker mod + pub use to sorted position - view/mod.rs: move scope_picker, stats, sync mod + use to sorted position - view/scope_picker.rs: sort use imports (ScopeContext before ScopePickerState)
403 lines
13 KiB
Rust
403 lines
13 KiB
Rust
//! 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());
|
|
}
|
|
}
|