1use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Session {
18 pub name: String,
20 pub session_id: String,
22 pub cwd: String,
24 pub backend: String,
26 pub jsonl_path: PathBuf,
28 pub permission_mode: Option<String>,
30 pub created: u64,
32}
33
34impl Session {
35 pub fn sidecar_path(name: &str) -> Result<PathBuf> {
38 validate_name(name)?;
39 Ok(sessions_dir()?.join(format!("{name}.json")))
40 }
41
42 pub fn save(&self) -> Result<()> {
44 let path = Session::sidecar_path(&self.name)?; let dir = sessions_dir()?;
46 fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
47 let body = serde_json::to_string_pretty(self)?;
48 fs::write(&path, body).map_err(|e| Error::io(&path, e))
49 }
50
51 pub fn load(name: &str) -> Result<Session> {
53 let path = Session::sidecar_path(name)?;
54 let body = fs::read_to_string(&path).map_err(|e| match e.kind() {
55 std::io::ErrorKind::NotFound => Error::NoSuchSession(name.to_string()),
56 _ => Error::io(&path, e),
57 })?;
58 Ok(serde_json::from_str(&body)?)
59 }
60
61 pub fn delete(name: &str) -> Result<()> {
63 let path = Session::sidecar_path(name)?;
64 match fs::remove_file(&path) {
65 Ok(()) => Ok(()),
66 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
67 Err(e) => Err(Error::io(&path, e)),
68 }
69 }
70
71 pub fn list() -> Result<Vec<Session>> {
73 let dir = sessions_dir()?;
74 if !dir.exists() {
75 return Ok(Vec::new());
76 }
77 let mut sessions = Vec::new();
78 for entry in fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
79 let entry = entry.map_err(|e| Error::io(&dir, e))?;
80 let path = entry.path();
81 if path.extension().and_then(|e| e.to_str()) != Some("json") {
82 continue;
83 }
84 if let Ok(body) = fs::read_to_string(&path) {
85 if let Ok(session) = serde_json::from_str::<Session>(&body) {
86 sessions.push(session);
87 }
88 }
89 }
90 sessions.sort_by(|a, b| a.name.cmp(&b.name));
91 Ok(sessions)
92 }
93}
94
95pub fn validate_name(name: &str) -> Result<()> {
99 let starts_ok = name
100 .chars()
101 .next()
102 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
103 let chars_ok = name
104 .chars()
105 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
106 if name.is_empty() || !starts_ok || !chars_ok || name.contains("..") {
107 return Err(Error::InvalidSessionName(name.to_string()));
108 }
109 Ok(())
110}
111
112fn sessions_dir() -> Result<PathBuf> {
114 let base = dirs::state_dir()
115 .or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
116 .ok_or(Error::NoDir { what: "state" })?;
117 Ok(base.join("csd").join("sessions"))
118}
119
120pub fn now_epoch() -> u64 {
122 SystemTime::now()
123 .duration_since(UNIX_EPOCH)
124 .map(|d| d.as_secs())
125 .unwrap_or(0)
126}
127
128pub fn cwd_slug(cwd: &str) -> String {
132 cwd.replace('/', "-")
133}
134
135pub fn jsonl_path(cwd: &str, session_id: &str) -> Result<PathBuf> {
137 let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
138 Ok(home
139 .join(".claude")
140 .join("projects")
141 .join(cwd_slug(cwd))
142 .join(format!("{session_id}.jsonl")))
143}
144
145pub fn plans_dir() -> Result<PathBuf> {
147 let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
148 Ok(home.join(".claude").join("plans"))
149}
150
151pub fn mtime_epoch(path: &Path) -> Option<u64> {
153 fs::metadata(path)
154 .and_then(|m| m.modified())
155 .ok()
156 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
157 .map(|d| d.as_secs())
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn slug_replaces_every_slash() {
166 assert_eq!(cwd_slug("/tmp/claude-itest1"), "-tmp-claude-itest1");
167 assert_eq!(cwd_slug("/home/marshall/dev/csd"), "-home-marshall-dev-csd");
168 }
169
170 #[test]
171 fn accepts_normal_names() {
172 for name in ["csd-csd-abc123", "agent_1", "Foo.bar-2"] {
173 assert!(validate_name(name).is_ok(), "{name} should be valid");
174 }
175 }
176
177 #[test]
178 fn rejects_path_traversal_and_separators() {
179 for name in ["../x", "..", "a/b", "/etc/passwd", "-rf", "", "a..b", "foo/../bar"] {
180 assert!(validate_name(name).is_err(), "{name} should be rejected");
181 }
182 }
183}