use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct SessionsRoot {
path: PathBuf,
}
impl SessionsRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::Artifacts {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("sessions"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list(&self) -> Result<Vec<LiveSession>> {
let entries = match fs::read_dir(&self.path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
match parse_session_file(&path) {
Ok(session) => out.push(session),
Err(e) => tracing::warn!(?path, "skipping session registry file: {e}"),
}
}
out.sort_by(|a, b| {
b.started_at_ms
.cmp(&a.started_at_ms)
.then_with(|| a.pid.cmp(&b.pid))
});
Ok(out)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LiveSession {
pub pid: Option<u64>,
pub session_id: Option<String>,
pub cwd: Option<String>,
pub started_at_ms: Option<u64>,
pub version: Option<String>,
pub kind: Option<String>,
pub entrypoint: Option<String>,
pub name: Option<String>,
pub file_path: PathBuf,
pub rest: serde_json::Map<String, Value>,
}
fn parse_session_file(path: &Path) -> Result<LiveSession> {
let content = fs::read_to_string(path)?;
let value: Value = serde_json::from_str(&content).map_err(|e| Error::Artifacts {
message: format!("session registry {} is not valid JSON: {e}", path.display()),
})?;
let mut rest = match value {
Value::Object(map) => map,
_ => {
return Err(Error::Artifacts {
message: format!("session registry {} is not a JSON object", path.display()),
});
}
};
Ok(LiveSession {
pid: take_u64(&mut rest, "pid"),
session_id: take_string(&mut rest, "sessionId"),
cwd: take_string(&mut rest, "cwd"),
started_at_ms: take_u64(&mut rest, "startedAt"),
version: take_string(&mut rest, "version"),
kind: take_string(&mut rest, "kind"),
entrypoint: take_string(&mut rest, "entrypoint"),
name: take_string(&mut rest, "name"),
file_path: path.to_path_buf(),
rest,
})
}
fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
match map.remove(key) {
Some(Value::String(s)) => Some(s),
Some(other) => {
map.insert(key.to_string(), other);
None
}
None => None,
}
}
fn take_u64(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<u64> {
match map.remove(key) {
Some(v) => {
let n = v.as_u64();
if n.is_none() {
map.insert(key.to_string(), v);
}
n
}
None => None,
}
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write_entry(root: &Path, stem: &str, contents: &str) {
fs::create_dir_all(root).unwrap();
fs::write(root.join(format!("{stem}.json")), contents).unwrap();
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
write_entry(
tmp.path(),
"100",
r#"{"pid":100,"sessionId":"s-old","cwd":"/a","startedAt":1000,"version":"2.1.0","kind":"interactive","entrypoint":"cli","name":"old-1","peerProtocol":1}"#,
);
write_entry(
tmp.path(),
"200",
r#"{"pid":200,"sessionId":"s-new","cwd":"/b","startedAt":2000,"entrypoint":"claude-desktop"}"#,
);
write_entry(tmp.path(), "bad", r#"[1,2,3]"#);
tmp
}
#[test]
fn list_sorts_newest_first_and_parses_fields() {
let tmp = fixture_root();
let root = SessionsRoot::at(tmp.path());
let sessions = root.list().expect("list");
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].session_id.as_deref(), Some("s-new"));
let old = &sessions[1];
assert_eq!(old.pid, Some(100));
assert_eq!(old.cwd.as_deref(), Some("/a"));
assert_eq!(old.started_at_ms, Some(1000));
assert_eq!(old.kind.as_deref(), Some("interactive"));
assert_eq!(old.entrypoint.as_deref(), Some("cli"));
assert_eq!(old.name.as_deref(), Some("old-1"));
assert_eq!(old.rest["peerProtocol"], 1);
}
#[test]
fn non_object_entries_are_skipped() {
let tmp = fixture_root();
let root = SessionsRoot::at(tmp.path());
assert_eq!(root.list().expect("list").len(), 2);
}
#[test]
fn missing_root_reads_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = SessionsRoot::at(tmp.path().join("does-not-exist"));
assert!(root.list().expect("ok").is_empty());
}
#[test]
fn mistyped_pid_stays_in_rest() {
let tmp = tempfile::tempdir().unwrap();
write_entry(tmp.path(), "1", r#"{"pid":"not-a-number","sessionId":"s"}"#);
let root = SessionsRoot::at(tmp.path());
let sessions = root.list().expect("list");
assert_eq!(sessions[0].pid, None);
assert_eq!(sessions[0].rest["pid"], "not-a-number");
}
}