Skip to main content

toolpath_claude/
paths.rs

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            // `path().is_dir()` follows symlinks where
78            // `entry.file_type()` would report the link itself,
79            // silently dropping symlinked project dirs.
80            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    // Claude Code maps '/', '_', and '.' to '-' when creating project
115    // directories. Notably, paths under dotdirs like `.claude/worktrees/…`
116    // double-up the dash (the leading `/.` becomes `--`).
117    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        // Paths under dotted directories (.claude/worktrees, github.com/…) must
190        // be encoded the same way Claude Code does — every '.' becomes '-'.
191        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        // Don't create projects dir
244        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        // Should not panic, just use system home dir
315        let _ = resolver.claude_dir();
316    }
317}