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 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub backend_version: Option<String>,
35 pub created: u64,
37}
38
39impl Session {
40 pub fn sidecar_path(name: &str) -> Result<PathBuf> {
43 validate_name(name)?;
44 Ok(sessions_dir()?.join(format!("{name}.json")))
45 }
46
47 pub fn save(&self) -> Result<()> {
49 let path = Session::sidecar_path(&self.name)?; let dir = sessions_dir()?;
51 fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
52 let body = serde_json::to_string_pretty(self)?;
53 fs::write(&path, body).map_err(|e| Error::io(&path, e))
54 }
55
56 pub fn load(name: &str) -> Result<Session> {
58 let path = Session::sidecar_path(name)?;
59 let body = fs::read_to_string(&path).map_err(|e| match e.kind() {
60 std::io::ErrorKind::NotFound => Error::NoSuchSession(name.to_string()),
61 _ => Error::io(&path, e),
62 })?;
63 Ok(serde_json::from_str(&body)?)
64 }
65
66 pub fn delete(name: &str) -> Result<()> {
68 let path = Session::sidecar_path(name)?;
69 match fs::remove_file(&path) {
70 Ok(()) => Ok(()),
71 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
72 Err(e) => Err(Error::io(&path, e)),
73 }
74 }
75
76 pub fn list() -> Result<Vec<Session>> {
78 let dir = sessions_dir()?;
79 if !dir.exists() {
80 return Ok(Vec::new());
81 }
82 let mut sessions = Vec::new();
83 for entry in fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
84 let entry = entry.map_err(|e| Error::io(&dir, e))?;
85 let path = entry.path();
86 if path.extension().and_then(|e| e.to_str()) != Some("json") {
87 continue;
88 }
89 if let Ok(body) = fs::read_to_string(&path) {
90 if let Ok(session) = serde_json::from_str::<Session>(&body) {
91 sessions.push(session);
92 }
93 }
94 }
95 sessions.sort_by(|a, b| a.name.cmp(&b.name));
96 Ok(sessions)
97 }
98}
99
100pub fn validate_name(name: &str) -> Result<()> {
104 let starts_ok = name
105 .chars()
106 .next()
107 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
108 let chars_ok = name
109 .chars()
110 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
111 if name.is_empty() || !starts_ok || !chars_ok || name.contains("..") {
112 return Err(Error::InvalidSessionName(name.to_string()));
113 }
114 Ok(())
115}
116
117fn sessions_dir() -> Result<PathBuf> {
119 let base = dirs::state_dir()
120 .or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
121 .ok_or(Error::NoDir { what: "state" })?;
122 Ok(base.join("csd").join("sessions"))
123}
124
125pub fn now_epoch() -> u64 {
127 SystemTime::now()
128 .duration_since(UNIX_EPOCH)
129 .map(|d| d.as_secs())
130 .unwrap_or(0)
131}
132
133pub fn cwd_slug(cwd: &str) -> String {
137 cwd.replace('/', "-")
138}
139
140pub fn jsonl_path(cwd: &str, session_id: &str) -> Result<PathBuf> {
142 let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
143 Ok(home
144 .join(".claude")
145 .join("projects")
146 .join(cwd_slug(cwd))
147 .join(format!("{session_id}.jsonl")))
148}
149
150pub fn plans_dir() -> Result<PathBuf> {
152 let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
153 Ok(home.join(".claude").join("plans"))
154}
155
156pub fn mtime_epoch(path: &Path) -> Option<u64> {
158 fs::metadata(path)
159 .and_then(|m| m.modified())
160 .ok()
161 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
162 .map(|d| d.as_secs())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn slug_replaces_every_slash() {
171 assert_eq!(cwd_slug("/tmp/claude-itest1"), "-tmp-claude-itest1");
172 assert_eq!(cwd_slug("/home/marshall/dev/csd"), "-home-marshall-dev-csd");
173 }
174
175 #[test]
176 fn accepts_normal_names() {
177 for name in ["csd-csd-abc123", "agent_1", "Foo.bar-2"] {
178 assert!(validate_name(name).is_ok(), "{name} should be valid");
179 }
180 }
181
182 #[test]
183 fn sidecar_without_backend_version_loads_and_none_is_not_serialized() {
184 let old = r#"{
185 "name": "csd-x-abc",
186 "session_id": "00000000-0000-0000-0000-000000000000",
187 "cwd": "/tmp/x",
188 "backend": "claude",
189 "jsonl_path": "/tmp/x.jsonl",
190 "permission_mode": null,
191 "created": 1
192 }"#;
193 let session: Session = serde_json::from_str(old).unwrap();
194 assert_eq!(session.backend_version, None);
195 let body = serde_json::to_string(&session).unwrap();
196 assert!(!body.contains("backend_version"));
197 }
198
199 #[test]
200 fn rejects_path_traversal_and_separators() {
201 for name in ["../x", "..", "a/b", "/etc/passwd", "-rf", "", "a..b", "foo/../bar"] {
202 assert!(validate_name(name).is_err(), "{name} should be rejected");
203 }
204 }
205}