Skip to main content

toolpath_pi/
io.rs

1//! Filesystem enumeration for Pi sessions.
2//!
3//! This module produces lightweight [`SessionMeta`] summaries by scanning the
4//! on-disk layout without reading full session bodies.
5
6use 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
14/// List project cwd paths with sessions on disk.
15pub fn list_projects(resolver: &PathResolver) -> Result<Vec<String>> {
16    resolver.list_projects().map_err(PiError::from)
17}
18
19/// List sessions for a project, newest first.
20///
21/// Each session is summarized into a [`SessionMeta`] by peeking at the header
22/// line (if any) and counting non-empty lines. Files that cannot be opened are
23/// logged to stderr and skipped — a single unreadable file does not fail the
24/// listing.
25pub 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    // Descending by timestamp, mtime tiebreaker.
127    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
136/// Open `path` and return its first non-empty line, if any.
137fn 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
149/// If the line is a `{"type":"session", ...}` header, return `(id, timestamp)`.
150fn 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
161/// Strip a leading timestamp prefix from a filename stem.
162///
163/// * Zero underscores → return full stem.
164/// * One or more underscores → return everything after the first `_`.
165fn 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
172/// Count non-empty lines in a file.
173fn 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
186/// Walk the JSONL until we find a `{"type":"message"}` entry whose
187/// `message.role == "user"` and whose content has non-empty text. Returns
188/// `None` if no user prompt is present.
189///
190/// We don't deserialize into the full `Entry` enum here — Pi messages have
191/// a varied schema and partial JSON parsing is enough for a "title" hint.
192fn 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
242/// Return the mtime of `path` formatted as RFC 3339, if it can be read.
243fn 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        // Should parse as RFC 3339.
408        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        // Should not panic, should include the file with fallback id.
491        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        // One valid session, one with un-parseable first line.
503        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        // Both files should appear; weird one falls back on filename + mtime.
514        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}