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 converts both '/' and '_' to '-' when creating project directories
112    path.replace(['/', '_'], "-")
113}
114
115fn unsanitize_project_path(sanitized: &str) -> String {
116    sanitized.replace('-', "/")
117}
118
119mod dirs {
120    use super::*;
121
122    pub fn home_dir() -> Option<PathBuf> {
123        env::var_os("HOME")
124            .or_else(|| env::var_os("USERPROFILE"))
125            .map(PathBuf::from)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::fs;
133    use tempfile::TempDir;
134
135    #[test]
136    fn test_path_resolution() {
137        let temp = TempDir::new().unwrap();
138        let resolver = PathResolver::new()
139            .with_home(temp.path())
140            .with_claude_dir(temp.path().join(".claude"));
141
142        let claude_dir = resolver.claude_dir().unwrap();
143        assert_eq!(claude_dir, temp.path().join(".claude"));
144
145        let projects_dir = resolver.projects_dir().unwrap();
146        assert_eq!(projects_dir, temp.path().join(".claude/projects"));
147
148        let history = resolver.history_file().unwrap();
149        assert_eq!(history, temp.path().join(".claude/history.jsonl"));
150    }
151
152    #[test]
153    fn test_project_path_sanitization() {
154        assert_eq!(
155            sanitize_project_path("/Users/alex/project"),
156            "-Users-alex-project"
157        );
158        assert_eq!(
159            unsanitize_project_path("-Users-alex-project"),
160            "/Users/alex/project"
161        );
162    }
163
164    #[test]
165    fn test_conversation_file_path() {
166        let temp = TempDir::new().unwrap();
167        let resolver = PathResolver::new().with_claude_dir(temp.path());
168
169        let convo_file = resolver
170            .conversation_file("/Users/alex/project", "session-123")
171            .unwrap();
172
173        assert_eq!(
174            convo_file,
175            temp.path()
176                .join("projects/-Users-alex-project/session-123.jsonl")
177        );
178    }
179
180    #[test]
181    fn test_list_projects() {
182        let temp = TempDir::new().unwrap();
183        let projects_dir = temp.path().join("projects");
184        fs::create_dir_all(&projects_dir).unwrap();
185        fs::create_dir(projects_dir.join("-Users-alex-project1")).unwrap();
186        fs::create_dir(projects_dir.join("-Users-bob-project2")).unwrap();
187
188        let resolver = PathResolver::new().with_claude_dir(temp.path());
189        let projects = resolver.list_project_dirs().unwrap();
190
191        assert_eq!(projects.len(), 2);
192        assert!(projects.contains(&"/Users/alex/project1".to_string()));
193        assert!(projects.contains(&"/Users/bob/project2".to_string()));
194    }
195
196    #[test]
197    fn test_list_projects_empty() {
198        let temp = TempDir::new().unwrap();
199        let projects_dir = temp.path().join("projects");
200        fs::create_dir_all(&projects_dir).unwrap();
201
202        let resolver = PathResolver::new().with_claude_dir(temp.path());
203        let projects = resolver.list_project_dirs().unwrap();
204        assert!(projects.is_empty());
205    }
206
207    #[test]
208    fn test_list_projects_no_dir() {
209        let temp = TempDir::new().unwrap();
210        // Don't create projects dir
211        let resolver = PathResolver::new().with_claude_dir(temp.path());
212        let projects = resolver.list_project_dirs().unwrap();
213        assert!(projects.is_empty());
214    }
215
216    #[test]
217    fn test_list_conversations() {
218        let temp = TempDir::new().unwrap();
219        let project_dir = temp.path().join("projects/-test-project");
220        fs::create_dir_all(&project_dir).unwrap();
221        fs::write(project_dir.join("session-1.jsonl"), "{}").unwrap();
222        fs::write(project_dir.join("session-2.jsonl"), "{}").unwrap();
223        fs::write(project_dir.join("not-jsonl.txt"), "{}").unwrap();
224
225        let resolver = PathResolver::new().with_claude_dir(temp.path());
226        let sessions = resolver.list_conversations("/test/project").unwrap();
227        assert_eq!(sessions.len(), 2);
228        assert!(sessions.contains(&"session-1".to_string()));
229        assert!(sessions.contains(&"session-2".to_string()));
230    }
231
232    #[test]
233    fn test_list_conversations_empty_project() {
234        let temp = TempDir::new().unwrap();
235        let project_dir = temp.path().join("projects/-test-project");
236        fs::create_dir_all(&project_dir).unwrap();
237
238        let resolver = PathResolver::new().with_claude_dir(temp.path());
239        let sessions = resolver.list_conversations("/test/project").unwrap();
240        assert!(sessions.is_empty());
241    }
242
243    #[test]
244    fn test_list_conversations_no_project() {
245        let temp = TempDir::new().unwrap();
246        let resolver = PathResolver::new().with_claude_dir(temp.path());
247        let sessions = resolver.list_conversations("/nonexistent/project").unwrap();
248        assert!(sessions.is_empty());
249    }
250
251    #[test]
252    fn test_exists() {
253        let temp = TempDir::new().unwrap();
254        let resolver = PathResolver::new().with_claude_dir(temp.path());
255        assert!(resolver.exists());
256
257        let resolver2 = PathResolver::new().with_claude_dir("/nonexistent/dir");
258        assert!(!resolver2.exists());
259    }
260
261    #[test]
262    fn test_with_home() {
263        let resolver = PathResolver::new().with_home("/custom/home");
264        assert_eq!(
265            resolver.home_dir().unwrap().to_str().unwrap(),
266            "/custom/home"
267        );
268    }
269
270    #[test]
271    fn test_history_file() {
272        let temp = TempDir::new().unwrap();
273        let resolver = PathResolver::new().with_claude_dir(temp.path());
274        let hist = resolver.history_file().unwrap();
275        assert!(hist.ends_with("history.jsonl"));
276    }
277
278    #[test]
279    fn test_default_impl() {
280        let resolver = PathResolver::default();
281        // Should not panic, just use system home dir
282        let _ = resolver.claude_dir();
283    }
284}