use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::Serialize;
use serde_json::Value;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct HistoryRoot {
path: PathBuf,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ListSort {
#[default]
NameAsc,
RecencyDesc,
}
#[derive(Debug, Clone)]
pub struct ListOptions {
pub limit: Option<usize>,
pub offset: usize,
pub include_empty: bool,
pub sort: ListSort,
}
impl Default for ListOptions {
fn default() -> Self {
Self {
limit: None,
offset: 0,
include_empty: true,
sort: ListSort::default(),
}
}
}
impl HistoryRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::History {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("projects"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list_projects(&self) -> Result<Vec<ProjectSummary>> {
self.list_projects_with(&ListOptions::default())
}
pub fn list_projects_with(&self, opts: &ListOptions) -> Result<Vec<ProjectSummary>> {
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 ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !ft.is_dir() {
continue;
}
let slug = entry.file_name().to_string_lossy().into_owned();
let summary = summarize_project(&entry.path(), slug);
if !opts.include_empty && summary.session_count == 0 {
continue;
}
out.push(summary);
}
match opts.sort {
ListSort::NameAsc => out.sort_by(|a, b| a.slug.cmp(&b.slug)),
ListSort::RecencyDesc => out.sort_by(|a, b| {
match (a.last_modified, b.last_modified) {
(Some(am), Some(bm)) => bm.cmp(&am),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.slug.cmp(&b.slug),
}
}),
}
apply_offset_limit(&mut out, opts);
Ok(out)
}
pub fn list_sessions(&self, slug: Option<&str>) -> Result<Vec<SessionSummary>> {
self.list_sessions_with(slug, &ListOptions::default())
}
pub fn list_sessions_with(
&self,
slug: Option<&str>,
opts: &ListOptions,
) -> Result<Vec<SessionSummary>> {
let enumerate_opts = ListOptions {
include_empty: true,
..ListOptions::default()
};
let project_dirs = match slug {
Some(s) => vec![self.path.join(s)],
None => self
.list_projects_with(&enumerate_opts)?
.into_iter()
.map(|p| self.path.join(&p.slug))
.collect(),
};
let mut out = Vec::new();
for dir in project_dirs {
let project_slug = dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let entries = match fs::read_dir(&dir) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Some(session_id) = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
else {
continue;
};
if let Some(summary) = summarize_session(&path, session_id, project_slug.clone()) {
if !opts.include_empty && summary.message_count == 0 {
continue;
}
out.push(summary);
}
}
}
match opts.sort {
ListSort::NameAsc => out.sort_by(|a, b| a.session_id.cmp(&b.session_id)),
ListSort::RecencyDesc => out.sort_by(|a, b| {
match (a.last_timestamp.as_deref(), b.last_timestamp.as_deref()) {
(Some(at), Some(bt)) => bt.cmp(at),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.session_id.cmp(&b.session_id),
}
}),
}
apply_offset_limit(&mut out, opts);
Ok(out)
}
pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
let (path, project_slug) =
self.find_session(session_id)?
.ok_or_else(|| Error::History {
message: format!(
"no session with id `{session_id}` under {}",
self.path.display()
),
})?;
parse_session(&path, session_id.to_string(), project_slug)
}
pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
for project in self.list_projects()? {
let candidate = self
.path
.join(&project.slug)
.join(format!("{session_id}.jsonl"));
if candidate.is_file() {
return Ok(Some((candidate, project.slug)));
}
}
Ok(None)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectSummary {
pub slug: String,
pub decoded_path: PathBuf,
pub session_count: usize,
pub last_modified: Option<SystemTime>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionSummary {
pub session_id: String,
pub project_slug: String,
pub message_count: usize,
pub first_timestamp: Option<String>,
pub last_timestamp: Option<String>,
pub title: Option<String>,
pub first_user_preview: Option<String>,
pub total_cost_usd: Option<f64>,
pub total_tokens: Option<u64>,
pub size_bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionLog {
pub session_id: String,
pub project_slug: String,
pub entries: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum HistoryEntry {
User {
uuid: Option<String>,
timestamp: Option<String>,
cwd: Option<String>,
git_branch: Option<String>,
message: Value,
#[serde(flatten)]
rest: serde_json::Map<String, Value>,
},
Assistant {
uuid: Option<String>,
timestamp: Option<String>,
message: Value,
#[serde(flatten)]
rest: serde_json::Map<String, Value>,
},
Other {
type_tag: String,
raw: Value,
},
}
fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
if opts.offset >= items.len() {
items.clear();
return;
}
if opts.offset > 0 {
items.drain(..opts.offset);
}
if let Some(lim) = opts.limit
&& items.len() > lim
{
items.truncate(lim);
}
}
fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
let mut session_count = 0usize;
let mut last_modified: Option<SystemTime> = None;
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
session_count += 1;
if let Ok(meta) = entry.metadata()
&& let Ok(mtime) = meta.modified()
{
last_modified = Some(match last_modified {
Some(prev) if prev > mtime => prev,
_ => mtime,
});
}
}
}
}
ProjectSummary {
decoded_path: decode_slug(&slug),
slug,
session_count,
last_modified,
}
}
fn summarize_session(
path: &Path,
session_id: String,
project_slug: String,
) -> Option<SessionSummary> {
let meta = fs::metadata(path).ok()?;
let size_bytes = meta.len();
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut message_count = 0usize;
let mut first_timestamp = None;
let mut last_timestamp = None;
let mut title = None;
let mut first_user_preview: Option<String> = None;
let mut total_cost_usd: Option<f64> = None;
let mut total_tokens: Option<u64> = None;
for line in reader.lines().map_while(std::io::Result::ok) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
match ty {
"user" => {
message_count += 1;
if first_user_preview.is_none()
&& let Some(p) = extract_user_text_preview(&v, 160)
{
first_user_preview = Some(p);
}
}
"assistant" => {
message_count += 1;
if let Some(c) = v
.get("message")
.and_then(|m| m.get("usage"))
.and_then(|u| u.get("total_cost_usd"))
.and_then(Value::as_f64)
{
*total_cost_usd.get_or_insert(0.0) += c;
}
if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
let mut t = 0u64;
for k in [
"input_tokens",
"output_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
] {
if let Some(n) = usage.get(k).and_then(Value::as_u64) {
t += n;
}
}
if t > 0 {
*total_tokens.get_or_insert(0) += t;
}
}
}
"ai-title" => {
let candidate = v
.get("aiTitle")
.and_then(Value::as_str)
.or_else(|| v.get("title").and_then(Value::as_str));
if let Some(t) = candidate
&& !t.is_empty()
{
title = Some(t.to_string());
}
}
_ => {}
}
if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
if first_timestamp.is_none() {
first_timestamp = Some(ts.to_string());
}
last_timestamp = Some(ts.to_string());
}
}
Some(SessionSummary {
session_id,
project_slug,
message_count,
first_timestamp,
last_timestamp,
title,
first_user_preview,
total_cost_usd,
total_tokens,
size_bytes,
})
}
fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
let content = entry.get("message")?.get("content")?;
let raw = if let Some(s) = content.as_str() {
s.to_string()
} else if let Some(arr) = content.as_array() {
let mut buf = String::new();
for block in arr {
let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
if ty == "text"
&& let Some(t) = block.get("text").and_then(Value::as_str)
{
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(t);
}
}
buf
} else {
return None;
};
let one_line = raw
.split('\n')
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
if one_line.is_empty() {
return None;
}
let truncated: String = one_line.chars().take(max_chars).collect();
if truncated.len() < one_line.len() {
Some(format!("{truncated}..."))
} else {
Some(truncated)
}
}
fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for (lineno, line) in reader.lines().enumerate() {
let line = match line {
Ok(l) => l,
Err(e) => {
tracing::warn!(
path = %path.display(),
line = lineno + 1,
error = %e,
"history: skipping unreadable line",
);
continue;
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match parse_entry(trimmed) {
Ok(entry) => entries.push(entry),
Err(e) => {
tracing::warn!(
path = %path.display(),
line = lineno + 1,
error = %e,
"history: skipping malformed line",
);
}
}
}
Ok(SessionLog {
session_id,
project_slug,
entries,
})
}
fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
let mut value: Value = serde_json::from_str(line)?;
let ty = value
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
match ty.as_str() {
"user" => Ok(HistoryEntry::User {
uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.map(String::from),
cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
git_branch: value
.get("gitBranch")
.and_then(Value::as_str)
.map(String::from),
message: value.get("message").cloned().unwrap_or(Value::Null),
rest: take_object(&mut value),
}),
"assistant" => Ok(HistoryEntry::Assistant {
uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.map(String::from),
message: value.get("message").cloned().unwrap_or(Value::Null),
rest: take_object(&mut value),
}),
other => Ok(HistoryEntry::Other {
type_tag: other.to_string(),
raw: value,
}),
}
}
fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
serde_json::Map::new()
}
fn decode_slug(slug: &str) -> PathBuf {
let body = slug.strip_prefix('-').unwrap_or(slug);
PathBuf::from(format!("/{}", body.replace('-', "/")))
}
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::*;
use std::io::Write;
fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
let path = dir.join(format!("{session_id}.jsonl"));
let mut f = fs::File::create(&path).expect("create jsonl");
for line in lines {
writeln!(f, "{line}").unwrap();
}
path
}
fn set_mtime(path: &Path, secs_since_epoch: u64) {
let f = fs::OpenOptions::new()
.write(true)
.open(path)
.expect("reopen for mtime");
let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
f.set_modified(when).expect("set mtime");
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
let a = tmp.path().join("-Users-josh-Code-projA");
fs::create_dir_all(&a).unwrap();
write_session(
&a,
"session-aaa",
&[
r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
r#"{"type":"ai-title","aiTitle":"hello world"}"#,
],
);
write_session(
&a,
"session-bbb",
&[
r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
],
);
let b = tmp.path().join("-private-tmp-projB");
fs::create_dir_all(&b).unwrap();
write_session(
&b,
"session-ccc",
&[
r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
r#"NOT VALID JSON"#,
r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
],
);
tmp
}
#[test]
fn list_projects_returns_directories_sorted_by_slug() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let projects = root.list_projects().expect("list projects");
let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
}
#[test]
fn list_projects_counts_sessions() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let projects = root.list_projects().expect("list");
let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
assert_eq!(a.session_count, 2);
assert_eq!(b.session_count, 1);
}
#[test]
fn list_projects_decodes_slug_to_filesystem_path() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let projects = root.list_projects().expect("list");
let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
}
#[test]
fn list_projects_returns_empty_when_root_missing() {
let tmp = tempfile::tempdir().unwrap();
let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
let projects = root.list_projects().expect("ok");
assert!(projects.is_empty());
}
#[test]
fn list_sessions_filtered_by_slug() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let sessions = root
.list_sessions(Some("-Users-josh-Code-projA"))
.expect("list");
let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, ["session-aaa", "session-bbb"]);
assert!(
sessions
.iter()
.all(|s| s.project_slug == "-Users-josh-Code-projA")
);
}
#[test]
fn list_sessions_unfiltered_returns_union() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let sessions = root.list_sessions(None).expect("list");
assert_eq!(sessions.len(), 3);
}
#[test]
fn session_summary_counts_only_user_and_assistant() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
let aaa = sessions
.iter()
.find(|s| s.session_id == "session-aaa")
.unwrap();
assert_eq!(aaa.message_count, 2);
assert_eq!(aaa.title.as_deref(), Some("hello world"));
assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
}
#[test]
fn read_session_returns_typed_entries_and_skips_malformed_lines() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let log = root.read_session("session-ccc").expect("read");
assert_eq!(log.session_id, "session-ccc");
assert_eq!(log.project_slug, "-private-tmp-projB");
assert_eq!(log.entries.len(), 2);
assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
}
#[test]
fn read_session_user_entry_carries_metadata() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let log = root.read_session("session-aaa").expect("read");
match &log.entries[0] {
HistoryEntry::User {
uuid,
timestamp,
cwd,
git_branch,
..
} => {
assert_eq!(uuid.as_deref(), Some("u1"));
assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
assert_eq!(git_branch.as_deref(), Some("main"));
}
other => panic!("expected User entry, got {other:?}"),
}
}
#[test]
fn read_session_other_entry_preserves_type_tag_and_raw() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let log = root.read_session("session-aaa").expect("read");
let queue_op = log
.entries
.iter()
.find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
.expect("queue-operation entry");
if let HistoryEntry::Other { raw, .. } = queue_op {
assert_eq!(raw["operation"], "enqueue");
}
}
#[test]
fn read_session_unknown_id_errors() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let err = root.read_session("not-a-real-session").unwrap_err();
assert!(matches!(err, Error::History { .. }));
assert!(format!("{err}").contains("no session with id"));
}
#[test]
fn find_session_returns_none_for_unknown_id() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let found = root.find_session("nope").expect("ok");
assert!(found.is_none());
}
#[test]
fn find_session_locates_real_session() {
let tmp = fixture_root();
let root = HistoryRoot::at(tmp.path());
let (path, slug) = root
.find_session("session-ccc")
.expect("ok")
.expect("found");
assert!(path.ends_with("session-ccc.jsonl"));
assert_eq!(slug, "-private-tmp-projB");
}
#[test]
fn decode_slug_round_trips_simple_paths() {
assert_eq!(
decode_slug("-Users-josh-Code-foo"),
PathBuf::from("/Users/josh/Code/foo")
);
assert_eq!(decode_slug("-tmp-bar"), PathBuf::from("/tmp/bar"));
}
fn paginated_fixture() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
for stem in ["-zzz-empty1", "-aaa-empty2"] {
fs::create_dir_all(tmp.path().join(stem)).unwrap();
}
for (stem, ts, mtime) in [
("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
] {
let dir = tmp.path().join(stem);
fs::create_dir_all(&dir).unwrap();
let session_path = write_session(
&dir,
"s1",
&[&format!(
r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
)],
);
set_mtime(&session_path, mtime);
}
tmp
}
#[test]
fn list_projects_with_include_empty_false_filters_them_out() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root
.list_projects_with(&ListOptions {
include_empty: false,
..Default::default()
})
.expect("list");
let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
}
#[test]
fn list_projects_with_default_includes_empty_for_bc() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root
.list_projects_with(&ListOptions::default())
.expect("list");
assert_eq!(projects.len(), 5);
}
#[test]
fn list_projects_zero_arg_preserves_legacy_inclusion() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root.list_projects().expect("list");
assert_eq!(projects.len(), 5);
let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
assert_eq!(
slugs,
[
"-aaa-empty2",
"-bbb-proj",
"-ccc-proj",
"-ddd-proj",
"-zzz-empty1",
]
);
}
#[test]
fn list_projects_with_limit_caps_results() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root
.list_projects_with(&ListOptions {
limit: Some(2),
include_empty: true,
..Default::default()
})
.expect("list");
assert_eq!(projects.len(), 2);
}
#[test]
fn list_projects_with_offset_skips() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root
.list_projects_with(&ListOptions {
offset: 3,
include_empty: true,
..Default::default()
})
.expect("list");
let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
}
#[test]
fn list_projects_with_offset_past_end_returns_empty() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root
.list_projects_with(&ListOptions {
offset: 99,
include_empty: true,
..Default::default()
})
.expect("list");
assert!(projects.is_empty());
}
#[test]
fn list_projects_with_recency_desc_sort() {
let tmp = paginated_fixture();
let root = HistoryRoot::at(tmp.path());
let projects = root
.list_projects_with(&ListOptions {
sort: ListSort::RecencyDesc,
include_empty: false,
..Default::default()
})
.expect("list");
let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
}
#[test]
fn list_sessions_with_include_empty_false_filters_zero_message() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("-proj");
fs::create_dir_all(&dir).unwrap();
write_session(
&dir,
"real",
&[
r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
],
);
write_session(
&dir,
"orphan",
&[
r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
],
);
let root = HistoryRoot::at(tmp.path());
let sessions = root
.list_sessions_with(
Some("-proj"),
&ListOptions {
include_empty: false,
..Default::default()
},
)
.expect("list");
let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, ["real"]);
}
#[test]
fn list_sessions_with_default_returns_orphans_for_bc() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("-proj");
fs::create_dir_all(&dir).unwrap();
write_session(
&dir,
"orphan",
&[
r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
],
);
let root = HistoryRoot::at(tmp.path());
let sessions = root
.list_sessions_with(Some("-proj"), &ListOptions::default())
.expect("list");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].message_count, 0);
}
#[test]
fn list_sessions_with_recency_desc_sort() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("-proj");
fs::create_dir_all(&dir).unwrap();
let old_p = write_session(
&dir,
"old",
&[
r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
],
);
let new_p = write_session(
&dir,
"new",
&[
r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
],
);
let mid_p = write_session(
&dir,
"mid",
&[
r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
],
);
set_mtime(&old_p, 1_700_000_000);
set_mtime(&mid_p, 1_700_001_000);
set_mtime(&new_p, 1_700_002_000);
let root = HistoryRoot::at(tmp.path());
let sessions = root
.list_sessions_with(
Some("-proj"),
&ListOptions {
sort: ListSort::RecencyDesc,
..Default::default()
},
)
.expect("list");
let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, ["new", "mid", "old"]);
}
#[test]
fn list_sessions_with_limit_and_offset_combine() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("-proj");
fs::create_dir_all(&dir).unwrap();
for i in 0..5 {
write_session(
&dir,
&format!("s{i}"),
&[&format!(
r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
)],
);
}
let root = HistoryRoot::at(tmp.path());
let sessions = root
.list_sessions_with(
Some("-proj"),
&ListOptions {
offset: 1,
limit: Some(2),
..Default::default()
},
)
.expect("list");
let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, ["s1", "s2"]);
}
#[test]
fn session_summary_parses_ai_title_camelcase() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("-proj");
fs::create_dir_all(&dir).unwrap();
write_session(
&dir,
"real-shape",
&[
r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
],
);
let root = HistoryRoot::at(tmp.path());
let sessions = root.list_sessions(Some("-proj")).expect("list");
let s = sessions
.iter()
.find(|s| s.session_id == "real-shape")
.unwrap();
assert_eq!(s.title.as_deref(), Some("My Session"));
}
#[test]
fn session_summary_legacy_title_field_still_works() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("-proj");
fs::create_dir_all(&dir).unwrap();
write_session(
&dir,
"legacy",
&[
r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
r#"{"type":"ai-title","title":"Legacy Form"}"#,
],
);
let root = HistoryRoot::at(tmp.path());
let sessions = root.list_sessions(Some("-proj")).expect("list");
let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
assert_eq!(s.title.as_deref(), Some("Legacy Form"));
}
}