Skip to main content

tj_core/session/
discovery.rs

1//! Discover Claude Code session JSONL files for a project.
2//!
3//! Sessions live at `~/.claude/projects/<encoded-path>/<uuid>.jsonl`.
4//! The encoded path replaces non-alphanumeric chars (except `-`) with `-`.
5
6use std::path::{Path, PathBuf};
7
8/// Resolve the Claude Code config directory.
9/// Uses `CLAUDE_CONFIG_DIR` env if set, otherwise `~/.claude`.
10pub fn claude_config_dir() -> anyhow::Result<PathBuf> {
11    if let Ok(custom) = std::env::var("CLAUDE_CONFIG_DIR") {
12        if !custom.is_empty() {
13            return Ok(PathBuf::from(custom));
14        }
15    }
16    let home = dirs_home()?;
17    Ok(home.join(".claude"))
18}
19
20/// Get the projects directory where session files live.
21pub fn projects_dir() -> anyhow::Result<PathBuf> {
22    Ok(claude_config_dir()?.join("projects"))
23}
24
25/// Encode a filesystem path into the Claude Code project directory name format.
26/// Non-alphanumeric chars (except `-`) are replaced with `-`.
27pub fn encode_project_path(path: &str) -> String {
28    path.chars()
29        .map(|c| {
30            if c.is_alphanumeric() || c == '-' {
31                c
32            } else {
33                '-'
34            }
35        })
36        .collect()
37}
38
39/// Find the project directory for a given filesystem path.
40/// Tries exact match first, then prefix match for worktree variants.
41pub fn find_project_dir(project_path: &Path) -> anyhow::Result<Option<PathBuf>> {
42    let projects = projects_dir()?;
43    if !projects.exists() {
44        return Ok(None);
45    }
46
47    let encoded = encode_project_path(&project_path.to_string_lossy());
48
49    // Try exact match first.
50    let exact = projects.join(&encoded);
51    if exact.is_dir() {
52        return Ok(Some(exact));
53    }
54
55    // Try case-insensitive match (WSL paths can differ in case).
56    let encoded_lower = encoded.to_lowercase();
57    if let Ok(entries) = std::fs::read_dir(&projects) {
58        for entry in entries.flatten() {
59            let name = entry.file_name().to_string_lossy().to_string();
60            if name.to_lowercase() == encoded_lower && entry.path().is_dir() {
61                return Ok(Some(entry.path()));
62            }
63        }
64    }
65
66    Ok(None)
67}
68
69/// List all session JSONL files in a project directory.
70/// Excludes agent files (starting with `agent-`).
71/// Returns files sorted by modification time (newest first).
72pub fn list_sessions(project_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
73    let mut sessions: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
74
75    for entry in std::fs::read_dir(project_dir)? {
76        let entry = entry?;
77        let path = entry.path();
78        let name = entry.file_name().to_string_lossy().to_string();
79
80        if !name.ends_with(".jsonl") {
81            continue;
82        }
83        // Skip agent sessions.
84        if name.starts_with("agent-") {
85            continue;
86        }
87
88        let mtime = entry
89            .metadata()
90            .and_then(|m| m.modified())
91            .unwrap_or(std::time::UNIX_EPOCH);
92
93        sessions.push((path, mtime));
94    }
95
96    // Sort newest first.
97    sessions.sort_by_key(|s| std::cmp::Reverse(s.1));
98    Ok(sessions.into_iter().map(|(p, _)| p).collect())
99}
100
101/// List all project directories in Claude Code config.
102pub fn list_all_projects() -> anyhow::Result<Vec<(String, PathBuf)>> {
103    let projects = projects_dir()?;
104    if !projects.exists() {
105        return Ok(vec![]);
106    }
107
108    let mut result = Vec::new();
109    for entry in std::fs::read_dir(&projects)? {
110        let entry = entry?;
111        if entry.path().is_dir() {
112            let name = entry.file_name().to_string_lossy().to_string();
113            // Decode the project name back to a readable path.
114            let decoded = decode_project_path(&name);
115            result.push((decoded, entry.path()));
116        }
117    }
118    result.sort_by(|a, b| a.0.cmp(&b.0));
119    Ok(result)
120}
121
122/// Decode an encoded project directory name back to a readable path.
123/// This is approximate — we can't distinguish `-` from original `/`.
124fn decode_project_path(encoded: &str) -> String {
125    // Common pattern: leading `--` means the path started with a path separator.
126    // Replace double dashes carefully.
127    encoded.to_string()
128}
129
130fn dirs_home() -> anyhow::Result<PathBuf> {
131    directories::BaseDirs::new()
132        .map(|d| d.home_dir().to_path_buf())
133        .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::sync::Mutex;
140
141    /// Serialize every test that touches `CLAUDE_CONFIG_DIR`. Cargo runs
142    /// unit tests in parallel by default; two tests mutating the same
143    /// process env race (set in A, observed in B) and flaked Windows CI
144    /// (saw "C:\Users\runneradmin\.claude" when expecting the override).
145    /// Tests that touch the env take this lock before the first set_var.
146    /// `lock().unwrap_or_else(|p| p.into_inner())` swallows poisoning
147    /// from a panicking sibling test — env is restored regardless.
148    static ENV_LOCK: Mutex<()> = Mutex::new(());
149
150    #[test]
151    fn encode_path_replaces_separators() {
152        let encoded = encode_project_path("/home/user/project");
153        assert_eq!(encoded, "-home-user-project");
154    }
155
156    #[test]
157    fn encode_preserves_dashes() {
158        let encoded = encode_project_path("/home/my-project");
159        assert_eq!(encoded, "-home-my-project");
160    }
161
162    #[test]
163    fn encode_wsl_path() {
164        let encoded = encode_project_path("\\\\wsl.localhost\\ubuntu\\home\\user\\project");
165        assert_eq!(encoded, "--wsl-localhost-ubuntu-home-user-project");
166    }
167
168    // --- list_sessions() ---
169
170    #[test]
171    fn list_sessions_returns_jsonl_files_skipping_agent_files() {
172        let dir = tempfile::tempdir().unwrap();
173        // Create regular session files.
174        std::fs::write(dir.path().join("sess-001.jsonl"), "{}").unwrap();
175        std::fs::write(dir.path().join("sess-002.jsonl"), "{}").unwrap();
176        // Create agent files that should be skipped.
177        std::fs::write(dir.path().join("agent-abc.jsonl"), "{}").unwrap();
178        std::fs::write(dir.path().join("agent-def.jsonl"), "{}").unwrap();
179        // Create non-jsonl files that should be skipped.
180        std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
181        std::fs::write(dir.path().join("data.json"), "{}").unwrap();
182
183        let sessions = list_sessions(dir.path()).unwrap();
184        assert_eq!(sessions.len(), 2);
185        let names: Vec<String> = sessions
186            .iter()
187            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
188            .collect();
189        assert!(names.contains(&"sess-001.jsonl".to_string()));
190        assert!(names.contains(&"sess-002.jsonl".to_string()));
191        assert!(!names.iter().any(|n| n.starts_with("agent-")));
192    }
193
194    #[test]
195    fn list_sessions_sorted_by_mtime_newest_first() {
196        let dir = tempfile::tempdir().unwrap();
197
198        // Create files with different modification times.
199        let older = dir.path().join("older.jsonl");
200        std::fs::write(&older, "{}").unwrap();
201
202        // Sleep briefly to ensure different mtime.
203        std::thread::sleep(std::time::Duration::from_millis(50));
204
205        let newer = dir.path().join("newer.jsonl");
206        std::fs::write(&newer, "{}").unwrap();
207
208        let sessions = list_sessions(dir.path()).unwrap();
209        assert_eq!(sessions.len(), 2);
210        // Newest file should come first.
211        let first_name = sessions[0]
212            .file_name()
213            .unwrap()
214            .to_string_lossy()
215            .to_string();
216        assert_eq!(first_name, "newer.jsonl");
217    }
218
219    #[test]
220    fn list_sessions_empty_directory() {
221        let dir = tempfile::tempdir().unwrap();
222        let sessions = list_sessions(dir.path()).unwrap();
223        assert!(sessions.is_empty());
224    }
225
226    #[test]
227    fn list_sessions_nonexistent_directory() {
228        let result = list_sessions(Path::new("/nonexistent/path/xyz"));
229        assert!(result.is_err());
230    }
231
232    // --- list_all_projects() ---
233
234    #[test]
235    fn list_all_projects_with_temp_dir() {
236        let dir = tempfile::tempdir().unwrap();
237        // Override CLAUDE_CONFIG_DIR for this test.
238        let config_dir = dir.path();
239        let projects = config_dir.join("projects");
240        std::fs::create_dir_all(&projects).unwrap();
241
242        // Create project directories.
243        std::fs::create_dir(projects.join("-home-user-project-alpha")).unwrap();
244        std::fs::create_dir(projects.join("-home-user-project-beta")).unwrap();
245        // Create a file (should be skipped — not a directory).
246        std::fs::write(projects.join("not-a-dir.txt"), "").unwrap();
247
248        // We can't easily test list_all_projects() because it uses projects_dir()
249        // which reads CLAUDE_CONFIG_DIR. Instead, test the directory listing logic directly.
250        let mut result = Vec::new();
251        for entry in std::fs::read_dir(&projects).unwrap() {
252            let entry = entry.unwrap();
253            if entry.path().is_dir() {
254                let name = entry.file_name().to_string_lossy().to_string();
255                let decoded = decode_project_path(&name);
256                result.push((decoded, entry.path()));
257            }
258        }
259        result.sort_by(|a, b| a.0.cmp(&b.0));
260
261        assert_eq!(result.len(), 2);
262        assert!(result[0].0.contains("alpha"));
263        assert!(result[1].0.contains("beta"));
264    }
265
266    // --- find_project_dir() with CLAUDE_CONFIG_DIR env override ---
267
268    #[test]
269    fn find_project_dir_with_env_override() {
270        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
271        let dir = tempfile::tempdir().unwrap();
272        let projects = dir.path().join("projects");
273        std::fs::create_dir_all(&projects).unwrap();
274
275        // Create a project directory matching an encoded path.
276        let encoded = encode_project_path("/home/user/myproject");
277        std::fs::create_dir(projects.join(&encoded)).unwrap();
278
279        // Set the env override.
280        std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().to_str().unwrap());
281
282        let result = find_project_dir(Path::new("/home/user/myproject"));
283
284        // Clean up env before assertions (to avoid affecting other tests).
285        std::env::remove_var("CLAUDE_CONFIG_DIR");
286
287        let found = result.unwrap();
288        assert!(found.is_some());
289        let found_path = found.unwrap();
290        assert!(found_path.ends_with(&encoded));
291    }
292
293    #[test]
294    fn find_project_dir_returns_none_when_no_match() {
295        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
296        let dir = tempfile::tempdir().unwrap();
297        let projects = dir.path().join("projects");
298        std::fs::create_dir_all(&projects).unwrap();
299
300        std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().to_str().unwrap());
301
302        let result = find_project_dir(Path::new("/nonexistent/project"));
303
304        std::env::remove_var("CLAUDE_CONFIG_DIR");
305
306        assert!(result.unwrap().is_none());
307    }
308
309    #[test]
310    fn find_project_dir_returns_none_when_projects_dir_missing() {
311        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
312        let dir = tempfile::tempdir().unwrap();
313        // Don't create a "projects" subdir — it doesn't exist.
314
315        std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().to_str().unwrap());
316
317        let result = find_project_dir(Path::new("/home/user/myproject"));
318
319        std::env::remove_var("CLAUDE_CONFIG_DIR");
320
321        assert!(result.unwrap().is_none());
322    }
323
324    // --- decode_project_path ---
325
326    #[test]
327    fn decode_project_path_returns_same_string() {
328        // Current implementation is identity — just verify it doesn't panic.
329        let decoded = decode_project_path("-home-user-project");
330        assert_eq!(decoded, "-home-user-project");
331    }
332
333    // --- claude_config_dir ---
334
335    /// Both `CLAUDE_CONFIG_DIR` cases are combined into one test so the
336    /// env-var read/write/restore steps run serially. Cargo runs unit
337    /// tests in parallel by default; two tests touching the same process
338    /// env on Windows raced (set in test A, observed in test B) and
339    /// flaked CI. Save → set → assert → restore inside one body makes
340    /// the dependency local. Uses a portable tempdir-style path rather
341    /// than the hardcoded "/tmp/..." that doesn't exist on Windows.
342    #[test]
343    fn claude_config_dir_handles_env_var() {
344        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
345        // SAFETY: the ENV_LOCK above serializes us against the other
346        // CLAUDE_CONFIG_DIR tests in this module; prev → restore at
347        // the end gives a clean exit regardless of panic.
348        let prev = std::env::var_os("CLAUDE_CONFIG_DIR");
349
350        // Case 1: non-empty value is honored verbatim. Use a portable
351        // path (std::env::temp_dir() works on Linux/macOS/Windows).
352        let custom = std::env::temp_dir().join("tj-custom-claude-config");
353        unsafe {
354            std::env::set_var("CLAUDE_CONFIG_DIR", &custom);
355        }
356        let dir = claude_config_dir().unwrap();
357        assert_eq!(dir, custom);
358
359        // Case 2: empty value falls back to home + .claude.
360        unsafe {
361            std::env::set_var("CLAUDE_CONFIG_DIR", "");
362        }
363        let dir = claude_config_dir().unwrap();
364        assert!(
365            dir.to_string_lossy().ends_with(".claude"),
366            "fallback must land in <home>/.claude, got: {dir:?}"
367        );
368
369        // Restore.
370        unsafe {
371            match prev {
372                Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v),
373                None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
374            }
375        }
376    }
377}