//! Session metadata: id, title, workspace roots, and message/token counts, //! persisted as `session.json` per session directory. use chrono::Utc; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// Metadata for one conversation session (distinct from the message /// history itself, which lives in `Conversation`/the msglog). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: String, pub created_at: i64, pub updated_at: i64, pub title: String, pub model: String, pub workspace_roots: Vec, pub message_count: u32, pub token_count: u32, pub archived: bool, pub summary: Option, } impl Session { /// Create a new session with the given id/title, defaulting the /// model, workspace root (current dir), and counters. pub fn new(id: String, title: String) -> Self { let now = Utc::now().timestamp_millis(); Session { id, created_at: now, updated_at: now, title, model: "anthropic/claude-opus-4-8".to_string(), workspace_roots: vec![std::env::current_dir().unwrap_or_default()], message_count: 0, token_count: 0, archived: false, summary: None, } } /// Compute this session's directory under `/sessions/`. pub fn session_dir(&self, base_dir: &Path) -> PathBuf { base_dir.join("sessions").join(&self.id) } /// Compute this session's `conversation.json` path. pub fn conversation_path(&self, base_dir: &Path) -> PathBuf { self.session_dir(base_dir).join("conversation.json") } /// Persist this session's metadata to `session.json`, atomically /// with fsync for crash safety. /// /// Flow: ensure the session directory exists → serialize to pretty /// JSON → write to `session.json.tmp` → fsync → rename over /// `session.json` → fsync parent directory. /// /// Why: write-then-rename avoids a torn/partial `session.json` if /// interrupted mid-write; fsync before rename ensures the data is /// on disk before the rename makes it visible. /// /// Return: `Ok(())` on success, or an `io::Error` from any step. pub fn save(&self, base_dir: &Path) -> std::io::Result<()> { let dir = self.session_dir(base_dir); std::fs::create_dir_all(&dir)?; let path = dir.join("session.json"); let data = serde_json::to_string_pretty(self)?; let tmp = dir.join("session.json.tmp"); std::fs::write(&tmp, data)?; let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, path)?; let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all()); Ok(()) } /// Load a session's metadata by id from `/sessions//session.json`. /// /// Security: the session id is validated to prevent directory traversal /// (e.g. `../../etc/passwd`). Only alphanumeric, hyphens, underscores, /// and dots are allowed — no path separators. /// /// Return: the parsed `Session`, or an `io::Error` if the file is /// missing or malformed. pub fn load(id: &str, base_dir: &Path) -> std::io::Result { // Reject session ids that contain path separators or parent dir refs if id.contains('/') || id.contains('\\') || id.contains("..") { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("invalid session id '{id}': must not contain path separators"), )); } let path = base_dir.join("sessions").join(id).join("session.json"); let data = std::fs::read_to_string(path)?; let session: Session = serde_json::from_str(&data)?; Ok(session) } /// List all loadable sessions under `/sessions/`. /// /// Flow: read the sessions directory → keep subdirectories → attempt /// `Session::load` for each by its directory name, discarding any /// that fail to load. /// /// Return: a `Vec`, empty if the directory can't be read or /// contains no valid sessions. pub fn list(base_dir: &Path) -> Vec { let sessions_dir = base_dir.join("sessions"); let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Vec::new(); }; entries .filter_map(std::result::Result::ok) .filter(|e| e.path().is_dir()) .filter_map(|e| { let id = e.file_name().to_string_lossy().to_string(); Session::load(&id, base_dir).ok() }) .collect() } }