1use crate::error::{ConvoError, Result};
2use std::env;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct PathResolver {
7 home_dir: Option<PathBuf>,
8 claude_dir: Option<PathBuf>,
9}
10
11impl Default for PathResolver {
12 fn default() -> Self {
13 Self::new()
14 }
15}
16
17impl PathResolver {
18 pub fn new() -> Self {
19 let home_dir = dirs::home_dir();
20 Self {
21 home_dir,
22 claude_dir: None,
23 }
24 }
25
26 pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
27 self.home_dir = Some(home.into());
28 self
29 }
30
31 pub fn with_claude_dir<P: Into<PathBuf>>(mut self, claude_dir: P) -> Self {
32 self.claude_dir = Some(claude_dir.into());
33 self
34 }
35
36 pub fn home_dir(&self) -> Result<&Path> {
37 self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
38 }
39
40 pub fn claude_dir(&self) -> Result<PathBuf> {
41 if let Some(ref claude_dir) = self.claude_dir {
42 return Ok(claude_dir.clone());
43 }
44
45 let home = self.home_dir()?;
46 Ok(home.join(".claude"))
47 }
48
49 pub fn projects_dir(&self) -> Result<PathBuf> {
50 Ok(self.claude_dir()?.join("projects"))
51 }
52
53 pub fn history_file(&self) -> Result<PathBuf> {
54 Ok(self.claude_dir()?.join("history.jsonl"))
55 }
56
57 pub fn project_dir(&self, project_path: &str) -> Result<PathBuf> {
58 let sanitized = sanitize_project_path(project_path);
59 Ok(self.projects_dir()?.join(sanitized))
60 }
61
62 pub fn conversation_file(&self, project_path: &str, session_id: &str) -> Result<PathBuf> {
63 Ok(self
64 .project_dir(project_path)?
65 .join(format!("{}.jsonl", session_id)))
66 }
67
68 pub fn list_project_dirs(&self) -> Result<Vec<String>> {
69 let projects_dir = self.projects_dir()?;
70 if !projects_dir.exists() {
71 return Ok(Vec::new());
72 }
73
74 let mut projects = Vec::new();
75 for entry in std::fs::read_dir(&projects_dir)? {
76 let entry = entry?;
77 if entry.path().is_dir()
81 && let Some(name) = entry.file_name().to_str()
82 {
83 projects.push(unsanitize_project_path(name));
84 }
85 }
86 Ok(projects)
87 }
88
89 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
90 let project_dir = self.project_dir(project_path)?;
91 if !project_dir.exists() {
92 return Ok(Vec::new());
93 }
94
95 let mut sessions = Vec::new();
96 for entry in std::fs::read_dir(&project_dir)? {
97 let entry = entry?;
98 let path = entry.path();
99 if path.extension().and_then(|s| s.to_str()) == Some("jsonl")
100 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
101 {
102 sessions.push(stem.to_string());
103 }
104 }
105 Ok(sessions)
106 }
107
108 pub fn exists(&self) -> bool {
109 self.claude_dir().map(|p| p.exists()).unwrap_or(false)
110 }
111}
112
113fn sanitize_project_path(path: &str) -> String {
114 path.replace(['/', '_', '.'], "-")
118}
119
120pub(crate) fn unsanitize_project_path(sanitized: &str) -> String {
121 sanitized.replace('-', "/")
122}
123
124mod dirs {
125 use super::*;
126
127 pub fn home_dir() -> Option<PathBuf> {
128 env::var_os("HOME")
129 .or_else(|| env::var_os("USERPROFILE"))
130 .map(PathBuf::from)
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use std::fs;
138 use tempfile::TempDir;
139
140 #[test]
141 fn test_path_resolution() {
142 let temp = TempDir::new().unwrap();
143 let resolver = PathResolver::new()
144 .with_home(temp.path())
145 .with_claude_dir(temp.path().join(".claude"));
146
147 let claude_dir = resolver.claude_dir().unwrap();
148 assert_eq!(claude_dir, temp.path().join(".claude"));
149
150 let projects_dir = resolver.projects_dir().unwrap();
151 assert_eq!(projects_dir, temp.path().join(".claude/projects"));
152
153 let history = resolver.history_file().unwrap();
154 assert_eq!(history, temp.path().join(".claude/history.jsonl"));
155 }
156
157 #[cfg(unix)]
158 #[test]
159 fn list_project_dirs_follows_symlinked_dirs() {
160 let temp = TempDir::new().unwrap();
161 let projects = temp.path().join(".claude/projects");
162 fs::create_dir_all(projects.join("-Users-alex-real")).unwrap();
163 let elsewhere = temp.path().join("elsewhere/-Users-alex-linked");
164 fs::create_dir_all(&elsewhere).unwrap();
165 std::os::unix::fs::symlink(&elsewhere, projects.join("-Users-alex-linked")).unwrap();
166
167 let resolver = PathResolver::new()
168 .with_home(temp.path())
169 .with_claude_dir(temp.path().join(".claude"));
170 let mut dirs = resolver.list_project_dirs().unwrap();
171 dirs.sort();
172 assert_eq!(dirs, vec!["/Users/alex/linked", "/Users/alex/real"]);
173 }
174
175 #[test]
176 fn test_project_path_sanitization() {
177 assert_eq!(
178 sanitize_project_path("/Users/alex/project"),
179 "-Users-alex-project"
180 );
181 assert_eq!(
182 unsanitize_project_path("-Users-alex-project"),
183 "/Users/alex/project"
184 );
185 }
186
187 #[test]
188 fn test_project_path_sanitization_with_dots() {
189 assert_eq!(
192 sanitize_project_path("/Users/alex/code/github.com/x/repo/.claude/worktrees/foo"),
193 "-Users-alex-code-github-com-x-repo--claude-worktrees-foo"
194 );
195 }
196
197 #[test]
198 fn test_conversation_file_path() {
199 let temp = TempDir::new().unwrap();
200 let resolver = PathResolver::new().with_claude_dir(temp.path());
201
202 let convo_file = resolver
203 .conversation_file("/Users/alex/project", "session-123")
204 .unwrap();
205
206 assert_eq!(
207 convo_file,
208 temp.path()
209 .join("projects/-Users-alex-project/session-123.jsonl")
210 );
211 }
212
213 #[test]
214 fn test_list_projects() {
215 let temp = TempDir::new().unwrap();
216 let projects_dir = temp.path().join("projects");
217 fs::create_dir_all(&projects_dir).unwrap();
218 fs::create_dir(projects_dir.join("-Users-alex-project1")).unwrap();
219 fs::create_dir(projects_dir.join("-Users-bob-project2")).unwrap();
220
221 let resolver = PathResolver::new().with_claude_dir(temp.path());
222 let projects = resolver.list_project_dirs().unwrap();
223
224 assert_eq!(projects.len(), 2);
225 assert!(projects.contains(&"/Users/alex/project1".to_string()));
226 assert!(projects.contains(&"/Users/bob/project2".to_string()));
227 }
228
229 #[test]
230 fn test_list_projects_empty() {
231 let temp = TempDir::new().unwrap();
232 let projects_dir = temp.path().join("projects");
233 fs::create_dir_all(&projects_dir).unwrap();
234
235 let resolver = PathResolver::new().with_claude_dir(temp.path());
236 let projects = resolver.list_project_dirs().unwrap();
237 assert!(projects.is_empty());
238 }
239
240 #[test]
241 fn test_list_projects_no_dir() {
242 let temp = TempDir::new().unwrap();
243 let resolver = PathResolver::new().with_claude_dir(temp.path());
245 let projects = resolver.list_project_dirs().unwrap();
246 assert!(projects.is_empty());
247 }
248
249 #[test]
250 fn test_list_conversations() {
251 let temp = TempDir::new().unwrap();
252 let project_dir = temp.path().join("projects/-test-project");
253 fs::create_dir_all(&project_dir).unwrap();
254 fs::write(project_dir.join("session-1.jsonl"), "{}").unwrap();
255 fs::write(project_dir.join("session-2.jsonl"), "{}").unwrap();
256 fs::write(project_dir.join("not-jsonl.txt"), "{}").unwrap();
257
258 let resolver = PathResolver::new().with_claude_dir(temp.path());
259 let sessions = resolver.list_conversations("/test/project").unwrap();
260 assert_eq!(sessions.len(), 2);
261 assert!(sessions.contains(&"session-1".to_string()));
262 assert!(sessions.contains(&"session-2".to_string()));
263 }
264
265 #[test]
266 fn test_list_conversations_empty_project() {
267 let temp = TempDir::new().unwrap();
268 let project_dir = temp.path().join("projects/-test-project");
269 fs::create_dir_all(&project_dir).unwrap();
270
271 let resolver = PathResolver::new().with_claude_dir(temp.path());
272 let sessions = resolver.list_conversations("/test/project").unwrap();
273 assert!(sessions.is_empty());
274 }
275
276 #[test]
277 fn test_list_conversations_no_project() {
278 let temp = TempDir::new().unwrap();
279 let resolver = PathResolver::new().with_claude_dir(temp.path());
280 let sessions = resolver.list_conversations("/nonexistent/project").unwrap();
281 assert!(sessions.is_empty());
282 }
283
284 #[test]
285 fn test_exists() {
286 let temp = TempDir::new().unwrap();
287 let resolver = PathResolver::new().with_claude_dir(temp.path());
288 assert!(resolver.exists());
289
290 let resolver2 = PathResolver::new().with_claude_dir("/nonexistent/dir");
291 assert!(!resolver2.exists());
292 }
293
294 #[test]
295 fn test_with_home() {
296 let resolver = PathResolver::new().with_home("/custom/home");
297 assert_eq!(
298 resolver.home_dir().unwrap().to_str().unwrap(),
299 "/custom/home"
300 );
301 }
302
303 #[test]
304 fn test_history_file() {
305 let temp = TempDir::new().unwrap();
306 let resolver = PathResolver::new().with_claude_dir(temp.path());
307 let hist = resolver.history_file().unwrap();
308 assert!(hist.ends_with("history.jsonl"));
309 }
310
311 #[test]
312 fn test_default_impl() {
313 let resolver = PathResolver::default();
314 let _ = resolver.claude_dir();
316 }
317}