1use std::fs;
54use std::path::{Path, PathBuf};
55
56use serde::Serialize;
57use serde_json::Value;
58
59use crate::error::{Error, Result};
60
61#[derive(Debug, Clone)]
65pub struct SessionsRoot {
66 path: PathBuf,
67}
68
69impl SessionsRoot {
70 pub fn home() -> Result<Self> {
73 let home = home_dir().ok_or_else(|| Error::Artifacts {
74 message: "could not determine user home directory".to_string(),
75 })?;
76 Ok(Self {
77 path: home.join(".claude").join("sessions"),
78 })
79 }
80
81 pub fn at(path: impl Into<PathBuf>) -> Self {
84 Self { path: path.into() }
85 }
86
87 pub fn path(&self) -> &Path {
89 &self.path
90 }
91
92 pub fn list(&self) -> Result<Vec<LiveSession>> {
97 let entries = match fs::read_dir(&self.path) {
98 Ok(it) => it,
99 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
100 Err(e) => return Err(e.into()),
101 };
102 let mut out = Vec::new();
103 for entry in entries.flatten() {
104 let path = entry.path();
105 if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("json") {
106 continue;
107 }
108 match parse_session_file(&path) {
109 Ok(session) => out.push(session),
110 Err(e) => tracing::warn!(?path, "skipping session registry file: {e}"),
111 }
112 }
113 out.sort_by(|a, b| {
114 b.started_at_ms
115 .cmp(&a.started_at_ms)
116 .then_with(|| a.pid.cmp(&b.pid))
117 });
118 Ok(out)
119 }
120}
121
122#[derive(Debug, Clone, Serialize)]
124pub struct LiveSession {
125 pub pid: Option<u64>,
128 pub session_id: Option<String>,
130 pub cwd: Option<String>,
132 pub started_at_ms: Option<u64>,
134 pub version: Option<String>,
136 pub kind: Option<String>,
138 pub entrypoint: Option<String>,
141 pub name: Option<String>,
143 pub file_path: PathBuf,
145 pub rest: serde_json::Map<String, Value>,
148}
149
150fn parse_session_file(path: &Path) -> Result<LiveSession> {
151 let content = fs::read_to_string(path)?;
152 let value: Value = serde_json::from_str(&content).map_err(|e| Error::Artifacts {
153 message: format!("session registry {} is not valid JSON: {e}", path.display()),
154 })?;
155 let mut rest = match value {
156 Value::Object(map) => map,
157 _ => {
158 return Err(Error::Artifacts {
159 message: format!("session registry {} is not a JSON object", path.display()),
160 });
161 }
162 };
163 Ok(LiveSession {
164 pid: take_u64(&mut rest, "pid"),
165 session_id: take_string(&mut rest, "sessionId"),
166 cwd: take_string(&mut rest, "cwd"),
167 started_at_ms: take_u64(&mut rest, "startedAt"),
168 version: take_string(&mut rest, "version"),
169 kind: take_string(&mut rest, "kind"),
170 entrypoint: take_string(&mut rest, "entrypoint"),
171 name: take_string(&mut rest, "name"),
172 file_path: path.to_path_buf(),
173 rest,
174 })
175}
176
177fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
180 match map.remove(key) {
181 Some(Value::String(s)) => Some(s),
182 Some(other) => {
183 map.insert(key.to_string(), other);
184 None
185 }
186 None => None,
187 }
188}
189
190fn take_u64(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<u64> {
193 match map.remove(key) {
194 Some(v) => {
195 let n = v.as_u64();
196 if n.is_none() {
197 map.insert(key.to_string(), v);
198 }
199 n
200 }
201 None => None,
202 }
203}
204
205fn home_dir() -> Option<PathBuf> {
206 if let Ok(h) = std::env::var("HOME")
207 && !h.is_empty()
208 {
209 return Some(PathBuf::from(h));
210 }
211 if let Ok(h) = std::env::var("USERPROFILE")
212 && !h.is_empty()
213 {
214 return Some(PathBuf::from(h));
215 }
216 None
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn write_entry(root: &Path, stem: &str, contents: &str) {
224 fs::create_dir_all(root).unwrap();
225 fs::write(root.join(format!("{stem}.json")), contents).unwrap();
226 }
227
228 fn fixture_root() -> tempfile::TempDir {
229 let tmp = tempfile::tempdir().expect("tempdir");
230 write_entry(
231 tmp.path(),
232 "100",
233 r#"{"pid":100,"sessionId":"s-old","cwd":"/a","startedAt":1000,"version":"2.1.0","kind":"interactive","entrypoint":"cli","name":"old-1","peerProtocol":1}"#,
234 );
235 write_entry(
236 tmp.path(),
237 "200",
238 r#"{"pid":200,"sessionId":"s-new","cwd":"/b","startedAt":2000,"entrypoint":"claude-desktop"}"#,
239 );
240 write_entry(tmp.path(), "bad", r#"[1,2,3]"#);
241 tmp
242 }
243
244 #[test]
245 fn list_sorts_newest_first_and_parses_fields() {
246 let tmp = fixture_root();
247 let root = SessionsRoot::at(tmp.path());
248 let sessions = root.list().expect("list");
249 assert_eq!(sessions.len(), 2);
250 assert_eq!(sessions[0].session_id.as_deref(), Some("s-new"));
251 let old = &sessions[1];
252 assert_eq!(old.pid, Some(100));
253 assert_eq!(old.cwd.as_deref(), Some("/a"));
254 assert_eq!(old.started_at_ms, Some(1000));
255 assert_eq!(old.kind.as_deref(), Some("interactive"));
256 assert_eq!(old.entrypoint.as_deref(), Some("cli"));
257 assert_eq!(old.name.as_deref(), Some("old-1"));
258 assert_eq!(old.rest["peerProtocol"], 1);
259 }
260
261 #[test]
262 fn non_object_entries_are_skipped() {
263 let tmp = fixture_root();
264 let root = SessionsRoot::at(tmp.path());
265 assert_eq!(root.list().expect("list").len(), 2);
267 }
268
269 #[test]
270 fn missing_root_reads_empty() {
271 let tmp = tempfile::tempdir().unwrap();
272 let root = SessionsRoot::at(tmp.path().join("does-not-exist"));
273 assert!(root.list().expect("ok").is_empty());
274 }
275
276 #[test]
277 fn mistyped_pid_stays_in_rest() {
278 let tmp = tempfile::tempdir().unwrap();
279 write_entry(tmp.path(), "1", r#"{"pid":"not-a-number","sessionId":"s"}"#);
280 let root = SessionsRoot::at(tmp.path());
281 let sessions = root.list().expect("list");
282 assert_eq!(sessions[0].pid, None);
283 assert_eq!(sessions[0].rest["pid"], "not-a-number");
284 }
285}