use crate::error::{PiError, Result};
use crate::paths::PathResolver;
use crate::reader::SessionMeta;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::time::SystemTime;
pub fn list_projects(resolver: &PathResolver) -> Result<Vec<String>> {
resolver.list_projects().map_err(PiError::from)
}
pub fn list_sessions(resolver: &PathResolver, project: &str) -> Result<Vec<SessionMeta>> {
let project_dir = resolver.project_dir(project);
if !project_dir.exists() {
return Err(PiError::project_not_found(project));
}
let mut metas: Vec<(SessionMeta, SystemTime)> = Vec::new();
let read_dir = std::fs::read_dir(&project_dir)?;
for entry in read_dir {
let entry = match entry {
Ok(e) => e,
Err(err) => {
eprintln!(
"warning: skipping entry in {}: {}",
project_dir.display(),
err
);
continue;
}
};
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(err) => {
eprintln!("warning: skipping {}: {}", path.display(), err);
continue;
}
};
if !file_type.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let header_line = match extract_header_line(&path) {
Ok(line) => line,
Err(err) => {
eprintln!("warning: skipping {}: {}", path.display(), err);
continue;
}
};
let parsed = header_line
.as_deref()
.and_then(parse_header_id_and_timestamp);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let total_nonempty = match count_nonempty_lines(&path) {
Ok(n) => n,
Err(err) => {
eprintln!("warning: skipping {}: {}", path.display(), err);
continue;
}
};
let (id, timestamp, entry_count) = match parsed {
Some((id, ts)) => {
let ec = total_nonempty.saturating_sub(1);
(id, ts, ec)
}
None => {
let id = fallback_id_from_stem(stem);
let ts = file_mtime_rfc3339(&path).unwrap_or_else(|| String::from(""));
(id, ts, total_nonempty)
}
};
let mtime = std::fs::metadata(&path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let first_user_message = match extract_first_user_message(&path) {
Ok(s) => s,
Err(err) => {
eprintln!(
"warning: skipping first-user-message extraction for {}: {}",
path.display(),
err
);
None
}
};
metas.push((
SessionMeta {
id,
timestamp,
file_path: path,
entry_count,
first_user_message,
},
mtime,
));
}
metas.sort_by(|a, b| {
b.0.timestamp
.cmp(&a.0.timestamp)
.then_with(|| b.1.cmp(&a.1))
});
Ok(metas.into_iter().map(|(m, _)| m).collect())
}
fn extract_header_line(path: &Path) -> std::io::Result<Option<String>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
if !line.trim().is_empty() {
return Ok(Some(line));
}
}
Ok(None)
}
fn parse_header_id_and_timestamp(line: &str) -> Option<(String, String)> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let obj = v.as_object()?;
if obj.get("type").and_then(|t| t.as_str()) != Some("session") {
return None;
}
let id = obj.get("id")?.as_str()?.to_string();
let timestamp = obj.get("timestamp")?.as_str()?.to_string();
Some((id, timestamp))
}
fn fallback_id_from_stem(stem: &str) -> String {
match stem.find('_') {
Some(idx) => stem[idx + 1..].to_string(),
None => stem.to_string(),
}
}
fn count_nonempty_lines(path: &Path) -> std::io::Result<usize> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut n = 0usize;
for line in reader.lines() {
let line = line?;
if !line.trim().is_empty() {
n += 1;
}
}
Ok(n)
}
fn extract_first_user_message(path: &Path) -> std::io::Result<Option<String>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let v: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let obj = match v.as_object() {
Some(o) => o,
None => continue,
};
if obj.get("type").and_then(|t| t.as_str()) != Some("message") {
continue;
}
let msg = match obj.get("message").and_then(|m| m.as_object()) {
Some(m) => m,
None => continue,
};
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
continue;
}
let text = match msg.get("content") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(blocks)) => blocks
.iter()
.filter_map(|b| {
let bo = b.as_object()?;
if bo.get("type").and_then(|t| t.as_str()) == Some("text") {
bo.get("text").and_then(|t| t.as_str()).map(str::to_string)
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n"),
_ => continue,
};
let trimmed = text.trim();
if !trimmed.is_empty() {
return Ok(Some(trimmed.to_string()));
}
}
Ok(None)
}
fn file_mtime_rfc3339(path: &Path) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
let mtime = meta.modified().ok()?;
Some(chrono::DateTime::<chrono::Utc>::from(mtime).to_rfc3339())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::PiError;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn resolver_with(sessions_dir: &Path) -> PathResolver {
PathResolver::new().with_sessions_dir(sessions_dir)
}
#[test]
fn test_list_projects_empty() {
let temp = TempDir::new().unwrap();
let resolver = resolver_with(temp.path());
let projects = list_projects(&resolver).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_list_projects_returns_projects_sorted() {
let temp = TempDir::new().unwrap();
fs::create_dir(temp.path().join("--home-bob-repo--")).unwrap();
fs::create_dir(temp.path().join("--Users-alex-proj--")).unwrap();
let resolver = resolver_with(temp.path());
let projects = list_projects(&resolver).unwrap();
assert_eq!(
projects,
vec!["/Users/alex/proj".to_string(), "/home/bob/repo".to_string(),]
);
}
#[test]
fn test_list_sessions_project_not_found() {
let temp = TempDir::new().unwrap();
let resolver = resolver_with(temp.path());
let err = list_sessions(&resolver, "/does/not/exist").unwrap_err();
match err {
PiError::ProjectNotFound(p) => assert_eq!(p, "/does/not/exist"),
other => panic!("expected ProjectNotFound, got {other:?}"),
}
}
#[test]
fn test_list_sessions_empty_project() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert!(sessions.is_empty());
}
fn write_file(path: &Path, contents: &str) {
let mut f = File::create(path).unwrap();
f.write_all(contents.as_bytes()).unwrap();
}
#[test]
fn test_list_sessions_returns_meta() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
let file = proj_dir.join("2026-04-16_s1.jsonl");
write_file(
&file,
"{\"type\":\"session\",\"id\":\"s1\",\"timestamp\":\"2026-04-16T10:00:00Z\",\"cwd\":\"/p\",\"version\":3}\n\
{\"type\":\"message\",\"id\":\"m1\"}\n\
{\"type\":\"message\",\"id\":\"m2\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.id, "s1");
assert_eq!(s.timestamp, "2026-04-16T10:00:00Z");
assert_eq!(s.entry_count, 2);
assert!(s.file_path.to_string_lossy().ends_with(".jsonl"));
}
#[test]
fn test_list_sessions_sorts_descending_by_timestamp() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("older.jsonl"),
"{\"type\":\"session\",\"id\":\"old\",\"timestamp\":\"2026-01-01T00:00:00Z\"}\n",
);
write_file(
&proj_dir.join("newer.jsonl"),
"{\"type\":\"session\",\"id\":\"new\",\"timestamp\":\"2026-06-01T00:00:00Z\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].id, "new");
assert_eq!(sessions[1].id, "old");
}
#[test]
fn test_list_sessions_fallback_id_from_filename() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("2026-04-16_fallback-id.jsonl"),
"{\"type\":\"message\",\"id\":\"x\",\"timestamp\":\"t\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "fallback-id");
}
#[test]
fn test_list_sessions_fallback_id_no_underscore() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("single.jsonl"),
"{\"type\":\"message\",\"id\":\"x\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "single");
}
#[test]
fn test_list_sessions_fallback_id_multiple_underscores() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("a_b_c.jsonl"),
"{\"type\":\"message\",\"id\":\"x\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "b_c");
}
#[test]
fn test_list_sessions_fallback_timestamp_is_mtime() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("x.jsonl"),
"{\"type\":\"message\",\"id\":\"x\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
let parsed = chrono::DateTime::parse_from_rfc3339(&sessions[0].timestamp);
assert!(
parsed.is_ok(),
"expected RFC 3339 timestamp, got {:?}",
sessions[0].timestamp
);
}
#[test]
fn test_list_sessions_entry_count_subtracts_header() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("s.jsonl"),
"{\"type\":\"session\",\"id\":\"s\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n\
{\"type\":\"message\",\"id\":\"1\"}\n\
{\"type\":\"message\",\"id\":\"2\"}\n\
{\"type\":\"message\",\"id\":\"3\"}\n\
{\"type\":\"message\",\"id\":\"4\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].entry_count, 4);
}
#[test]
fn test_list_sessions_entry_count_without_header() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("x.jsonl"),
"{\"type\":\"message\",\"id\":\"1\"}\n\
{\"type\":\"message\",\"id\":\"2\"}\n\
{\"type\":\"message\",\"id\":\"3\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].entry_count, 3);
}
#[test]
fn test_list_sessions_ignores_non_jsonl_files() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(&proj_dir.join("notes.txt"), "hello\n");
write_file(
&proj_dir.join("session.jsonl"),
"{\"type\":\"session\",\"id\":\"s\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s");
}
#[test]
fn test_list_sessions_ignores_subdirectories() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
fs::create_dir(proj_dir.join("subdir")).unwrap();
write_file(
&proj_dir.join("s.jsonl"),
"{\"type\":\"session\",\"id\":\"s\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
}
#[test]
fn test_list_sessions_skips_empty_files() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(&proj_dir.join("empty.jsonl"), "");
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "empty");
assert_eq!(sessions[0].entry_count, 0);
}
#[test]
fn test_list_sessions_warns_on_weird_file_but_continues() {
let temp = TempDir::new().unwrap();
let proj_dir = temp.path().join("--p--");
fs::create_dir(&proj_dir).unwrap();
write_file(
&proj_dir.join("good.jsonl"),
"{\"type\":\"session\",\"id\":\"good\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n",
);
write_file(
&proj_dir.join("weird.jsonl"),
"not-json at all\nmore junk\n",
);
let resolver = resolver_with(temp.path());
let sessions = list_sessions(&resolver, "/p").unwrap();
assert_eq!(sessions.len(), 2);
let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"good"));
assert!(ids.contains(&"weird"));
}
}