1use crate::error::{PiError, Result};
7use crate::paths::PathResolver;
8use crate::reader::SessionMeta;
9use std::fs::File;
10use std::io::{BufRead, BufReader};
11use std::path::Path;
12use std::time::SystemTime;
13
14pub fn list_projects(resolver: &PathResolver) -> Result<Vec<String>> {
16 resolver.list_projects().map_err(PiError::from)
17}
18
19pub fn list_sessions(resolver: &PathResolver, project: &str) -> Result<Vec<SessionMeta>> {
26 let project_dir = resolver.project_dir(project);
27 if !project_dir.exists() {
28 return Err(PiError::project_not_found(project));
29 }
30
31 let mut metas: Vec<(SessionMeta, SystemTime)> = Vec::new();
32
33 let read_dir = std::fs::read_dir(&project_dir)?;
34 for entry in read_dir {
35 let entry = match entry {
36 Ok(e) => e,
37 Err(err) => {
38 eprintln!(
39 "warning: skipping entry in {}: {}",
40 project_dir.display(),
41 err
42 );
43 continue;
44 }
45 };
46
47 let path = entry.path();
48
49 let file_type = match entry.file_type() {
50 Ok(ft) => ft,
51 Err(err) => {
52 eprintln!("warning: skipping {}: {}", path.display(), err);
53 continue;
54 }
55 };
56 if !file_type.is_file() {
57 continue;
58 }
59
60 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
61 continue;
62 }
63
64 let header_line = match extract_header_line(&path) {
65 Ok(line) => line,
66 Err(err) => {
67 eprintln!("warning: skipping {}: {}", path.display(), err);
68 continue;
69 }
70 };
71
72 let parsed = header_line
73 .as_deref()
74 .and_then(parse_header_id_and_timestamp);
75
76 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
77
78 let total_nonempty = match count_nonempty_lines(&path) {
79 Ok(n) => n,
80 Err(err) => {
81 eprintln!("warning: skipping {}: {}", path.display(), err);
82 continue;
83 }
84 };
85
86 let (id, timestamp, entry_count) = match parsed {
87 Some((id, ts)) => {
88 let ec = total_nonempty.saturating_sub(1);
89 (id, ts, ec)
90 }
91 None => {
92 let id = fallback_id_from_stem(stem);
93 let ts = file_mtime_rfc3339(&path).unwrap_or_else(|| String::from(""));
94 (id, ts, total_nonempty)
95 }
96 };
97
98 let mtime = std::fs::metadata(&path)
99 .and_then(|m| m.modified())
100 .unwrap_or(SystemTime::UNIX_EPOCH);
101
102 let first_user_message = match extract_first_user_message(&path) {
103 Ok(s) => s,
104 Err(err) => {
105 eprintln!(
106 "warning: skipping first-user-message extraction for {}: {}",
107 path.display(),
108 err
109 );
110 None
111 }
112 };
113
114 metas.push((
115 SessionMeta {
116 id,
117 timestamp,
118 file_path: path,
119 entry_count,
120 first_user_message,
121 },
122 mtime,
123 ));
124 }
125
126 metas.sort_by(|a, b| {
128 b.0.timestamp
129 .cmp(&a.0.timestamp)
130 .then_with(|| b.1.cmp(&a.1))
131 });
132
133 Ok(metas.into_iter().map(|(m, _)| m).collect())
134}
135
136fn extract_header_line(path: &Path) -> std::io::Result<Option<String>> {
138 let file = File::open(path)?;
139 let reader = BufReader::new(file);
140 for line in reader.lines() {
141 let line = line?;
142 if !line.trim().is_empty() {
143 return Ok(Some(line));
144 }
145 }
146 Ok(None)
147}
148
149fn parse_header_id_and_timestamp(line: &str) -> Option<(String, String)> {
151 let v: serde_json::Value = serde_json::from_str(line).ok()?;
152 let obj = v.as_object()?;
153 if obj.get("type").and_then(|t| t.as_str()) != Some("session") {
154 return None;
155 }
156 let id = obj.get("id")?.as_str()?.to_string();
157 let timestamp = obj.get("timestamp")?.as_str()?.to_string();
158 Some((id, timestamp))
159}
160
161fn fallback_id_from_stem(stem: &str) -> String {
166 match stem.find('_') {
167 Some(idx) => stem[idx + 1..].to_string(),
168 None => stem.to_string(),
169 }
170}
171
172fn count_nonempty_lines(path: &Path) -> std::io::Result<usize> {
174 let file = File::open(path)?;
175 let reader = BufReader::new(file);
176 let mut n = 0usize;
177 for line in reader.lines() {
178 let line = line?;
179 if !line.trim().is_empty() {
180 n += 1;
181 }
182 }
183 Ok(n)
184}
185
186fn extract_first_user_message(path: &Path) -> std::io::Result<Option<String>> {
193 let file = File::open(path)?;
194 let reader = BufReader::new(file);
195 for line in reader.lines() {
196 let line = line?;
197 if line.trim().is_empty() {
198 continue;
199 }
200 let v: serde_json::Value = match serde_json::from_str(&line) {
201 Ok(v) => v,
202 Err(_) => continue,
203 };
204 let obj = match v.as_object() {
205 Some(o) => o,
206 None => continue,
207 };
208 if obj.get("type").and_then(|t| t.as_str()) != Some("message") {
209 continue;
210 }
211 let msg = match obj.get("message").and_then(|m| m.as_object()) {
212 Some(m) => m,
213 None => continue,
214 };
215 if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
216 continue;
217 }
218 let text = match msg.get("content") {
219 Some(serde_json::Value::String(s)) => s.clone(),
220 Some(serde_json::Value::Array(blocks)) => blocks
221 .iter()
222 .filter_map(|b| {
223 let bo = b.as_object()?;
224 if bo.get("type").and_then(|t| t.as_str()) == Some("text") {
225 bo.get("text").and_then(|t| t.as_str()).map(str::to_string)
226 } else {
227 None
228 }
229 })
230 .collect::<Vec<_>>()
231 .join("\n"),
232 _ => continue,
233 };
234 let trimmed = text.trim();
235 if !trimmed.is_empty() {
236 return Ok(Some(trimmed.to_string()));
237 }
238 }
239 Ok(None)
240}
241
242fn file_mtime_rfc3339(path: &Path) -> Option<String> {
244 let meta = std::fs::metadata(path).ok()?;
245 let mtime = meta.modified().ok()?;
246 Some(chrono::DateTime::<chrono::Utc>::from(mtime).to_rfc3339())
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::error::PiError;
253 use std::fs;
254 use std::io::Write;
255 use tempfile::TempDir;
256
257 fn resolver_with(sessions_dir: &Path) -> PathResolver {
258 PathResolver::new().with_sessions_dir(sessions_dir)
259 }
260
261 #[test]
262 fn test_list_projects_empty() {
263 let temp = TempDir::new().unwrap();
264 let resolver = resolver_with(temp.path());
265 let projects = list_projects(&resolver).unwrap();
266 assert!(projects.is_empty());
267 }
268
269 #[test]
270 fn test_list_projects_returns_projects_sorted() {
271 let temp = TempDir::new().unwrap();
272 fs::create_dir(temp.path().join("--home-bob-repo--")).unwrap();
273 fs::create_dir(temp.path().join("--Users-alex-proj--")).unwrap();
274 let resolver = resolver_with(temp.path());
275 let projects = list_projects(&resolver).unwrap();
276 assert_eq!(
277 projects,
278 vec!["/Users/alex/proj".to_string(), "/home/bob/repo".to_string(),]
279 );
280 }
281
282 #[test]
283 fn test_list_sessions_project_not_found() {
284 let temp = TempDir::new().unwrap();
285 let resolver = resolver_with(temp.path());
286 let err = list_sessions(&resolver, "/does/not/exist").unwrap_err();
287 match err {
288 PiError::ProjectNotFound(p) => assert_eq!(p, "/does/not/exist"),
289 other => panic!("expected ProjectNotFound, got {other:?}"),
290 }
291 }
292
293 #[test]
294 fn test_list_sessions_empty_project() {
295 let temp = TempDir::new().unwrap();
296 let proj_dir = temp.path().join("--p--");
297 fs::create_dir(&proj_dir).unwrap();
298 let resolver = resolver_with(temp.path());
299 let sessions = list_sessions(&resolver, "/p").unwrap();
300 assert!(sessions.is_empty());
301 }
302
303 fn write_file(path: &Path, contents: &str) {
304 let mut f = File::create(path).unwrap();
305 f.write_all(contents.as_bytes()).unwrap();
306 }
307
308 #[test]
309 fn test_list_sessions_returns_meta() {
310 let temp = TempDir::new().unwrap();
311 let proj_dir = temp.path().join("--p--");
312 fs::create_dir(&proj_dir).unwrap();
313 let file = proj_dir.join("2026-04-16_s1.jsonl");
314 write_file(
315 &file,
316 "{\"type\":\"session\",\"id\":\"s1\",\"timestamp\":\"2026-04-16T10:00:00Z\",\"cwd\":\"/p\",\"version\":3}\n\
317 {\"type\":\"message\",\"id\":\"m1\"}\n\
318 {\"type\":\"message\",\"id\":\"m2\"}\n",
319 );
320 let resolver = resolver_with(temp.path());
321 let sessions = list_sessions(&resolver, "/p").unwrap();
322 assert_eq!(sessions.len(), 1);
323 let s = &sessions[0];
324 assert_eq!(s.id, "s1");
325 assert_eq!(s.timestamp, "2026-04-16T10:00:00Z");
326 assert_eq!(s.entry_count, 2);
327 assert!(s.file_path.to_string_lossy().ends_with(".jsonl"));
328 }
329
330 #[test]
331 fn test_list_sessions_sorts_descending_by_timestamp() {
332 let temp = TempDir::new().unwrap();
333 let proj_dir = temp.path().join("--p--");
334 fs::create_dir(&proj_dir).unwrap();
335 write_file(
336 &proj_dir.join("older.jsonl"),
337 "{\"type\":\"session\",\"id\":\"old\",\"timestamp\":\"2026-01-01T00:00:00Z\"}\n",
338 );
339 write_file(
340 &proj_dir.join("newer.jsonl"),
341 "{\"type\":\"session\",\"id\":\"new\",\"timestamp\":\"2026-06-01T00:00:00Z\"}\n",
342 );
343 let resolver = resolver_with(temp.path());
344 let sessions = list_sessions(&resolver, "/p").unwrap();
345 assert_eq!(sessions.len(), 2);
346 assert_eq!(sessions[0].id, "new");
347 assert_eq!(sessions[1].id, "old");
348 }
349
350 #[test]
351 fn test_list_sessions_fallback_id_from_filename() {
352 let temp = TempDir::new().unwrap();
353 let proj_dir = temp.path().join("--p--");
354 fs::create_dir(&proj_dir).unwrap();
355 write_file(
356 &proj_dir.join("2026-04-16_fallback-id.jsonl"),
357 "{\"type\":\"message\",\"id\":\"x\",\"timestamp\":\"t\"}\n",
358 );
359 let resolver = resolver_with(temp.path());
360 let sessions = list_sessions(&resolver, "/p").unwrap();
361 assert_eq!(sessions.len(), 1);
362 assert_eq!(sessions[0].id, "fallback-id");
363 }
364
365 #[test]
366 fn test_list_sessions_fallback_id_no_underscore() {
367 let temp = TempDir::new().unwrap();
368 let proj_dir = temp.path().join("--p--");
369 fs::create_dir(&proj_dir).unwrap();
370 write_file(
371 &proj_dir.join("single.jsonl"),
372 "{\"type\":\"message\",\"id\":\"x\"}\n",
373 );
374 let resolver = resolver_with(temp.path());
375 let sessions = list_sessions(&resolver, "/p").unwrap();
376 assert_eq!(sessions.len(), 1);
377 assert_eq!(sessions[0].id, "single");
378 }
379
380 #[test]
381 fn test_list_sessions_fallback_id_multiple_underscores() {
382 let temp = TempDir::new().unwrap();
383 let proj_dir = temp.path().join("--p--");
384 fs::create_dir(&proj_dir).unwrap();
385 write_file(
386 &proj_dir.join("a_b_c.jsonl"),
387 "{\"type\":\"message\",\"id\":\"x\"}\n",
388 );
389 let resolver = resolver_with(temp.path());
390 let sessions = list_sessions(&resolver, "/p").unwrap();
391 assert_eq!(sessions.len(), 1);
392 assert_eq!(sessions[0].id, "b_c");
393 }
394
395 #[test]
396 fn test_list_sessions_fallback_timestamp_is_mtime() {
397 let temp = TempDir::new().unwrap();
398 let proj_dir = temp.path().join("--p--");
399 fs::create_dir(&proj_dir).unwrap();
400 write_file(
401 &proj_dir.join("x.jsonl"),
402 "{\"type\":\"message\",\"id\":\"x\"}\n",
403 );
404 let resolver = resolver_with(temp.path());
405 let sessions = list_sessions(&resolver, "/p").unwrap();
406 assert_eq!(sessions.len(), 1);
407 let parsed = chrono::DateTime::parse_from_rfc3339(&sessions[0].timestamp);
409 assert!(
410 parsed.is_ok(),
411 "expected RFC 3339 timestamp, got {:?}",
412 sessions[0].timestamp
413 );
414 }
415
416 #[test]
417 fn test_list_sessions_entry_count_subtracts_header() {
418 let temp = TempDir::new().unwrap();
419 let proj_dir = temp.path().join("--p--");
420 fs::create_dir(&proj_dir).unwrap();
421 write_file(
422 &proj_dir.join("s.jsonl"),
423 "{\"type\":\"session\",\"id\":\"s\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n\
424 {\"type\":\"message\",\"id\":\"1\"}\n\
425 {\"type\":\"message\",\"id\":\"2\"}\n\
426 {\"type\":\"message\",\"id\":\"3\"}\n\
427 {\"type\":\"message\",\"id\":\"4\"}\n",
428 );
429 let resolver = resolver_with(temp.path());
430 let sessions = list_sessions(&resolver, "/p").unwrap();
431 assert_eq!(sessions.len(), 1);
432 assert_eq!(sessions[0].entry_count, 4);
433 }
434
435 #[test]
436 fn test_list_sessions_entry_count_without_header() {
437 let temp = TempDir::new().unwrap();
438 let proj_dir = temp.path().join("--p--");
439 fs::create_dir(&proj_dir).unwrap();
440 write_file(
441 &proj_dir.join("x.jsonl"),
442 "{\"type\":\"message\",\"id\":\"1\"}\n\
443 {\"type\":\"message\",\"id\":\"2\"}\n\
444 {\"type\":\"message\",\"id\":\"3\"}\n",
445 );
446 let resolver = resolver_with(temp.path());
447 let sessions = list_sessions(&resolver, "/p").unwrap();
448 assert_eq!(sessions.len(), 1);
449 assert_eq!(sessions[0].entry_count, 3);
450 }
451
452 #[test]
453 fn test_list_sessions_ignores_non_jsonl_files() {
454 let temp = TempDir::new().unwrap();
455 let proj_dir = temp.path().join("--p--");
456 fs::create_dir(&proj_dir).unwrap();
457 write_file(&proj_dir.join("notes.txt"), "hello\n");
458 write_file(
459 &proj_dir.join("session.jsonl"),
460 "{\"type\":\"session\",\"id\":\"s\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n",
461 );
462 let resolver = resolver_with(temp.path());
463 let sessions = list_sessions(&resolver, "/p").unwrap();
464 assert_eq!(sessions.len(), 1);
465 assert_eq!(sessions[0].id, "s");
466 }
467
468 #[test]
469 fn test_list_sessions_ignores_subdirectories() {
470 let temp = TempDir::new().unwrap();
471 let proj_dir = temp.path().join("--p--");
472 fs::create_dir(&proj_dir).unwrap();
473 fs::create_dir(proj_dir.join("subdir")).unwrap();
474 write_file(
475 &proj_dir.join("s.jsonl"),
476 "{\"type\":\"session\",\"id\":\"s\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n",
477 );
478 let resolver = resolver_with(temp.path());
479 let sessions = list_sessions(&resolver, "/p").unwrap();
480 assert_eq!(sessions.len(), 1);
481 }
482
483 #[test]
484 fn test_list_sessions_skips_empty_files() {
485 let temp = TempDir::new().unwrap();
486 let proj_dir = temp.path().join("--p--");
487 fs::create_dir(&proj_dir).unwrap();
488 write_file(&proj_dir.join("empty.jsonl"), "");
489 let resolver = resolver_with(temp.path());
490 let sessions = list_sessions(&resolver, "/p").unwrap();
492 assert_eq!(sessions.len(), 1);
493 assert_eq!(sessions[0].id, "empty");
494 assert_eq!(sessions[0].entry_count, 0);
495 }
496
497 #[test]
498 fn test_list_sessions_warns_on_weird_file_but_continues() {
499 let temp = TempDir::new().unwrap();
500 let proj_dir = temp.path().join("--p--");
501 fs::create_dir(&proj_dir).unwrap();
502 write_file(
504 &proj_dir.join("good.jsonl"),
505 "{\"type\":\"session\",\"id\":\"good\",\"timestamp\":\"2026-04-16T10:00:00Z\"}\n",
506 );
507 write_file(
508 &proj_dir.join("weird.jsonl"),
509 "not-json at all\nmore junk\n",
510 );
511 let resolver = resolver_with(temp.path());
512 let sessions = list_sessions(&resolver, "/p").unwrap();
513 assert_eq!(sessions.len(), 2);
515 let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
516 assert!(ids.contains(&"good"));
517 assert!(ids.contains(&"weird"));
518 }
519}