tj_core/session/
discovery.rs1use std::path::{Path, PathBuf};
7
8pub 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
20pub fn projects_dir() -> anyhow::Result<PathBuf> {
22 Ok(claude_config_dir()?.join("projects"))
23}
24
25pub 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
39pub 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 let exact = projects.join(&encoded);
51 if exact.is_dir() {
52 return Ok(Some(exact));
53 }
54
55 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
69pub 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 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 sessions.sort_by_key(|s| std::cmp::Reverse(s.1));
98 Ok(sessions.into_iter().map(|(p, _)| p).collect())
99}
100
101pub 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 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
122fn decode_project_path(encoded: &str) -> String {
125 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 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 #[test]
171 fn list_sessions_returns_jsonl_files_skipping_agent_files() {
172 let dir = tempfile::tempdir().unwrap();
173 std::fs::write(dir.path().join("sess-001.jsonl"), "{}").unwrap();
175 std::fs::write(dir.path().join("sess-002.jsonl"), "{}").unwrap();
176 std::fs::write(dir.path().join("agent-abc.jsonl"), "{}").unwrap();
178 std::fs::write(dir.path().join("agent-def.jsonl"), "{}").unwrap();
179 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 let older = dir.path().join("older.jsonl");
200 std::fs::write(&older, "{}").unwrap();
201
202 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 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 #[test]
235 fn list_all_projects_with_temp_dir() {
236 let dir = tempfile::tempdir().unwrap();
237 let config_dir = dir.path();
239 let projects = config_dir.join("projects");
240 std::fs::create_dir_all(&projects).unwrap();
241
242 std::fs::create_dir(projects.join("-home-user-project-alpha")).unwrap();
244 std::fs::create_dir(projects.join("-home-user-project-beta")).unwrap();
245 std::fs::write(projects.join("not-a-dir.txt"), "").unwrap();
247
248 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 #[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 let encoded = encode_project_path("/home/user/myproject");
277 std::fs::create_dir(projects.join(&encoded)).unwrap();
278
279 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 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 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 #[test]
327 fn decode_project_path_returns_same_string() {
328 let decoded = decode_project_path("-home-user-project");
330 assert_eq!(decoded, "-home-user-project");
331 }
332
333 #[test]
343 fn claude_config_dir_handles_env_var() {
344 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
345 let prev = std::env::var_os("CLAUDE_CONFIG_DIR");
349
350 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 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 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}