Rust · 3375 bytes Raw Blame History
1 //! Walk `~/.claude/projects/` and enumerate sessions. Pure filesystem
2 //! work — no JSON parsing. Each returned [`ProjectDir`] carries the
3 //! encoded directory name, absolute path, and the list of `.jsonl`
4 //! session files it contains.
5
6 use std::fs;
7 use std::path::{Path, PathBuf};
8
9 use crate::core::error::CoreResult;
10
11 /// One encoded-cwd directory discovered under `~/.claude/projects/`.
12 #[derive(Debug, Clone)]
13 pub struct ProjectDir {
14 /// Stable key — the directory's own filename, e.g.
15 /// `-Users-alice-Documents-claudex`.
16 pub id: String,
17 pub path: PathBuf,
18 /// All `.jsonl` session files inside this project, unsorted.
19 pub sessions: Vec<PathBuf>,
20 }
21
22 /// Enumerate all project directories under `projects_root`. If the root
23 /// doesn't exist (fresh Claude Code install, or tests), returns `Ok([])`.
24 pub fn scan_projects(projects_root: &Path) -> CoreResult<Vec<ProjectDir>> {
25 if !projects_root.exists() {
26 return Ok(Vec::new());
27 }
28
29 let mut out = Vec::new();
30 for entry in fs::read_dir(projects_root)? {
31 let entry = entry?;
32 if !entry.file_type()?.is_dir() {
33 continue;
34 }
35
36 let id = entry.file_name().to_string_lossy().into_owned();
37 let path = entry.path();
38 let sessions = list_session_files(&path)?;
39
40 out.push(ProjectDir { id, path, sessions });
41 }
42
43 Ok(out)
44 }
45
46 /// Enumerate just the `.jsonl` files inside a single project dir. UUID
47 /// subdirectories and `memory/` are skipped.
48 pub fn list_session_files(project_dir: &Path) -> CoreResult<Vec<PathBuf>> {
49 let mut sessions = Vec::new();
50 for entry in fs::read_dir(project_dir)? {
51 let entry = entry?;
52 let path = entry.path();
53 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
54 sessions.push(path);
55 }
56 }
57 Ok(sessions)
58 }
59
60 #[cfg(test)]
61 mod tests {
62 use super::*;
63 use std::fs::File;
64 use tempfile::tempdir;
65
66 #[test]
67 fn scans_empty_root() {
68 let tmp = tempdir().unwrap();
69 let projects = tmp.path().join("projects");
70 // Root doesn't exist — should return empty, not error.
71 let result = scan_projects(&projects).unwrap();
72 assert!(result.is_empty());
73 }
74
75 #[test]
76 fn scans_project_with_sessions() {
77 let tmp = tempdir().unwrap();
78 let projects = tmp.path().join("projects");
79 let proj = projects.join("-Users-test-repo");
80 std::fs::create_dir_all(&proj).unwrap();
81 File::create(proj.join("a.jsonl")).unwrap();
82 File::create(proj.join("b.jsonl")).unwrap();
83 // Noise that should be ignored:
84 File::create(proj.join("note.txt")).unwrap();
85 std::fs::create_dir(proj.join("memory")).unwrap();
86 std::fs::create_dir(proj.join("a")).unwrap();
87
88 let result = scan_projects(&projects).unwrap();
89 assert_eq!(result.len(), 1);
90 assert_eq!(result[0].id, "-Users-test-repo");
91 assert_eq!(result[0].sessions.len(), 2);
92 }
93
94 #[test]
95 fn skips_files_at_projects_root() {
96 let tmp = tempdir().unwrap();
97 let projects = tmp.path().join("projects");
98 std::fs::create_dir_all(&projects).unwrap();
99 File::create(projects.join("stray.jsonl")).unwrap();
100
101 let result = scan_projects(&projects).unwrap();
102 assert!(result.is_empty());
103 }
104 }
105