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            if entry.file_type()?.is_dir()
78                && let Some(name) = entry.file_name().to_str()
79            {
80                projects.push(unsanitize_project_path(name));
81            }
82        }
83        Ok(projects)
84    }
85
86    pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
87        let project_dir = self.project_dir(project_path)?;
88        if !project_dir.exists() {
89            return Ok(Vec::new());
90        }
91
92        let mut sessions = Vec::new();
93        for entry in std::fs::read_dir(&project_dir)? {
94            let entry = entry?;
95            let path = entry.path();
96            if path.extension().and_then(|s| s.to_str()) == Some("jsonl")
97                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
98            {
99                sessions.push(stem.to_string());
100            }
101        }
102        Ok(sessions)
103    }
104
105    pub fn exists(&self) -> bool {
106        self.claude_dir().map(|p| p.exists()).unwrap_or(false)
107    }
108}
109
110fn sanitize_project_path(path: &str) -> String {
111    // Claude Code maps '/', '_', and '.' to '-' when creating project
112    // directories. Notably, paths under dotdirs like `.claude/worktrees/…`
113    // double-up the dash (the leading `/.` becomes `--`).
114    path.replace(['/', '_', '.'], "-")
115}
116
117fn unsanitize_project_path(sanitized: &str) -> String {
118    sanitized.replace('-', "/")
119}
120
121mod dirs {
122    use super::*;
123
124    pub fn home_dir() -> Option<PathBuf> {
125        env::var_os("HOME")
126            .or_else(|| env::var_os("USERPROFILE"))
127            .map(PathBuf::from)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::fs;
135    use tempfile::TempDir;
136
137    #[test]
138    fn test_path_resolution() {
139        let temp = TempDir::new().unwrap();
140        let resolver = PathResolver::new()
141            .with_home(temp.path())
142            .with_claude_dir(temp.path().join(".claude"));
143
144        let claude_dir = resolver.claude_dir().unwrap();
145        assert_eq!(claude_dir, temp.path().join(".claude"));
146
147        let projects_dir = resolver.projects_dir().unwrap();
148        assert_eq!(projects_dir, temp.path().join(".claude/projects"));
149
150        let history = resolver.history_file().unwrap();
151        assert_eq!(history, temp.path().join(".claude/history.jsonl"));
152    }
153
154    #[test]
155    fn test_project_path_sanitization() {
156        assert_eq!(
157            sanitize_project_path("/Users/alex/project"),
158            "-Users-alex-project"
159        );
160        assert_eq!(
161            unsanitize_project_path("-Users-alex-project"),
162            "/Users/alex/project"
163        );
164    }
165
166    #[test]
167    fn test_project_path_sanitization_with_dots() {
168        // Paths under dotted directories (.claude/worktrees, github.com/…) must
169        // be encoded the same way Claude Code does — every '.' becomes '-'.
170        assert_eq!(
171            sanitize_project_path("/Users/alex/code/github.com/x/repo/.claude/worktrees/foo"),
172            "-Users-alex-code-github-com-x-repo--claude-worktrees-foo"
173        );
174    }
175
176    #[test]
177    fn test_conversation_file_path() {
178        let temp = TempDir::new().unwrap();
179        let resolver = PathResolver::new().with_claude_dir(temp.path());
180
181        let convo_file = resolver
182            .conversation_file("/Users/alex/project", "session-123")
183            .unwrap();
184
185        assert_eq!(
186            convo_file,
187            temp.path()
188                .join("projects/-Users-alex-project/session-123.jsonl")
189        );
190    }
191
192    #[test]
193    fn test_list_projects() {
194        let temp = TempDir::new().unwrap();
195        let projects_dir = temp.path().join("projects");
196        fs::create_dir_all(&projects_dir).unwrap();
197        fs::create_dir(projects_dir.join("-Users-alex-project1")).unwrap();
198        fs::create_dir(projects_dir.join("-Users-bob-project2")).unwrap();
199
200        let resolver = PathResolver::new().with_claude_dir(temp.path());
201        let projects = resolver.list_project_dirs().unwrap();
202
203        assert_eq!(projects.len(), 2);
204        assert!(projects.contains(&"/Users/alex/project1".to_string()));
205        assert!(projects.contains(&"/Users/bob/project2".to_string()));
206    }
207
208    #[test]
209    fn test_list_projects_empty() {
210        let temp = TempDir::new().unwrap();
211        let projects_dir = temp.path().join("projects");
212        fs::create_dir_all(&projects_dir).unwrap();
213
214        let resolver = PathResolver::new().with_claude_dir(temp.path());
215        let projects = resolver.list_project_dirs().unwrap();
216        assert!(projects.is_empty());
217    }
218
219    #[test]
220    fn test_list_projects_no_dir() {
221        let temp = TempDir::new().unwrap();
222        // Don't create projects dir
223        let resolver = PathResolver::new().with_claude_dir(temp.path());
224        let projects = resolver.list_project_dirs().unwrap();
225        assert!(projects.is_empty());
226    }
227
228    #[test]
229    fn test_list_conversations() {
230        let temp = TempDir::new().unwrap();
231        let project_dir = temp.path().join("projects/-test-project");
232        fs::create_dir_all(&project_dir).unwrap();
233        fs::write(project_dir.join("session-1.jsonl"), "{}").unwrap();
234        fs::write(project_dir.join("session-2.jsonl"), "{}").unwrap();
235        fs::write(project_dir.join("not-jsonl.txt"), "{}").unwrap();
236
237        let resolver = PathResolver::new().with_claude_dir(temp.path());
238        let sessions = resolver.list_conversations("/test/project").unwrap();
239        assert_eq!(sessions.len(), 2);
240        assert!(sessions.contains(&"session-1".to_string()));
241        assert!(sessions.contains(&"session-2".to_string()));
242    }
243
244    #[test]
245    fn test_list_conversations_empty_project() {
246        let temp = TempDir::new().unwrap();
247        let project_dir = temp.path().join("projects/-test-project");
248        fs::create_dir_all(&project_dir).unwrap();
249
250        let resolver = PathResolver::new().with_claude_dir(temp.path());
251        let sessions = resolver.list_conversations("/test/project").unwrap();
252        assert!(sessions.is_empty());
253    }
254
255    #[test]
256    fn test_list_conversations_no_project() {
257        let temp = TempDir::new().unwrap();
258        let resolver = PathResolver::new().with_claude_dir(temp.path());
259        let sessions = resolver.list_conversations("/nonexistent/project").unwrap();
260        assert!(sessions.is_empty());
261    }
262
263    #[test]
264    fn test_exists() {
265        let temp = TempDir::new().unwrap();
266        let resolver = PathResolver::new().with_claude_dir(temp.path());
267        assert!(resolver.exists());
268
269        let resolver2 = PathResolver::new().with_claude_dir("/nonexistent/dir");
270        assert!(!resolver2.exists());
271    }
272
273    #[test]
274    fn test_with_home() {
275        let resolver = PathResolver::new().with_home("/custom/home");
276        assert_eq!(
277            resolver.home_dir().unwrap().to_str().unwrap(),
278            "/custom/home"
279        );
280    }
281
282    #[test]
283    fn test_history_file() {
284        let temp = TempDir::new().unwrap();
285        let resolver = PathResolver::new().with_claude_dir(temp.path());
286        let hist = resolver.history_file().unwrap();
287        assert!(hist.ends_with("history.jsonl"));
288    }
289
290    #[test]
291    fn test_default_impl() {
292        let resolver = PathResolver::default();
293        // Should not panic, just use system home dir
294        let _ = resolver.claude_dir();
295    }
296}