//! 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, /// 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, } 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 { 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()); } }