use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub name: String,
pub session_id: String,
pub cwd: String,
pub backend: String,
pub jsonl_path: PathBuf,
pub permission_mode: Option<String>,
pub created: u64,
}
impl Session {
pub fn sidecar_path(name: &str) -> Result<PathBuf> {
validate_name(name)?;
Ok(sessions_dir()?.join(format!("{name}.json")))
}
pub fn save(&self) -> Result<()> {
let path = Session::sidecar_path(&self.name)?; let dir = sessions_dir()?;
fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
let body = serde_json::to_string_pretty(self)?;
fs::write(&path, body).map_err(|e| Error::io(&path, e))
}
pub fn load(name: &str) -> Result<Session> {
let path = Session::sidecar_path(name)?;
let body = fs::read_to_string(&path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => Error::NoSuchSession(name.to_string()),
_ => Error::io(&path, e),
})?;
Ok(serde_json::from_str(&body)?)
}
pub fn delete(name: &str) -> Result<()> {
let path = Session::sidecar_path(name)?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::io(&path, e)),
}
}
pub fn list() -> Result<Vec<Session>> {
let dir = sessions_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
let entry = entry.map_err(|e| Error::io(&dir, e))?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(body) = fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<Session>(&body) {
sessions.push(session);
}
}
}
sessions.sort_by(|a, b| a.name.cmp(&b.name));
Ok(sessions)
}
}
pub fn validate_name(name: &str) -> Result<()> {
let starts_ok = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
let chars_ok = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if name.is_empty() || !starts_ok || !chars_ok || name.contains("..") {
return Err(Error::InvalidSessionName(name.to_string()));
}
Ok(())
}
fn sessions_dir() -> Result<PathBuf> {
let base = dirs::state_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
.ok_or(Error::NoDir { what: "state" })?;
Ok(base.join("csd").join("sessions"))
}
pub fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn cwd_slug(cwd: &str) -> String {
cwd.replace('/', "-")
}
pub fn jsonl_path(cwd: &str, session_id: &str) -> Result<PathBuf> {
let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
Ok(home
.join(".claude")
.join("projects")
.join(cwd_slug(cwd))
.join(format!("{session_id}.jsonl")))
}
pub fn plans_dir() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
Ok(home.join(".claude").join("plans"))
}
pub fn mtime_epoch(path: &Path) -> Option<u64> {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_replaces_every_slash() {
assert_eq!(cwd_slug("/tmp/claude-itest1"), "-tmp-claude-itest1");
assert_eq!(cwd_slug("/home/marshall/dev/csd"), "-home-marshall-dev-csd");
}
#[test]
fn accepts_normal_names() {
for name in ["csd-csd-abc123", "agent_1", "Foo.bar-2"] {
assert!(validate_name(name).is_ok(), "{name} should be valid");
}
}
#[test]
fn rejects_path_traversal_and_separators() {
for name in ["../x", "..", "a/b", "/etc/passwd", "-rf", "", "a..b", "foo/../bar"] {
assert!(validate_name(name).is_err(), "{name} should be rejected");
}
}
}